From 811792afdb0817f08fc404e013454d9700184a1d Mon Sep 17 00:00:00 2001 From: Muukii Date: Tue, 21 Jan 2025 22:49:05 +0900 Subject: [PATCH 1/4] Update --- .../AsyncMultiplexImage-Demo.xcscheme | 2 +- .../ContentView.swift | 25 +- .../AsyncMultiplexImage-Demo/List.swift | 104 ++++-- .../AsyncMultiplexImageNuke.swift | 32 +- .../AsyncMultiplexImageNukeDownloader.swift | 2 +- .../AsyncMultiplexImage.swift | 295 +++++------------- .../AsyncMultiplexImageContent.swift | 34 ++ .../AsyncMultiplexImageView.swift | 16 +- .../AsyncMultiplexImage/DownloadManager.swift | 181 +++++++++++ .../AsyncMultiplexImage/MultiplexImage.swift | 11 +- 10 files changed, 396 insertions(+), 306 deletions(-) create mode 100644 Sources/AsyncMultiplexImage/AsyncMultiplexImageContent.swift create mode 100644 Sources/AsyncMultiplexImage/DownloadManager.swift diff --git a/Development/AsyncMultiplexImage-Demo.xcodeproj/xcshareddata/xcschemes/AsyncMultiplexImage-Demo.xcscheme b/Development/AsyncMultiplexImage-Demo.xcodeproj/xcshareddata/xcschemes/AsyncMultiplexImage-Demo.xcscheme index f6c7b6a..e2de900 100644 --- a/Development/AsyncMultiplexImage-Demo.xcodeproj/xcshareddata/xcschemes/AsyncMultiplexImage-Demo.xcscheme +++ b/Development/AsyncMultiplexImage-Demo.xcodeproj/xcshareddata/xcschemes/AsyncMultiplexImage-Demo.xcscheme @@ -53,7 +53,7 @@ + isEnabled = "NO"> diff --git a/Development/AsyncMultiplexImage-Demo/ContentView.swift b/Development/AsyncMultiplexImage-Demo/ContentView.swift index 2a4d83e..631a71d 100644 --- a/Development/AsyncMultiplexImage-Demo/ContentView.swift +++ b/Development/AsyncMultiplexImage-Demo/ContentView.swift @@ -59,23 +59,9 @@ struct ContentView: View { identifier: basePhotoURLString, urls: buildURLs(basePhotoURLString) ), - downloader: _SlowDownloader(pipeline: .shared) - ) { phase in - switch phase { - case .empty: - Text("Loading") - case .progress(let image): - image - .resizable() - .scaledToFill() - case .success(let image): - image - .resizable() - .scaledToFill() - case .failure(let error): - Text("Error") - } - } + downloader: _SlowDownloader(pipeline: .shared), + content: AsyncMultiplexImageBasicContent() + ) HStack { Button("1") { @@ -98,7 +84,10 @@ struct ContentView: View { NavigationLink("UIKit") { UIKitContentViewRepresentable() } - NavigationLink("SwiftUI List", destination: { UsingList() }) + + NavigationLink("Stress 1", destination: { StressGrid() }) + + NavigationLink("Stress 2", destination: { StressGrid() }) } .navigationTitle("Multiplex Image") } diff --git a/Development/AsyncMultiplexImage-Demo/List.swift b/Development/AsyncMultiplexImage-Demo/List.swift index 0159ad7..b786c86 100644 --- a/Development/AsyncMultiplexImage-Demo/List.swift +++ b/Development/AsyncMultiplexImage-Demo/List.swift @@ -1,14 +1,8 @@ import AsyncMultiplexImage import AsyncMultiplexImage_Nuke -// -// List.swift -// AsyncMultiplexImage-Demo -// -// Created by Muukii on 2024/06/13. -// import SwiftUI -struct UsingList: View { +struct StressGrid: View { @State var items: [Entity] = Entity.batch() @@ -16,24 +10,14 @@ struct UsingList: View { ScrollView { LazyVGrid( columns: [ - .init(.flexible(minimum: 0, maximum: .infinity), spacing: 16), - .init(.flexible(minimum: 0, maximum: .infinity), spacing: 16), - .init(.flexible(minimum: 0, maximum: .infinity), spacing: 16), - .init(.flexible(minimum: 0, maximum: .infinity), spacing: 16), - ], spacing: 16 + .init(.flexible(minimum: 0, maximum: .infinity), spacing: 2), + .init(.flexible(minimum: 0, maximum: .infinity), spacing: 2), + .init(.flexible(minimum: 0, maximum: .infinity), spacing: 2), + .init(.flexible(minimum: 0, maximum: .infinity), spacing: 2), + ], spacing: 2 ) { ForEach(items) { entity in - if entity.id == items.last?.id { - Cell(entity: entity) - .onAppear { - Task { - let newItems = await Entity.delayBatch() - items.append(contentsOf: newItems) - } - } - } else { - Cell(entity: entity) - } + Cell(entity: entity) } } } @@ -42,13 +26,32 @@ struct UsingList: View { } #Preview { - UsingList() + StressGrid() +} + +#Preview { + StressGrid() } let imageURLString = "https://images.unsplash.com/photo-1567095761054-7a02e69e5c43?q=80&w=800&auto=format&fit=crop&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D" -struct Cell: View { +protocol CellType: View { + init(entity: Entity) +} + +struct Cell_1: View, CellType { + + let entity: Entity + + var body: some View { + AsyncMultiplexImageNuke(imageRepresentation: .remote(entity.image)) + .frame(height: 100) + } +} + + +struct Cell_2: View, CellType { let entity: Entity @@ -56,12 +59,49 @@ struct Cell: View { VStack { AsyncMultiplexImageNuke(imageRepresentation: .remote(entity.image)) .frame(height: 100) - HStack { - Image(systemName: "globe") - .imageScale(.large) - .foregroundStyle(.tint) - Text(entity.name) - } + .clipShape( + RoundedRectangle( + cornerRadius: 20, + style: .continuous + ) + ) + .frame(maxWidth: .infinity) + .aspectRatio(1, contentMode: .fit) + } + .padding() + } +} + +struct Cell_3: View, CellType { + + final class Object: ObservableObject { + + @Published var value: Int = 0 + + init() { + print("Object.init") + } + + deinit { +// print("Object.deinit") + + } + } + + let entity: Entity + + @State private var value: Int = 0 + @StateObject private var object = Object() + + var body: some View { + let _ = Self._printChanges() + VStack { + Button("Up \(value)") { + value += 1 + } + Button("Up \(object.value)") { + object.value += 1 + } } .padding() } @@ -84,7 +124,7 @@ struct Entity: Identifiable { } static func batch() -> [Self] { - (0..<100).map { _ in + (0..<100000).map { _ in .make() } } diff --git a/Sources/AsyncMultiplexImage-Nuke/AsyncMultiplexImageNuke.swift b/Sources/AsyncMultiplexImage-Nuke/AsyncMultiplexImageNuke.swift index 17f918a..323a857 100644 --- a/Sources/AsyncMultiplexImage-Nuke/AsyncMultiplexImageNuke.swift +++ b/Sources/AsyncMultiplexImage-Nuke/AsyncMultiplexImageNuke.swift @@ -12,27 +12,9 @@ public struct AsyncMultiplexImageNuke: View { public var body: some View { AsyncMultiplexImage( imageRepresentation: imageRepresentation, - downloader: AsyncMultiplexImageNukeDownloader.shared - ) { phase in - Group { - switch phase { - case .empty: - EmptyView() - case .progress(let image): - image - .resizable() - .scaledToFill() - .transition(.opacity.animation(.bouncy)) - case .success(let image): - image - .resizable() - .scaledToFill() - .transition(.opacity.animation(.bouncy)) - case .failure: - EmptyView() - } - } - } + downloader: AsyncMultiplexImageNukeDownloader.shared, + content: AsyncMultiplexImageBasicContent() + ) } } @@ -52,11 +34,11 @@ public struct AsyncMultiplexImageNuke: View { #Preview("Rotating") { HStack { - + Rectangle() .frame(width: 100, height: 100) .rotationEffect(.degrees(10)) - + AsyncMultiplexImageNuke( imageRepresentation: .remote( .init( @@ -70,7 +52,7 @@ public struct AsyncMultiplexImageNuke: View { .frame(width: 100, height: 100) .rotationEffect(.degrees(10)) .clipped(antialiased: true) - + AsyncMultiplexImageNuke( imageRepresentation: .remote( .init( @@ -83,7 +65,7 @@ public struct AsyncMultiplexImageNuke: View { ) .frame(width: 100, height: 100) .rotationEffect(.degrees(20)) - + AsyncMultiplexImageNuke( imageRepresentation: .remote( .init( diff --git a/Sources/AsyncMultiplexImage-Nuke/AsyncMultiplexImageNukeDownloader.swift b/Sources/AsyncMultiplexImage-Nuke/AsyncMultiplexImageNukeDownloader.swift index 39b0e90..d6b2957 100644 --- a/Sources/AsyncMultiplexImage-Nuke/AsyncMultiplexImageNukeDownloader.swift +++ b/Sources/AsyncMultiplexImage-Nuke/AsyncMultiplexImageNukeDownloader.swift @@ -4,7 +4,7 @@ import Nuke import SwiftUI public actor AsyncMultiplexImageNukeDownloader: AsyncMultiplexImageDownloader { - + public static let `shared` = AsyncMultiplexImageNukeDownloader(pipeline: .shared, debugDelay: 0) public let pipeline: ImagePipeline diff --git a/Sources/AsyncMultiplexImage/AsyncMultiplexImage.swift b/Sources/AsyncMultiplexImage/AsyncMultiplexImage.swift index 459bb01..ec3a467 100644 --- a/Sources/AsyncMultiplexImage/AsyncMultiplexImage.swift +++ b/Sources/AsyncMultiplexImage/AsyncMultiplexImage.swift @@ -44,17 +44,17 @@ extension OSLog { @inline(__always) private static func makeOSLogInDebug(isEnabled: Bool = true, _ factory: () -> OSLog) -> OSLog { #if DEBUG - if ProcessInfo.processInfo.environment["ASYNC_MULTIPLEX_IMAGE_LOG_ENABLED"] == "1" { - return factory() - } else { - return .disabled - } + if ProcessInfo.processInfo.environment["ASYNC_MULTIPLEX_IMAGE_LOG_ENABLED"] == "1" { + return factory() + } else { + return .disabled + } #else - return .disabled + return .disabled #endif } - static let generic: OSLog = makeOSLogInDebug { + static let generic: OSLog = makeOSLogInDebug(isEnabled: false) { OSLog.init(subsystem: "app.muukii", category: "default") } static let view: OSLog = makeOSLogInDebug { @@ -67,13 +67,6 @@ extension OSLog { } -@MainActor -public final class DownloadManager { - - public static let shared: DownloadManager = .init() - -} - public protocol AsyncMultiplexImageDownloader: Actor { func download(candidate: AsyncMultiplexImageCandidate, displaySize: CGSize) async throws @@ -100,21 +93,22 @@ public enum ImageRepresentation: Equatable { case loaded(Image) } -public struct AsyncMultiplexImage: View { +public struct AsyncMultiplexImage< + Content: AsyncMultiplexImageContent, Downloader: AsyncMultiplexImageDownloader +>: View { private let imageRepresentation: ImageRepresentation private let downloader: Downloader - private let content: (AsyncMultiplexImagePhase) -> Content + private let content: Content + private let clearsContentBeforeDownload: Bool - // sharing - @StateObject private var viewModel: _AsyncMultiplexImageViewModel = .init() // convenience init public init( multiplexImage: MultiplexImage, downloader: Downloader, clearsContentBeforeDownload: Bool = true, - @ViewBuilder content: @escaping (AsyncMultiplexImagePhase) -> Content + content: Content ) { self.init( imageRepresentation: .remote(multiplexImage), @@ -128,7 +122,7 @@ public struct AsyncMultiplexImage Content + content: Content ) { self.clearsContentBeforeDownload = clearsContentBeforeDownload @@ -140,7 +134,6 @@ public struct AsyncMultiplexImage? - func registerCurrentTask(_ task: Task?) { - self.cancelCurrentTask() - self.task = task - } - - func cancelCurrentTask() { - guard let task else { return } - guard task.isCancelled == false else { return } - task.cancel() - } - - deinit { - guard let task else { return } - guard task.isCancelled == false else { return } - task.cancel() - } - -} - -private struct _AsyncMultiplexImage: View -{ +private struct _AsyncMultiplexImage< + Content: AsyncMultiplexImageContent, Downloader: AsyncMultiplexImageDownloader +>: View { private struct UpdateTrigger: Equatable { let size: CGSize let image: ImageRepresentation + let isInDisplay: Bool } - @State private var currentUsingCandidates: [AsyncMultiplexImageCandidate] = [] - @State private var item: ResultContainer.ItemSwiftUI? - - let viewModel: _AsyncMultiplexImageViewModel + @State private var task: Task<(), Never>? + @State private var displaySize: CGSize = .zero private let imageRepresentation: ImageRepresentation private let downloader: Downloader - private let content: (AsyncMultiplexImagePhase) -> Content + private let content: Content private let clearsContentBeforeDownload: Bool public init( - viewModel: _AsyncMultiplexImageViewModel, clearsContentBeforeDownload: Bool, imageRepresentation: ImageRepresentation, downloader: Downloader, - @ViewBuilder content: @escaping (AsyncMultiplexImagePhase) -> Content + content: Content ) { - self.viewModel = viewModel self.clearsContentBeforeDownload = clearsContentBeforeDownload self.imageRepresentation = imageRepresentation self.downloader = downloader @@ -210,27 +178,39 @@ private struct _AsyncMultiplexImage? - private var progressImagesTask: Task? - - deinit { - idealImageTask?.cancel() - progressImagesTask?.cancel() - } - - func makeStream( - candidates: [AsyncMultiplexImageCandidate], - downloader: Downloader, - displaySize: CGSize - ) -> AsyncThrowingStream { - - Log.debug(.`generic`, "Load: \(candidates.map { $0.urlRequest })") - - return .init { [self] continuation in - - continuation.onTermination = { [self] termination in - - switch termination { - case .finished, .cancelled: - Task { - await self.idealImageTask?.cancel() - await self.progressImagesTask?.cancel() - } - @unknown default: - break - } - - } - - guard let idealCandidate = candidates.first else { - continuation.finish() - return - } - - let idealImage = Task { - - do { - let result = try await downloader.download( - candidate: idealCandidate, - displaySize: displaySize - ) - - progressImagesTask?.cancel() - - Log.debug(.`generic`, "Loaded ideal") - - lastCandidate = idealCandidate - continuation.yield(.final(result)) - } catch { - continuation.yield(with: .failure(error)) - } - - continuation.finish() - - } - - idealImageTask = idealImage - - let progressCandidates = candidates.dropFirst(1) - - guard progressCandidates.isEmpty == false else { - return - } - - let progressImages = Task { - - // download images sequentially from lower image - for candidate in progressCandidates.reversed() { - do { - - guard Task.isCancelled == false else { - Log.debug(.`generic`, "Cancelled progress images") - return - } - - Log.debug(.`generic`, "Load progress image => \(candidate.index)") - let result = try await downloader.download( - candidate: candidate, - displaySize: displaySize - ) - - guard Task.isCancelled == false else { - Log.debug(.`generic`, "Cancelled progress images") - return - } - - if let lastCandidate, lastCandidate.index > candidate.index { - continuation.finish() - return - } - - lastCandidate = idealCandidate - - let yieldResult = continuation.yield(.progress(result)) - - Log.debug(.`generic`, "Loaded progress image => \(candidate.index), \(yieldResult)") - } catch { - - } - } - - } - - progressImagesTask = progressImages - - } - } -} diff --git a/Sources/AsyncMultiplexImage/AsyncMultiplexImageContent.swift b/Sources/AsyncMultiplexImage/AsyncMultiplexImageContent.swift new file mode 100644 index 0000000..96a439c --- /dev/null +++ b/Sources/AsyncMultiplexImage/AsyncMultiplexImageContent.swift @@ -0,0 +1,34 @@ +import SwiftUI + +public protocol AsyncMultiplexImageContent { + + associatedtype Content: View + + @ViewBuilder + func body(phase: AsyncMultiplexImagePhase) -> Content +} + +public struct AsyncMultiplexImageBasicContent: AsyncMultiplexImageContent { + + public init() {} + + public func body(phase: AsyncMultiplexImagePhase) -> some View { + switch phase { + case .empty: + Rectangle().fill(.clear) + case .progress(let image): + image + .resizable() + .scaledToFill() + .transition(.opacity.animation(.bouncy)) + case .success(let image): + image + .resizable() + .scaledToFill() + .transition(.opacity.animation(.bouncy)) + case .failure: + Rectangle().fill(.clear) + } + } + +} diff --git a/Sources/AsyncMultiplexImage/AsyncMultiplexImageView.swift b/Sources/AsyncMultiplexImage/AsyncMultiplexImageView.swift index 2abf8ed..479fa75 100644 --- a/Sources/AsyncMultiplexImage/AsyncMultiplexImageView.swift +++ b/Sources/AsyncMultiplexImage/AsyncMultiplexImageView.swift @@ -33,7 +33,7 @@ open class AsyncMultiplexImageView: UIView { public let downloader: any AsyncMultiplexImageDownloader public let offloadStrategy: (any OffloadStrategy)? - private let viewModel: _AsyncMultiplexImageViewModel = .init() + private var task: Task? private var currentUsingNetworkImage: MultiplexImage? private var currentUsingImage: UIImage? @@ -109,7 +109,8 @@ open class AsyncMultiplexImageView: UIView { let offloads = offloadStrategy?.offloads(using: state) if let offloads, offloads { - viewModel.cancelCurrentTask() + self.task?.cancel() + self.task = nil unloadNetworkImage() } @@ -158,14 +159,19 @@ open class AsyncMultiplexImageView: UIView { currentUsingNetworkImage = nil currentUsingImage = image - viewModel.cancelCurrentTask() imageView.image = image + + self.task?.cancel() + self.task = nil + } public func clearImage() { currentUsingNetworkImage = nil imageView.image = nil - viewModel.cancelCurrentTask() + + self.task?.cancel() + self.task = nil } private func startDownload() { @@ -234,7 +240,7 @@ open class AsyncMultiplexImageView: UIView { } } - viewModel.registerCurrentTask(currentTask) + self.task = currentTask } private func unloadNetworkImage() { diff --git a/Sources/AsyncMultiplexImage/DownloadManager.swift b/Sources/AsyncMultiplexImage/DownloadManager.swift new file mode 100644 index 0000000..254dccf --- /dev/null +++ b/Sources/AsyncMultiplexImage/DownloadManager.swift @@ -0,0 +1,181 @@ +// +// DownloadManager.swift +// AsyncMultiplexImage +// +// Created by Muukii on 2025/01/21. +// +import SwiftUI + +actor DownloadManager { + + @MainActor + static let shared = DownloadManager() + + private init() {} + + func start( + source: MultiplexImage, + candidates: [AsyncMultiplexImageCandidate], + downloader: any AsyncMultiplexImageDownloader, + displaySize: CGSize + ) async -> sending AsyncThrowingStream { + + // this instance will be alive until finish + let container = ResultContainer() + + let stream = await container.makeStream( + candidates: candidates, + downloader: downloader, + displaySize: displaySize + ) + + return stream + + } + +} + +actor ResultContainer { + + enum Item { + case progress(UIImage) + case final(UIImage) + + var swiftUI: ItemSwiftUI { + switch self { + case .progress(let image): + return .progress(.init(uiImage: image).renderingMode(.original)) + case .final(let image): + return .final(.init(uiImage: image).renderingMode(.original)) + } + } + } + + enum ItemSwiftUI { + case progress(Image) + case final(Image) + } + + private var referenceCount: UInt64 = 0 + + private var lastCandidate: AsyncMultiplexImageCandidate? = nil + + private var idealImageTask: Task? + private var progressImagesTask: Task? + + deinit { + idealImageTask?.cancel() + progressImagesTask?.cancel() + } + + func incrementReference() { + referenceCount += 1 + } + + func decrementReference() { + referenceCount -= 1 + } + + func makeStream( + candidates: [AsyncMultiplexImageCandidate], + downloader: Downloader, + displaySize: CGSize + ) -> AsyncThrowingStream { + + Log.debug(.`generic`, "Load: \(candidates.map { $0.urlRequest })") + + return .init { [self] continuation in + + continuation.onTermination = { [self] termination in + + switch termination { + case .finished, .cancelled: + Task { + await self.idealImageTask?.cancel() + await self.progressImagesTask?.cancel() + } + @unknown default: + break + } + + } + + guard let idealCandidate = candidates.first else { + continuation.finish() + return + } + + let idealImage = Task { + + do { + let result = try await downloader.download( + candidate: idealCandidate, + displaySize: displaySize + ) + + progressImagesTask?.cancel() + + Log.debug(.`generic`, "Loaded ideal") + + lastCandidate = idealCandidate + continuation.yield(.final(result)) + } catch { + continuation.yield(with: .failure(error)) + } + + continuation.finish() + + } + + idealImageTask = idealImage + + let progressCandidates = candidates.dropFirst(1) + + guard progressCandidates.isEmpty == false else { + return + } + + let progressImages = Task { + + // download images sequentially from lower image + for candidate in progressCandidates.reversed() { + do { + + guard Task.isCancelled == false else { + Log.debug(.`generic`, "Cancelled progress images") + return + } + + Log.debug(.`generic`, "Load progress image => \(candidate.index)") + let result = try await downloader.download( + candidate: candidate, + displaySize: displaySize + ) + + guard Task.isCancelled == false else { + Log.debug(.`generic`, "Cancelled progress images") + return + } + + if let lastCandidate, lastCandidate.index > candidate.index { + continuation.finish() + return + } + + lastCandidate = idealCandidate + + let yieldResult = continuation.yield(.progress(result)) + + Log.debug(.`generic`, "Loaded progress image => \(candidate.index), \(yieldResult)") + } catch { + + } + } + + } + + progressImagesTask = progressImages + + } + } +} diff --git a/Sources/AsyncMultiplexImage/MultiplexImage.swift b/Sources/AsyncMultiplexImage/MultiplexImage.swift index 2750290..64a2f83 100644 --- a/Sources/AsyncMultiplexImage/MultiplexImage.swift +++ b/Sources/AsyncMultiplexImage/MultiplexImage.swift @@ -1,6 +1,6 @@ import Foundation -public struct MultiplexImage: Hashable { +public struct MultiplexImage: Hashable, Sendable { public static func == (lhs: MultiplexImage, rhs: MultiplexImage) -> Bool { lhs.identifier == rhs.identifier @@ -12,11 +12,16 @@ public struct MultiplexImage: Hashable { public let identifier: String - private(set) var _urlsProvider: @MainActor (CGSize) -> [URL] + private(set) var _urlsProvider: @Sendable (CGSize) -> [URL] + /** + - Parameters: + - identifier: The unique identifier of the image. + - urlsProvider: The provider of the image URLs as the first item is the top priority. + */ public init( identifier: String, - urlsProvider: @escaping @MainActor (CGSize) -> [URL] + urlsProvider: @escaping @Sendable (CGSize) -> [URL] ) { self.identifier = identifier self._urlsProvider = urlsProvider From 33183ae41b3584d5fe1b25285b1a99fba059d9a5 Mon Sep 17 00:00:00 2001 From: Muukii Date: Wed, 22 Jan 2025 01:46:51 +0900 Subject: [PATCH 2/4] Update --- .../AsyncMultiplexImage.swift | 9 ++++++++- .../AsyncMultiplexImageView.swift | 6 +++++- .../AsyncMultiplexImage/MultiplexImage.swift | 17 +++++++++++++++-- 3 files changed, 28 insertions(+), 4 deletions(-) diff --git a/Sources/AsyncMultiplexImage/AsyncMultiplexImage.swift b/Sources/AsyncMultiplexImage/AsyncMultiplexImage.swift index ec3a467..ea8478a 100644 --- a/Sources/AsyncMultiplexImage/AsyncMultiplexImage.swift +++ b/Sources/AsyncMultiplexImage/AsyncMultiplexImage.swift @@ -157,6 +157,8 @@ private struct _AsyncMultiplexImage< @State private var item: ResultContainer.ItemSwiftUI? @State private var task: Task<(), Never>? @State private var displaySize: CGSize = .zero + + @Environment(\.displayScale) var displayScale private let imageRepresentation: ImageRepresentation private let downloader: Downloader @@ -235,7 +237,12 @@ private struct _AsyncMultiplexImage< let task = Task.detached { // making new candidates - let urls = multiplexImage._urlsProvider(newSize) + let context = await MultiplexImage.Context( + targetSize: newSize, + displayScale: displayScale + ) + + let urls = multiplexImage._urlsProvider(context) let candidates = urls.enumerated().map { i, e in AsyncMultiplexImageCandidate(index: i, urlRequest: .init(url: e)) diff --git a/Sources/AsyncMultiplexImage/AsyncMultiplexImageView.swift b/Sources/AsyncMultiplexImage/AsyncMultiplexImageView.swift index 479fa75..2ffa675 100644 --- a/Sources/AsyncMultiplexImage/AsyncMultiplexImageView.swift +++ b/Sources/AsyncMultiplexImage/AsyncMultiplexImageView.swift @@ -187,7 +187,11 @@ open class AsyncMultiplexImageView: UIView { } // making new candidates - let urls = image._urlsProvider(newSize) + let context = MultiplexImage.Context( + targetSize: newSize, + displayScale: UIScreen.main.scale + ) + let urls = image._urlsProvider(context) let candidates = urls.enumerated().map { i, e in AsyncMultiplexImageCandidate(index: i, urlRequest: .init(url: e)) diff --git a/Sources/AsyncMultiplexImage/MultiplexImage.swift b/Sources/AsyncMultiplexImage/MultiplexImage.swift index 64a2f83..37de171 100644 --- a/Sources/AsyncMultiplexImage/MultiplexImage.swift +++ b/Sources/AsyncMultiplexImage/MultiplexImage.swift @@ -1,6 +1,19 @@ import Foundation public struct MultiplexImage: Hashable, Sendable { + + public struct Context: ~Copyable { + public let targetSize: CGSize + public let displayScale: CGFloat + + init( + targetSize: consuming CGSize, + displayScale: consuming CGFloat + ) { + self.targetSize = targetSize + self.displayScale = displayScale + } + } public static func == (lhs: MultiplexImage, rhs: MultiplexImage) -> Bool { lhs.identifier == rhs.identifier @@ -12,7 +25,7 @@ public struct MultiplexImage: Hashable, Sendable { public let identifier: String - private(set) var _urlsProvider: @Sendable (CGSize) -> [URL] + let _urlsProvider: @Sendable (borrowing Context) -> [URL] /** - Parameters: @@ -21,7 +34,7 @@ public struct MultiplexImage: Hashable, Sendable { */ public init( identifier: String, - urlsProvider: @escaping @Sendable (CGSize) -> [URL] + urlsProvider: @escaping @Sendable (borrowing Context) -> [URL] ) { self.identifier = identifier self._urlsProvider = urlsProvider From 7d95f7ce4a32e69cf38dddb9dfed4faf8c8e24c2 Mon Sep 17 00:00:00 2001 From: Muukii Date: Wed, 22 Jan 2025 02:42:17 +0900 Subject: [PATCH 3/4] Update --- .../ContentView.swift | 68 +++++---- .../AsyncMultiplexImage-Demo/List.swift | 31 ++-- .../AsyncMultiplexImage.swift | 141 ++++++++++-------- .../AsyncMultiplexImageView.swift | 3 +- .../AsyncMultiplexImage/MultiplexImage.swift | 8 +- 5 files changed, 142 insertions(+), 109 deletions(-) diff --git a/Development/AsyncMultiplexImage-Demo/ContentView.swift b/Development/AsyncMultiplexImage-Demo/ContentView.swift index 631a71d..dac5854 100644 --- a/Development/AsyncMultiplexImage-Demo/ContentView.swift +++ b/Development/AsyncMultiplexImage-Demo/ContentView.swift @@ -45,41 +45,13 @@ actor _SlowDownloader: AsyncMultiplexImageDownloader { struct ContentView: View { - @State private var basePhotoURLString: String = - "https://images.unsplash.com/photo-1492446845049-9c50cc313f00" - var body: some View { NavigationView { Form { Section { NavigationLink("SwiftUI") { - VStack { - AsyncMultiplexImage( - multiplexImage: .init( - identifier: basePhotoURLString, - urls: buildURLs(basePhotoURLString) - ), - downloader: _SlowDownloader(pipeline: .shared), - content: AsyncMultiplexImageBasicContent() - ) - - HStack { - Button("1") { - basePhotoURLString = - "https://images.unsplash.com/photo-1660668377331-da480e5339a0" - } - Button("2") { - basePhotoURLString = - "https://images.unsplash.com/photo-1658214764191-b002b517e9e5" - } - Button("3") { - basePhotoURLString = - "https://images.unsplash.com/photo-1587126396803-be14d33e49cf" - } - } - } - .padding() - .navigationTitle("SwiftUI") + SwitchingDemo() + .navigationTitle("SwiftUI") } NavigationLink("UIKit") { UIKitContentViewRepresentable() @@ -95,6 +67,42 @@ struct ContentView: View { } } +private struct SwitchingDemo: View { + + @State private var basePhotoURLString: String = + "https://images.unsplash.com/photo-1492446845049-9c50cc313f00" + + var body: some View { + VStack { + AsyncMultiplexImage( + multiplexImage: .init( + identifier: basePhotoURLString, + urls: buildURLs(basePhotoURLString) + ), + downloader: _SlowDownloader(pipeline: .shared), + content: AsyncMultiplexImageBasicContent() + ) + + HStack { + Button("1") { + basePhotoURLString = + "https://images.unsplash.com/photo-1660668377331-da480e5339a0" + } + Button("2") { + basePhotoURLString = + "https://images.unsplash.com/photo-1658214764191-b002b517e9e5" + } + Button("3") { + basePhotoURLString = + "https://images.unsplash.com/photo-1587126396803-be14d33e49cf" + } + } + } + .padding() + } + +} + struct UIKitContentViewRepresentable: UIViewRepresentable { func makeUIView(context: Context) -> UIKitContentView { diff --git a/Development/AsyncMultiplexImage-Demo/List.swift b/Development/AsyncMultiplexImage-Demo/List.swift index b786c86..b4d199a 100644 --- a/Development/AsyncMultiplexImage-Demo/List.swift +++ b/Development/AsyncMultiplexImage-Demo/List.swift @@ -7,19 +7,28 @@ struct StressGrid: View { @State var items: [Entity] = Entity.batch() var body: some View { - ScrollView { - LazyVGrid( - columns: [ - .init(.flexible(minimum: 0, maximum: .infinity), spacing: 2), - .init(.flexible(minimum: 0, maximum: .infinity), spacing: 2), - .init(.flexible(minimum: 0, maximum: .infinity), spacing: 2), - .init(.flexible(minimum: 0, maximum: .infinity), spacing: 2), - ], spacing: 2 - ) { - ForEach(items) { entity in - Cell(entity: entity) + GeometryReader { proxy in + ScrollView { + LazyVGrid( + columns: [ + .init(.flexible(minimum: 0, maximum: .infinity), spacing: 2), + .init(.flexible(minimum: 0, maximum: .infinity), spacing: 2), + .init(.flexible(minimum: 0, maximum: .infinity), spacing: 2), + .init(.flexible(minimum: 0, maximum: .infinity), spacing: 2), + ], spacing: 2 + ) { + ForEach(items) { entity in + Cell(entity: entity) + } } } + .onPreferenceChange(AnchorPreferenceKey.self, perform: { v in + guard let v = v else { + return + } + let bounds = proxy[v] + print(bounds) + }) } } diff --git a/Sources/AsyncMultiplexImage/AsyncMultiplexImage.swift b/Sources/AsyncMultiplexImage/AsyncMultiplexImage.swift index ea8478a..b8e95c1 100644 --- a/Sources/AsyncMultiplexImage/AsyncMultiplexImage.swift +++ b/Sources/AsyncMultiplexImage/AsyncMultiplexImage.swift @@ -143,6 +143,14 @@ public struct AsyncMultiplexImage< } +public enum AnchorPreferenceKey: PreferenceKey { + public static var defaultValue: Anchor? { + nil + } + public static func reduce(value: inout Value, nextValue: () -> Value) { + value = nextValue() + } +} private struct _AsyncMultiplexImage< Content: AsyncMultiplexImageContent, Downloader: AsyncMultiplexImageDownloader @@ -151,14 +159,12 @@ private struct _AsyncMultiplexImage< private struct UpdateTrigger: Equatable { let size: CGSize let image: ImageRepresentation - let isInDisplay: Bool } @State private var item: ResultContainer.ItemSwiftUI? - @State private var task: Task<(), Never>? @State private var displaySize: CGSize = .zero - @Environment(\.displayScale) var displayScale +// @Environment(\.displayScale) var displayScale private let imageRepresentation: ImageRepresentation private let downloader: Downloader @@ -194,7 +200,7 @@ private struct _AsyncMultiplexImage< } }() ) - .frame(width: displaySize.width, height: displaySize.height) + .frame(width: displaySize.width, height: displaySize.height) ) .onGeometryChange( for: CGSize.self, @@ -203,23 +209,19 @@ private struct _AsyncMultiplexImage< displaySize = newValue } ) - .onChangeWithPrevious( - of: UpdateTrigger( - size: displaySize, - image: imageRepresentation, - isInDisplay: true - ), - emitsInitial: true, - perform: { - trigger, - _ in - - let newSize = trigger.size - + .task(id: UpdateTrigger( + size: displaySize, + image: imageRepresentation + ), { + + await withTaskCancellationHandler { + + let newSize = displaySize + guard newSize.height > 0 && newSize.width > 0 else { return } - + if clearsContentBeforeDownload { var transaction = Transaction() transaction.disablesAnimations = true @@ -227,68 +229,77 @@ private struct _AsyncMultiplexImage< self.item = nil } } - - switch trigger.image { + + switch imageRepresentation { case .remote(let multiplexImage): - - self.task?.cancel() - self.task = nil - - let task = Task.detached { - + + let candidates = await pushBackground { + // making new candidates - let context = await MultiplexImage.Context( + let context = MultiplexImage.Context( targetSize: newSize, - displayScale: displayScale + displayScale: 1 ) + + let urls = multiplexImage.makeURLs(context: context) - let urls = multiplexImage._urlsProvider(context) - let candidates = urls.enumerated().map { i, e in AsyncMultiplexImageCandidate(index: i, urlRequest: .init(url: e)) } - - let stream = await DownloadManager.shared.start( - source: multiplexImage, - candidates: candidates, - downloader: downloader, - displaySize: newSize - ) - - guard Task.isCancelled == false else { - return - } - - do { - for try await item in stream { - - guard Task.isCancelled == false else { - return - } - - await MainActor.run { - self.item = item.swiftUI - } + + return candidates + } + + guard Task.isCancelled == false else { + return + } + + let stream = await DownloadManager.shared.start( + source: multiplexImage, + candidates: candidates, + downloader: downloader, + displaySize: newSize + ) + + guard Task.isCancelled == false else { + return + } + + do { + for try await item in stream { + + guard Task.isCancelled == false else { + return + } + + await MainActor.run { + self.item = item.swiftUI } - } catch { - // FIXME: Error handling } - - } - - self.task = task - + } catch { + // FIXME: Error handling + } + case .loaded(let image): - - self.task?.cancel() - self.task = nil + self.item = .final(image) - + } - } - ) + } onCancel: { + print("cancel") + } + + }) .clipped(antialiased: true) +// .onDisappear { +// self.task?.cancel() +// self.task = nil +// } } } + +private func pushBackground(task: @Sendable () -> sending Result) async -> sending Result { + task() +} diff --git a/Sources/AsyncMultiplexImage/AsyncMultiplexImageView.swift b/Sources/AsyncMultiplexImage/AsyncMultiplexImageView.swift index 2ffa675..93c6990 100644 --- a/Sources/AsyncMultiplexImage/AsyncMultiplexImageView.swift +++ b/Sources/AsyncMultiplexImage/AsyncMultiplexImageView.swift @@ -191,7 +191,8 @@ open class AsyncMultiplexImageView: UIView { targetSize: newSize, displayScale: UIScreen.main.scale ) - let urls = image._urlsProvider(context) + + let urls = image.makeURLs(context: context) let candidates = urls.enumerated().map { i, e in AsyncMultiplexImageCandidate(index: i, urlRequest: .init(url: e)) diff --git a/Sources/AsyncMultiplexImage/MultiplexImage.swift b/Sources/AsyncMultiplexImage/MultiplexImage.swift index 37de171..84d589f 100644 --- a/Sources/AsyncMultiplexImage/MultiplexImage.swift +++ b/Sources/AsyncMultiplexImage/MultiplexImage.swift @@ -25,7 +25,7 @@ public struct MultiplexImage: Hashable, Sendable { public let identifier: String - let _urlsProvider: @Sendable (borrowing Context) -> [URL] + private let _urlsProvider: @Sendable (borrowing Context) -> [URL] /** - Parameters: @@ -43,7 +43,11 @@ public struct MultiplexImage: Hashable, Sendable { public init(identifier: String, urls: [URL]) { self.init(identifier: identifier, urlsProvider: { _ in urls }) } - + + func makeURLs(context: borrowing Context) -> [URL] { + _urlsProvider(context) + } + } // MARK: convenience init From 298f61778666d1e8c7c5c0061f4e432ed140fe9e Mon Sep 17 00:00:00 2001 From: Muukii Date: Wed, 22 Jan 2025 02:43:35 +0900 Subject: [PATCH 4/4] Update --- Sources/AsyncMultiplexImage/AsyncMultiplexImage.swift | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Sources/AsyncMultiplexImage/AsyncMultiplexImage.swift b/Sources/AsyncMultiplexImage/AsyncMultiplexImage.swift index b8e95c1..9084349 100644 --- a/Sources/AsyncMultiplexImage/AsyncMultiplexImage.swift +++ b/Sources/AsyncMultiplexImage/AsyncMultiplexImage.swift @@ -162,9 +162,8 @@ private struct _AsyncMultiplexImage< } @State private var item: ResultContainer.ItemSwiftUI? - @State private var displaySize: CGSize = .zero - -// @Environment(\.displayScale) var displayScale + @State private var displaySize: CGSize = .zero + @Environment(\.displayScale) var displayScale private let imageRepresentation: ImageRepresentation private let downloader: Downloader @@ -233,12 +232,13 @@ private struct _AsyncMultiplexImage< switch imageRepresentation { case .remote(let multiplexImage): + let displayScale = self.displayScale let candidates = await pushBackground { // making new candidates let context = MultiplexImage.Context( targetSize: newSize, - displayScale: 1 + displayScale: displayScale ) let urls = multiplexImage.makeURLs(context: context)