mirror of
https://github.com/Novaloop-AG/element-x-ios.git
synced 2026-11-04 19:44:05 +00:00
Add animated gif support (#825)
This commit is contained in:
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -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?
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user