Add animated gif support (#825)

This commit is contained in:
Stefan Ceriu
2023-04-24 17:44:58 +03:00
committed by GitHub
parent 50f674a111
commit 5a14c700cc
10 changed files with 157 additions and 65 deletions
@@ -36,6 +36,14 @@ struct LoadableAvatarImage: View {
} }
var body: some View { var body: some View {
avatar
.frame(width: frameSize, height: frameSize)
.clipShape(Circle())
}
@ViewBuilder
var avatar: some View {
if let url {
LoadableImage(url: url, LoadableImage(url: url,
size: avatarSize.scaledSize, size: avatarSize.scaledSize,
imageProvider: imageProvider) { image in imageProvider: imageProvider) { image in
@@ -44,7 +52,8 @@ struct LoadableAvatarImage: View {
} placeholder: { } placeholder: {
PlaceholderAvatarImage(name: name, contentID: contentID) PlaceholderAvatarImage(name: name, contentID: contentID)
} }
.frame(width: frameSize, height: frameSize) } else {
.clipShape(Circle()) PlaceholderAvatarImage(name: name, contentID: contentID)
}
} }
} }
@@ -14,18 +14,19 @@
// limitations under the License. // limitations under the License.
// //
import Kingfisher
import SwiftUI import SwiftUI
struct LoadableImage<TransformerView: View, PlaceholderView: View>: View { struct LoadableImage<TransformerView: View, PlaceholderView: View>: View, ImageDataProvider {
private let mediaSource: MediaSourceProxy? private let mediaSource: MediaSourceProxy
private let blurhash: String? private let blurhash: String?
private let size: CGSize? private let size: CGSize?
private let imageProvider: ImageProviderProtocol? private let imageProvider: ImageProviderProtocol?
private var transformer: (Image) -> TransformerView private var transformer: (AnyView) -> TransformerView
private let placeholder: () -> PlaceholderView private let placeholder: () -> PlaceholderView
@State private var cachedImage: UIImage? @StateObject private var contentLoader: ContentLoader
/// A SwiftUI view that automatically fetches images /// A SwiftUI view that automatically fetches images
/// It will try fetching the image from in-memory cache and if that's not available /// It will try fetching the image from in-memory cache and if that's not available
@@ -35,11 +36,11 @@ struct LoadableImage<TransformerView: View, PlaceholderView: View>: View {
/// - blurhash: an optional blurhash /// - blurhash: an optional blurhash
/// - transformer: entry point for configuring the resulting image view /// - transformer: entry point for configuring the resulting image view
/// - placeholder: a view to show while the image or blurhash are not available /// - placeholder: a view to show while the image or blurhash are not available
init(mediaSource: MediaSourceProxy?, init(mediaSource: MediaSourceProxy,
blurhash: String? = nil, blurhash: String? = nil,
size: CGSize? = nil, size: CGSize? = nil,
imageProvider: ImageProviderProtocol?, imageProvider: ImageProviderProtocol?,
transformer: @escaping (Image) -> TransformerView = { $0 }, transformer: @escaping (AnyView) -> TransformerView = { $0 },
placeholder: @escaping () -> PlaceholderView) { placeholder: @escaping () -> PlaceholderView) {
self.mediaSource = mediaSource self.mediaSource = mediaSource
self.blurhash = blurhash self.blurhash = blurhash
@@ -48,17 +49,17 @@ struct LoadableImage<TransformerView: View, PlaceholderView: View>: View {
self.transformer = transformer self.transformer = transformer
self.placeholder = placeholder self.placeholder = placeholder
_contentLoader = StateObject(wrappedValue: ContentLoader(mediaSource: mediaSource, size: size, imageProvider: imageProvider))
} }
init(url: URL?, init(url: URL,
blurhash: String? = nil, blurhash: String? = nil,
size: CGSize? = nil, size: CGSize? = nil,
imageProvider: ImageProviderProtocol?, imageProvider: ImageProviderProtocol?,
transformer: @escaping (Image) -> TransformerView = { $0 }, transformer: @escaping (AnyView) -> TransformerView = { $0 },
placeholder: @escaping () -> PlaceholderView) { placeholder: @escaping () -> PlaceholderView) {
let mediaSource = url.map { MediaSourceProxy(url: $0, mimeType: nil) } self.init(mediaSource: MediaSourceProxy(url: url, mimeType: nil),
self.init(mediaSource: mediaSource,
blurhash: blurhash, blurhash: blurhash,
size: size, size: size,
imageProvider: imageProvider, imageProvider: imageProvider,
@@ -69,34 +70,101 @@ struct LoadableImage<TransformerView: View, PlaceholderView: View>: View {
var body: some View { var body: some View {
let _ = Task { let _ = Task {
// Future improvement: Does guarding against a nil image prevent the image being updated when the URL changes? // Future improvement: Does guarding against a nil image prevent the image being updated when the URL changes?
guard image == nil, let mediaSource else { return } guard contentLoader.content == nil else {
return
if case let .success(image) = await imageProvider?.loadImageFromSource(mediaSource, size: size) {
cachedImage = image
} }
await contentLoader.load()
} }
ZStack { ZStack {
if let image { switch contentLoader.content {
case .image(let image):
transformer( transformer(
Image(uiImage: image) AnyView(Image(uiImage: image).resizable())
.resizable()
) )
} else if let blurhash, case .gifData:
transformer(AnyView(KFAnimatedImage(source: .provider(self))))
case .none:
if let blurhash,
// Build a small blurhash image so that it's fast // Build a small blurhash image so that it's fast
let image = UIImage(blurHash: blurhash, size: .init(width: 10.0, height: 10.0)) { let image = UIImage(blurHash: blurhash, size: .init(width: 10.0, height: 10.0)) {
transformer( transformer(AnyView(Image(uiImage: image).resizable()))
Image(uiImage: image)
.resizable()
)
} else { } else {
placeholder() placeholder()
} }
} }
.animation(.elementDefault, value: image) }
.animation(.elementDefault, value: contentLoader.content)
} }
private var image: UIImage? { // MARK: - ImageDataProvider
cachedImage ?? imageProvider?.imageFromSource(mediaSource, size: size)
var cacheKey: String {
mediaSource.url.absoluteString
}
func data(handler: @escaping (Result<Data, Error>) -> Void) {
guard case let .gifData(data) = contentLoader.content else {
fatalError("Shouldn't reach this point without any gif data")
}
handler(.success(data))
}
}
private class ContentLoader: ObservableObject {
enum Content: Equatable {
case image(UIImage)
case gifData(Data)
}
private let imageProvider: ImageProviderProtocol?
private let mediaSource: MediaSourceProxy
private let size: CGSize?
@Published private var cachedContent: Content?
var content: Content? {
if cachedContent != nil {
return cachedContent
}
if let image = imageProvider?.imageFromSource(mediaSource) {
let isGIF = mediaSource.mimeType == "image/gif"
if isGIF {
if let data = image.kf.data(format: .GIF) {
return .gifData(data)
}
} else {
return .image(image)
}
}
return cachedContent
}
init(mediaSource: MediaSourceProxy, size: CGSize?, imageProvider: ImageProviderProtocol?) {
self.mediaSource = mediaSource
self.size = size
self.imageProvider = imageProvider
}
@MainActor
func load() async {
if isGIF {
if case let .success(data) = await imageProvider?.loadImageDataFromSource(mediaSource) {
cachedContent = .gifData(data)
}
} else {
if case let .success(image) = await imageProvider?.loadImageFromSource(mediaSource, size: size) {
cachedContent = .image(image)
}
}
}
private var isGIF: Bool {
mediaSource.mimeType == "image/gif"
} }
} }
@@ -25,12 +25,10 @@ struct ImageRoomTimelineView: View {
TimelineStyler(timelineItem: timelineItem) { TimelineStyler(timelineItem: timelineItem) {
LoadableImage(mediaSource: timelineItem.source, LoadableImage(mediaSource: timelineItem.source,
blurhash: timelineItem.blurhash, blurhash: timelineItem.blurhash,
imageProvider: context.imageProvider) { image in imageProvider: context.imageProvider) {
image.overlay { overlay }
} placeholder: {
placeholder placeholder
} }
.frame(maxHeight: 300) .frame(maxHeight: min(300, timelineItem.height ?? .infinity))
.aspectRatio(timelineItem.aspectRatio, contentMode: .fit) .aspectRatio(timelineItem.aspectRatio, contentMode: .fit)
} }
} }
@@ -45,20 +43,6 @@ struct ImageRoomTimelineView: View {
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
} }
} }
@ViewBuilder
var overlay: some View {
if timelineItem.contentType == .gif {
Text(L10n.commonGif)
.font(.element.bodyBold)
.foregroundStyle(.primary)
.padding(.horizontal, 10)
.padding(.vertical, 7)
.background(.thinMaterial)
.cornerRadius(8)
.environment(\.colorScheme, .dark)
}
}
} }
struct ImageRoomTimelineView_Previews: PreviewProvider { struct ImageRoomTimelineView_Previews: PreviewProvider {
@@ -77,7 +61,7 @@ struct ImageRoomTimelineView_Previews: PreviewProvider {
isOutgoing: false, isOutgoing: false,
isEditable: false, isEditable: false,
sender: .init(id: "Bob"), sender: .init(id: "Bob"),
source: nil)) source: MediaSourceProxy(url: .picturesDirectory, mimeType: "image/png")))
ImageRoomTimelineView(timelineItem: ImageRoomTimelineItem(id: UUID().uuidString, ImageRoomTimelineView(timelineItem: ImageRoomTimelineItem(id: UUID().uuidString,
body: "Some other image", body: "Some other image",
@@ -85,7 +69,7 @@ struct ImageRoomTimelineView_Previews: PreviewProvider {
isOutgoing: false, isOutgoing: false,
isEditable: false, isEditable: false,
sender: .init(id: "Bob"), sender: .init(id: "Bob"),
source: nil)) source: MediaSourceProxy(url: .picturesDirectory, mimeType: "image/png")))
ImageRoomTimelineView(timelineItem: ImageRoomTimelineItem(id: UUID().uuidString, ImageRoomTimelineView(timelineItem: ImageRoomTimelineItem(id: UUID().uuidString,
body: "Blurhashed image", body: "Blurhashed image",
@@ -93,7 +77,7 @@ struct ImageRoomTimelineView_Previews: PreviewProvider {
isOutgoing: false, isOutgoing: false,
isEditable: false, isEditable: false,
sender: .init(id: "Bob"), sender: .init(id: "Bob"),
source: nil, source: MediaSourceProxy(url: .picturesDirectory, mimeType: "image/gif"),
aspectRatio: 0.7, aspectRatio: 0.7,
blurhash: "L%KUc%kqS$RP?Ks,WEf8OlrqaekW", blurhash: "L%KUc%kqS$RP?Ks,WEf8OlrqaekW",
contentType: .gif)) contentType: .gif))
@@ -28,7 +28,7 @@ struct StickerRoomTimelineView: View {
imageProvider: context.imageProvider) { imageProvider: context.imageProvider) {
placeholder placeholder
} }
.frame(maxHeight: 300) .frame(maxHeight: min(300, timelineItem.height ?? .infinity))
.aspectRatio(timelineItem.aspectRatio, contentMode: .fit) .aspectRatio(timelineItem.aspectRatio, contentMode: .fit)
} }
.accessibilityLabel(timelineItem.body) .accessibilityLabel(timelineItem.body)
@@ -23,7 +23,16 @@ struct VideoRoomTimelineView: View {
var body: some View { var body: some View {
TimelineStyler(timelineItem: timelineItem) { TimelineStyler(timelineItem: timelineItem) {
LoadableImage(mediaSource: timelineItem.thumbnailSource, thumbnail
.frame(maxHeight: min(300, timelineItem.height ?? .infinity))
.aspectRatio(timelineItem.aspectRatio, contentMode: .fit)
}
}
@ViewBuilder
var thumbnail: some View {
if let thumbnailSource = timelineItem.thumbnailSource {
LoadableImage(mediaSource: thumbnailSource,
blurhash: timelineItem.blurhash, blurhash: timelineItem.blurhash,
imageProvider: context.imageProvider) { imageView in imageProvider: context.imageProvider) { imageView in
imageView imageView
@@ -31,8 +40,8 @@ struct VideoRoomTimelineView: View {
} placeholder: { } placeholder: {
placeholder placeholder
} }
.frame(maxHeight: 300) } else {
.aspectRatio(timelineItem.aspectRatio, contentMode: .fit) playIcon
} }
} }
@@ -20,6 +20,8 @@ protocol ImageProviderProtocol {
func imageFromSource(_ source: MediaSourceProxy?, size: CGSize?) -> UIImage? func imageFromSource(_ source: MediaSourceProxy?, size: CGSize?) -> UIImage?
func loadImageFromSource(_ source: MediaSourceProxy, size: CGSize?) async -> Result<UIImage, MediaProviderError> func loadImageFromSource(_ source: MediaSourceProxy, size: CGSize?) async -> Result<UIImage, MediaProviderError>
func loadImageDataFromSource(_ source: MediaSourceProxy) async -> Result<Data, MediaProviderError>
} }
extension ImageProviderProtocol { extension ImageProviderProtocol {
@@ -79,6 +79,16 @@ struct MediaProvider: MediaProviderProtocol {
} }
} }
func loadImageDataFromSource(_ source: MediaSourceProxy) async -> Result<Data, MediaProviderError> {
do {
let imageData = try await mediaLoader.loadMediaContentForSource(source)
return .success(imageData)
} catch {
MXLog.error("Failed retrieving image with error: \(error)")
return .failure(.failedRetrievingImage)
}
}
// MARK: Files // MARK: Files
func loadFileFromSource(_ source: MediaSourceProxy) async -> Result<MediaFileHandleProxy, MediaProviderError> { func loadFileFromSource(_ source: MediaSourceProxy) async -> Result<MediaFileHandleProxy, MediaProviderError> {
@@ -38,6 +38,15 @@ struct MockMediaProvider: MediaProviderProtocol {
return .success(image) return .success(image)
} }
func loadImageDataFromSource(_ source: MediaSourceProxy) async -> Result<Data, MediaProviderError> {
guard let image = UIImage(systemName: "photo"),
let data = image.pngData() else {
fatalError()
}
return .success(data)
}
func loadFileFromSource(_ source: MediaSourceProxy) async -> Result<MediaFileHandleProxy, MediaProviderError> { func loadFileFromSource(_ source: MediaSourceProxy) async -> Result<MediaFileHandleProxy, MediaProviderError> {
.failure(.failedRetrievingFile) .failure(.failedRetrievingFile)
} }
@@ -26,7 +26,7 @@ struct ImageRoomTimelineItem: EventBasedTimelineItemProtocol, Identifiable, Hash
let sender: TimelineItemSender let sender: TimelineItemSender
let source: MediaSourceProxy? let source: MediaSourceProxy
var width: CGFloat? var width: CGFloat?
var height: CGFloat? var height: CGFloat?
+2 -1
View File
@@ -224,7 +224,8 @@ class LoggingTests: XCTestCase {
timestamp: "", isOutgoing: false, isEditable: false, sender: .init(id: "sender")) timestamp: "", isOutgoing: false, isEditable: false, sender: .init(id: "sender"))
let imageMessage = ImageRoomTimelineItem(id: "myimagemessage", body: "ImageString", let imageMessage = ImageRoomTimelineItem(id: "myimagemessage", body: "ImageString",
timestamp: "", isOutgoing: false, isEditable: false, timestamp: "", isOutgoing: false, isEditable: false,
sender: .init(id: "sender"), source: nil) sender: .init(id: "sender"),
source: MediaSourceProxy(url: .picturesDirectory, mimeType: "image/gif"))
let videoMessage = VideoRoomTimelineItem(id: "myvideomessage", body: "VideoString", let videoMessage = VideoRoomTimelineItem(id: "myvideomessage", body: "VideoString",
timestamp: "", isOutgoing: false, isEditable: false, timestamp: "", isOutgoing: false, isEditable: false,
sender: .init(id: "sender"), duration: 0, source: nil, thumbnailSource: nil) sender: .init(id: "sender"), duration: 0, source: nil, thumbnailSource: nil)