430 lines
16 KiB
Swift
430 lines
16 KiB
Swift
//
|
|
// Copyright 2025 Element Creations Ltd.
|
|
// Copyright 2023-2025 New Vector Ltd.
|
|
//
|
|
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
|
// Please see LICENSE files in the repository root for full details.
|
|
//
|
|
|
|
import Combine
|
|
import Compound
|
|
import Kingfisher
|
|
import SwiftUI
|
|
|
|
/// Used to configure animations
|
|
enum LoadableImageMediaType: Equatable {
|
|
/// An avatar (can be displayed anywhere within the app).
|
|
case avatar
|
|
/// An image displayed in the timeline.
|
|
case timelineItem(uniqueID: TimelineItemIdentifier.UniqueID)
|
|
/// Any other media (can be displayed anywhere within the app).
|
|
case generic
|
|
}
|
|
|
|
struct LoadableImage<TransformerView: View, PlaceholderView: View>: View {
|
|
private let mediaSource: MediaSourceProxy?
|
|
private let mediaType: LoadableImageMediaType
|
|
private let blurhash: String?
|
|
private let size: CGSize?
|
|
private let mediaProvider: MediaProviderProtocol?
|
|
private let transformer: (AnyView) -> TransformerView
|
|
private let placeholder: () -> PlaceholderView
|
|
|
|
/// 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 fire a task to load it through the image provider
|
|
/// - Parameters:
|
|
/// - mediaSource: the source of the image
|
|
/// - blurhash: an optional blurhash
|
|
/// - transformer: entry point for configuring the resulting image view
|
|
/// - placeholder: a view to show while the image or blurhash are not available
|
|
init(mediaSource: MediaSourceProxy,
|
|
mediaType: LoadableImageMediaType = .generic,
|
|
blurhash: String? = nil,
|
|
size: CGSize? = nil,
|
|
mediaProvider: MediaProviderProtocol?,
|
|
transformer: @escaping (AnyView) -> TransformerView = { $0 },
|
|
placeholder: @escaping () -> PlaceholderView) {
|
|
self.mediaSource = mediaSource
|
|
self.mediaType = mediaType
|
|
self.blurhash = blurhash
|
|
self.size = size
|
|
self.mediaProvider = mediaProvider
|
|
self.transformer = transformer
|
|
self.placeholder = placeholder
|
|
}
|
|
|
|
init(url: URL,
|
|
mediaType: LoadableImageMediaType = .generic,
|
|
blurhash: String? = nil,
|
|
size: CGSize? = nil,
|
|
mediaProvider: MediaProviderProtocol?,
|
|
transformer: @escaping (AnyView) -> TransformerView = { $0 },
|
|
placeholder: @escaping () -> PlaceholderView) {
|
|
mediaSource = try? MediaSourceProxy(url: url, mimeType: nil)
|
|
self.mediaType = mediaType
|
|
self.blurhash = blurhash
|
|
self.size = size
|
|
self.mediaProvider = mediaProvider
|
|
self.transformer = transformer
|
|
self.placeholder = placeholder
|
|
}
|
|
|
|
var body: some View {
|
|
if let mediaSource {
|
|
LoadableImageContent(mediaSource: mediaSource,
|
|
mediaType: mediaType,
|
|
blurhash: blurhash,
|
|
size: size,
|
|
mediaProvider: mediaProvider,
|
|
transformer: transformer,
|
|
placeholder: placeholder)
|
|
.id(stableMediaIdentifier)
|
|
} else {
|
|
placeholder()
|
|
}
|
|
}
|
|
|
|
private var stableMediaIdentifier: String? {
|
|
switch mediaType {
|
|
case .timelineItem(let uniqueID):
|
|
// Consider media for the same item to be the same view
|
|
uniqueID.value
|
|
default:
|
|
// Binds the lifecycle of the LoadableImage to the associated URL.
|
|
// This fixes the problem of the cache returning old values after a change in the URL.
|
|
mediaSource?.url.absoluteString
|
|
}
|
|
}
|
|
}
|
|
|
|
private struct LoadableImageContent<TransformerView: View, PlaceholderView: View>: View, ImageDataProvider {
|
|
@Environment(\.shouldAutomaticallyLoadImages) private var loadAutomatically
|
|
|
|
private let mediaSource: MediaSourceProxy
|
|
private let mediaType: LoadableImageMediaType
|
|
private let blurhash: String?
|
|
private let transformer: (AnyView) -> TransformerView
|
|
private let placeholder: () -> PlaceholderView
|
|
|
|
@StateObject private var contentLoader: ContentLoader
|
|
@State private var loadManually = false
|
|
|
|
init(mediaSource: MediaSourceProxy,
|
|
mediaType: LoadableImageMediaType,
|
|
blurhash: String? = nil,
|
|
size: CGSize? = nil,
|
|
mediaProvider: MediaProviderProtocol?,
|
|
transformer: @escaping (AnyView) -> TransformerView,
|
|
placeholder: @escaping () -> PlaceholderView) {
|
|
assert(mediaProvider != nil, "Missing image provider, make sure one has been supplied to the view model.")
|
|
|
|
self.mediaSource = mediaSource
|
|
self.mediaType = mediaType
|
|
self.blurhash = blurhash
|
|
self.transformer = transformer
|
|
self.placeholder = placeholder
|
|
_contentLoader = StateObject(wrappedValue: ContentLoader(mediaSource: mediaSource, size: size, mediaProvider: mediaProvider))
|
|
}
|
|
|
|
var shouldRender: Bool {
|
|
loadAutomatically || loadManually
|
|
}
|
|
|
|
var body: some View {
|
|
ZStack {
|
|
switch (contentLoader.content, shouldRender) {
|
|
case (.image(let image), true):
|
|
transformer(AnyView(Image(uiImage: image).resizable()))
|
|
case (.gifData, true):
|
|
transformer(AnyView(KFAnimatedImage(source: .provider(self))))
|
|
case (.none, _), (_, false):
|
|
if let blurHashView {
|
|
if shouldRender {
|
|
transformer(blurHashView)
|
|
} else {
|
|
blurHashView
|
|
}
|
|
} else {
|
|
placeholder().overlay { placeholderOverlay }
|
|
}
|
|
}
|
|
}
|
|
.animation(mediaType == .avatar ? .noAnimation : .elementDefault, value: contentLoader.content)
|
|
.animation(.elementDefault, value: loadManually)
|
|
.task(id: mediaSource.url.absoluteString + "\(shouldRender)") {
|
|
guard shouldRender, contentLoader.content == nil else {
|
|
return
|
|
}
|
|
|
|
await contentLoader.load()
|
|
}
|
|
.onDisappear {
|
|
guard contentLoader.content == nil else {
|
|
return
|
|
}
|
|
|
|
contentLoader.cancel()
|
|
}
|
|
}
|
|
|
|
/// Note: Returns `AnyView` as this is what `transformer` expects.
|
|
var blurHashView: AnyView? {
|
|
if let blurhash,
|
|
// Build a small blurhash image so that it's fast
|
|
let image = UIImage(blurHash: blurhash, size: .init(width: 10.0, height: 10.0)) {
|
|
return AnyView(Image(uiImage: image).resizable().overlay { blurHashOverlay })
|
|
} else {
|
|
return nil
|
|
}
|
|
}
|
|
|
|
// MARK: - Overlays
|
|
|
|
@ViewBuilder
|
|
var placeholderOverlay: some View {
|
|
switch mediaType {
|
|
case .avatar, .generic:
|
|
EmptyView()
|
|
case .timelineItem:
|
|
if shouldRender {
|
|
ProgressView(L10n.commonLoading)
|
|
.frame(maxWidth: .infinity)
|
|
} else {
|
|
loadManuallyButton
|
|
}
|
|
}
|
|
}
|
|
|
|
@ViewBuilder
|
|
var blurHashOverlay: some View {
|
|
if !shouldRender {
|
|
loadManuallyButton
|
|
}
|
|
}
|
|
|
|
var loadManuallyButton: some View {
|
|
ZStack {
|
|
Color.black.opacity(0.6)
|
|
.contentShape(.rect)
|
|
.onTapGesture { /* Empty gesture to block the `mediaTapped` action */ }
|
|
|
|
// Don't use a real Button as it sometimes triggers simultaneously with the long press gesture.
|
|
Text(L10n.actionShow)
|
|
.font(.compound.bodyLGSemibold)
|
|
.foregroundStyle(.compound.textOnSolidPrimary)
|
|
.padding(.horizontal, 16)
|
|
.padding(.vertical, 4)
|
|
.overlay {
|
|
Capsule()
|
|
.stroke(lineWidth: 1)
|
|
.foregroundStyle(.compound.borderInteractiveSecondary)
|
|
}
|
|
.contentShape(.capsule)
|
|
.onTapGesture {
|
|
loadManually = true
|
|
}
|
|
.environment(\.colorScheme, .light)
|
|
}
|
|
}
|
|
|
|
// MARK: - ImageDataProvider
|
|
|
|
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 mediaProvider: MediaProviderProtocol?
|
|
private let mediaSource: MediaSourceProxy
|
|
private let size: CGSize?
|
|
private var imageLoadingCancellable: AnyCancellable?
|
|
|
|
@Published private var cachedContent: Content?
|
|
|
|
var content: Content? {
|
|
if cachedContent != nil {
|
|
return cachedContent
|
|
}
|
|
|
|
if isGIF {
|
|
if let image = mediaProvider?.imageFromSource(mediaSource),
|
|
let data = image.kf.data(format: .GIF) {
|
|
return .gifData(data)
|
|
}
|
|
} else if let image = mediaProvider?.imageFromSource(mediaSource, size: size) {
|
|
return .image(image)
|
|
}
|
|
|
|
return cachedContent
|
|
}
|
|
|
|
init(mediaSource: MediaSourceProxy, size: CGSize?, mediaProvider: MediaProviderProtocol?) {
|
|
self.mediaSource = mediaSource
|
|
self.size = size
|
|
self.mediaProvider = mediaProvider
|
|
}
|
|
|
|
@MainActor
|
|
func load() async {
|
|
if isGIF {
|
|
if case let .success(data) = await mediaProvider?.loadImageDataFromSource(mediaSource) {
|
|
cachedContent = .gifData(data)
|
|
}
|
|
} else {
|
|
guard let task = mediaProvider?.loadImageRetryingOnReconnection(mediaSource, size: size) else {
|
|
MXLog.error("Failed loading image, invalid reconnection retry task.")
|
|
return
|
|
}
|
|
|
|
imageLoadingCancellable = task.asCancellable()
|
|
|
|
if let image = try? await task.value {
|
|
cachedContent = .image(image)
|
|
}
|
|
}
|
|
}
|
|
|
|
func cancel() {
|
|
imageLoadingCancellable?.cancel()
|
|
}
|
|
|
|
private var isGIF: Bool {
|
|
mediaSource.mimeType == "image/gif"
|
|
}
|
|
}
|
|
|
|
extension EnvironmentValues {
|
|
/// Whether or not images should be loaded inside `LoadableImage` without a user interaction.
|
|
@Entry var shouldAutomaticallyLoadImages = true
|
|
}
|
|
|
|
// MARK: - Previews
|
|
|
|
struct LoadableImage_Previews: PreviewProvider, TestablePreview {
|
|
static let mediaProvider = makeMediaProvider()
|
|
static let loadingMediaProvider = makeMediaProvider(isLoading: true)
|
|
|
|
static var previews: some View {
|
|
LazyVGrid(columns: [.init(.adaptive(minimum: 110, maximum: 110))], spacing: 24) {
|
|
LoadableImage(url: "mxc://wherever/1234",
|
|
mediaType: .timelineItem(uniqueID: .init("id")),
|
|
mediaProvider: mediaProvider,
|
|
placeholder: placeholder)
|
|
.layout(title: "Loaded")
|
|
|
|
LoadableImage(url: "mxc://wherever/2345",
|
|
mediaType: .timelineItem(uniqueID: .init("id")),
|
|
blurhash: "KpE4oyayR5|GbHb];3j@of",
|
|
mediaProvider: mediaProvider,
|
|
placeholder: placeholder)
|
|
.layout(title: "Hidden (blurhash)", hideTimelineMedia: true)
|
|
|
|
LoadableImage(url: "mxc://wherever/3456",
|
|
mediaType: .timelineItem(uniqueID: .init("id")),
|
|
mediaProvider: mediaProvider,
|
|
placeholder: placeholder)
|
|
.layout(title: "Hidden (placeholder)", hideTimelineMedia: true)
|
|
|
|
LoadableImage(url: "mxc://wherever/4567",
|
|
mediaType: .timelineItem(uniqueID: .init("id")),
|
|
blurhash: "KbLM^j]q$jT|EfR-3rtjXk",
|
|
mediaProvider: loadingMediaProvider,
|
|
placeholder: placeholder)
|
|
.layout(title: "Loading (blurhash)")
|
|
|
|
LoadableImage(url: "mxc://wherever/5678",
|
|
mediaType: .timelineItem(uniqueID: .init("id")),
|
|
mediaProvider: loadingMediaProvider,
|
|
placeholder: placeholder)
|
|
.layout(title: "Loading (placeholder)")
|
|
|
|
LoadableImage(url: "mxc://wherever/6789",
|
|
mediaType: .avatar,
|
|
mediaProvider: loadingMediaProvider,
|
|
placeholder: placeholder)
|
|
.layout(title: "Loading (avatar)")
|
|
|
|
LoadableImage(url: "mxc://wherever/345",
|
|
mediaType: .timelineItem(uniqueID: .init("id")),
|
|
blurhash: "KbLM^j]q$jT|EfR-3rtjXk",
|
|
mediaProvider: mediaProvider,
|
|
transformer: transformer,
|
|
placeholder: placeholder)
|
|
.layout(title: "Loaded (transformer)")
|
|
|
|
LoadableImage(url: "mxc://wherever/345",
|
|
mediaType: .timelineItem(uniqueID: .init("id")),
|
|
blurhash: "KbLM^j]q$jT|EfR-3rtjXk",
|
|
mediaProvider: loadingMediaProvider,
|
|
transformer: transformer,
|
|
placeholder: placeholder)
|
|
.layout(title: "Loading (transformer)")
|
|
|
|
LoadableImage(url: "mxc://wherever/234",
|
|
mediaType: .timelineItem(uniqueID: .init("id")),
|
|
blurhash: "KbLM^j]q$jT|EfR-3rtjXk",
|
|
mediaProvider: mediaProvider,
|
|
transformer: transformer,
|
|
placeholder: placeholder)
|
|
.layout(title: "Hidden (transformer)", hideTimelineMedia: true)
|
|
}
|
|
}
|
|
|
|
static func placeholder() -> some View {
|
|
Color.compound._bgBubbleIncoming
|
|
}
|
|
|
|
static func transformer(_ view: AnyView) -> some View {
|
|
view.overlay {
|
|
Image(systemSymbol: .playCircleFill)
|
|
.font(.largeTitle)
|
|
.foregroundStyle(.compound.iconAccentPrimary)
|
|
}
|
|
}
|
|
|
|
static func makeMediaProvider(isLoading: Bool = false) -> MediaProviderProtocol {
|
|
let mediaProvider = MediaProviderMock(configuration: .init())
|
|
|
|
if isLoading {
|
|
mediaProvider.imageFromSourceSizeClosure = { _, _ in nil }
|
|
mediaProvider.loadFileFromSourceFilenameClosure = { _, _ in .failure(.failedRetrievingFile) }
|
|
mediaProvider.loadImageDataFromSourceClosure = { _ in .failure(.failedRetrievingImage) }
|
|
mediaProvider.loadImageFromSourceSizeClosure = { _, _ in .failure(.failedRetrievingImage) }
|
|
mediaProvider.loadThumbnailForSourceSourceSizeClosure = { _, _ in .failure(.failedRetrievingThumbnail) }
|
|
mediaProvider.loadImageRetryingOnReconnectionSizeClosure = { _, _ in
|
|
Task { throw MediaProviderError.failedRetrievingImage }
|
|
}
|
|
}
|
|
return mediaProvider
|
|
}
|
|
}
|
|
|
|
private extension View {
|
|
func layout(title: String, hideTimelineMedia: Bool = false) -> some View {
|
|
aspectRatio(contentMode: .fit)
|
|
.clipShape(RoundedRectangle(cornerRadius: 20))
|
|
.overlay(alignment: .bottom) {
|
|
Text(title)
|
|
.font(.caption2)
|
|
.offset(y: 16)
|
|
.padding(.horizontal, -5)
|
|
}
|
|
.environment(\.shouldAutomaticallyLoadImages, !hideTimelineMedia)
|
|
}
|
|
}
|