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..dac5854 100644
--- a/Development/AsyncMultiplexImage-Demo/ContentView.swift
+++ b/Development/AsyncMultiplexImage-Demo/ContentView.swift
@@ -45,60 +45,21 @@ 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)
- ) { 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")
- }
- }
-
- 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()
}
- NavigationLink("SwiftUI List", destination: { UsingList() })
+
+ NavigationLink("Stress 1", destination: { StressGrid() })
+
+ NavigationLink("Stress 2", destination: { StressGrid() })
}
.navigationTitle("Multiplex Image")
}
@@ -106,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 0159ad7..b4d199a 100644
--- a/Development/AsyncMultiplexImage-Demo/List.swift
+++ b/Development/AsyncMultiplexImage-Demo/List.swift
@@ -1,54 +1,66 @@
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()
var body: some 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
- ) {
- 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)
+ 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)
+ })
}
}
}
#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 +68,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 +133,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..9084349 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
+public enum AnchorPreferenceKey: PreferenceKey {
+ public static var defaultValue: Anchor? {
+ nil
}
-
- func cancelCurrentTask() {
- guard let task else { return }
- guard task.isCancelled == false else { return }
- task.cancel()
+ public static func reduce(value: inout Value, nextValue: () -> Value) {
+ value = nextValue()
}
-
- 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
}
- @State private var currentUsingCandidates: [AsyncMultiplexImageCandidate] = []
-
@State private var item: ResultContainer.ItemSwiftUI?
-
- let viewModel: _AsyncMultiplexImageViewModel
+ @State private var displaySize: CGSize = .zero
+ @Environment(\.displayScale) var displayScale
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,34 +185,42 @@ private struct _AsyncMultiplexImage 0 && newSize.width > 0 else {
return
}
-
+
if clearsContentBeforeDownload {
var transaction = Transaction()
transaction.disablesAnimations = true
@@ -245,190 +228,78 @@ 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
+
+ let stream = await DownloadManager.shared.start(
+ source: multiplexImage,
+ candidates: candidates,
+ downloader: downloader,
+ displaySize: newSize
)
-
+
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 {
-
+
+ do {
+ for try await item in stream {
+
+ guard Task.isCancelled == false else {
+ return
+ }
+
+ await MainActor.run {
+ self.item = item.swiftUI
+ }
+ }
+ } catch {
+ // FIXME: Error handling
+ }
+
+ case .loaded(let image):
+
+ self.item = .final(image)
+
}
- }
+ } onCancel: {
+ print("cancel")
+ }
+
+ })
+ .clipped(antialiased: true)
+// .onDisappear {
+// self.task?.cancel()
+// self.task = nil
+// }
- }
+ }
- progressImagesTask = progressImages
+}
- }
- }
+private func pushBackground(task: @Sendable () -> sending Result) async -> sending Result {
+ task()
}
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..93c6990 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() {
@@ -181,7 +187,12 @@ 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.makeURLs(context: context)
let candidates = urls.enumerated().map { i, e in
AsyncMultiplexImageCandidate(index: i, urlRequest: .init(url: e))
@@ -234,7 +245,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..84d589f 100644
--- a/Sources/AsyncMultiplexImage/MultiplexImage.swift
+++ b/Sources/AsyncMultiplexImage/MultiplexImage.swift
@@ -1,6 +1,19 @@
import Foundation
-public struct MultiplexImage: Hashable {
+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,11 +25,16 @@ public struct MultiplexImage: Hashable {
public let identifier: String
- private(set) var _urlsProvider: @MainActor (CGSize) -> [URL]
+ private let _urlsProvider: @Sendable (borrowing Context) -> [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 (borrowing Context) -> [URL]
) {
self.identifier = identifier
self._urlsProvider = urlsProvider
@@ -25,7 +43,11 @@ public struct MultiplexImage: Hashable {
public init(identifier: String, urls: [URL]) {
self.init(identifier: identifier, urlsProvider: { _ in urls })
}
-
+
+ func makeURLs(context: borrowing Context) -> [URL] {
+ _urlsProvider(context)
+ }
+
}
// MARK: convenience init