diff --git a/AsyncMultiplexImage-Demo/AsyncMultiplexImage-Demo.xcodeproj/project.pbxproj b/AsyncMultiplexImage-Demo/AsyncMultiplexImage-Demo.xcodeproj/project.pbxproj
index 1b96f4f..e3aafcd 100644
--- a/AsyncMultiplexImage-Demo/AsyncMultiplexImage-Demo.xcodeproj/project.pbxproj
+++ b/AsyncMultiplexImage-Demo/AsyncMultiplexImage-Demo.xcodeproj/project.pbxproj
@@ -215,6 +215,7 @@
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
+ IPHONEOS_DEPLOYMENT_TARGET = 14.0;
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
MTL_FAST_MATH = YES;
ONLY_ACTIVE_ARCH = YES;
@@ -267,6 +268,7 @@
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
+ IPHONEOS_DEPLOYMENT_TARGET = 14.0;
MTL_ENABLE_DEBUG_INFO = NO;
MTL_FAST_MATH = YES;
SWIFT_COMPILATION_MODE = wholemodule;
@@ -292,7 +294,7 @@
"INFOPLIST_KEY_UIStatusBarStyle[sdk=iphoneos*]" = UIStatusBarStyleDefault;
"INFOPLIST_KEY_UIStatusBarStyle[sdk=iphonesimulator*]" = UIStatusBarStyleDefault;
INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait;
- IPHONEOS_DEPLOYMENT_TARGET = 13.0;
+ IPHONEOS_DEPLOYMENT_TARGET = 14.0;
LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks";
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
MACOSX_DEPLOYMENT_TARGET = 12.3;
@@ -327,7 +329,7 @@
"INFOPLIST_KEY_UIStatusBarStyle[sdk=iphoneos*]" = UIStatusBarStyleDefault;
"INFOPLIST_KEY_UIStatusBarStyle[sdk=iphonesimulator*]" = UIStatusBarStyleDefault;
INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait;
- IPHONEOS_DEPLOYMENT_TARGET = 13.0;
+ IPHONEOS_DEPLOYMENT_TARGET = 14.0;
LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks";
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
MACOSX_DEPLOYMENT_TARGET = 12.3;
diff --git a/AsyncMultiplexImage-Demo/AsyncMultiplexImage-Demo.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/AsyncMultiplexImage-Demo/AsyncMultiplexImage-Demo.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved
index 5f323e7..f35381f 100644
--- a/AsyncMultiplexImage-Demo/AsyncMultiplexImage-Demo.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved
+++ b/AsyncMultiplexImage-Demo/AsyncMultiplexImage-Demo.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved
@@ -5,8 +5,17 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/kean/Nuke.git",
"state" : {
- "revision" : "9df754fe4ca4c5abdf3376e4e9ec33b3485bf180",
- "version" : "11.2.1"
+ "revision" : "c3864b8882bc69f5edfe5c70e18786c91d228b28",
+ "version" : "12.1.3"
+ }
+ },
+ {
+ "identity" : "swiftui-gesture-velocity",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/FluidGroup/swiftui-gesture-velocity",
+ "state" : {
+ "revision" : "9c83f8995f9e5efc29db2fca4b9ff058283f1603",
+ "version" : "1.0.0"
}
},
{
@@ -14,8 +23,8 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/FluidGroup/swiftui-support.git",
"state" : {
- "revision" : "16ee1ed32465765f6eb293cb45862618e44321f0",
- "version" : "0.2.3"
+ "revision" : "8ef53190c33bd345e7a95ef504dafe0f85ad9c4d",
+ "version" : "0.4.1"
}
}
],
diff --git a/AsyncMultiplexImage-Demo/AsyncMultiplexImage-Demo.xcodeproj/xcshareddata/xcschemes/AsyncMultiplexImage-Demo.xcscheme b/AsyncMultiplexImage-Demo/AsyncMultiplexImage-Demo.xcodeproj/xcshareddata/xcschemes/AsyncMultiplexImage-Demo.xcscheme
new file mode 100644
index 0000000..f6c7b6a
--- /dev/null
+++ b/AsyncMultiplexImage-Demo/AsyncMultiplexImage-Demo.xcodeproj/xcshareddata/xcschemes/AsyncMultiplexImage-Demo.xcscheme
@@ -0,0 +1,84 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/AsyncMultiplexImage-Demo/AsyncMultiplexImage-Demo/ContentView.swift b/AsyncMultiplexImage-Demo/AsyncMultiplexImage-Demo/ContentView.swift
index b7718ed..65ed9e4 100644
--- a/AsyncMultiplexImage-Demo/AsyncMultiplexImage-Demo/ContentView.swift
+++ b/AsyncMultiplexImage-Demo/AsyncMultiplexImage-Demo/ContentView.swift
@@ -13,15 +13,15 @@ import SwiftUI
import Nuke
struct _SlowDownloader: AsyncMultiplexImageDownloader {
-
+
let pipeline: ImagePipeline
init(pipeline: ImagePipeline) {
self.pipeline = pipeline
}
- func download(candidate: AsyncMultiplexImageCandidate) async throws -> Image {
-
+ func download(candidate: AsyncMultiplexImageCandidate, displaySize: CGSize) async throws -> Image {
+
switch candidate.index {
case 0:
try? await Task.sleep(nanoseconds: 2_000_000_000)
@@ -36,7 +36,7 @@ struct _SlowDownloader: AsyncMultiplexImageDownloader {
}
let response = try await pipeline.image(for: .init(urlRequest: candidate.urlRequest))
- return .init(uiImage: response.image)
+ return .init(uiImage: response)
}
}
diff --git a/Sources/AsyncMultiplexImage/AsyncMultiplexImage.swift b/Sources/AsyncMultiplexImage/AsyncMultiplexImage.swift
index 3221180..9329425 100644
--- a/Sources/AsyncMultiplexImage/AsyncMultiplexImage.swift
+++ b/Sources/AsyncMultiplexImage/AsyncMultiplexImage.swift
@@ -1,58 +1,76 @@
import Foundation
import SwiftUI
-import os.log
import SwiftUISupport
+import os.log
enum Log {
-
+
static func debug(
file: StaticString = #file,
line: UInt = #line,
_ log: OSLog,
_ object: @autoclosure () -> Any
) {
- os_log(.default, log: log, "%{public}@\n%{public}@:%{public}@", "\(object())", "\(file)", "\(line.description)")
+ os_log(
+ .default,
+ log: log,
+ "%{public}@\n%{public}@:%{public}@",
+ "\(object())",
+ "\(file)",
+ "\(line.description)"
+ )
}
-
+
static func error(
file: StaticString = #file,
line: UInt = #line,
_ log: OSLog,
_ object: @autoclosure () -> Any
) {
- os_log(.error, log: log, "%{public}@\n%{public}@:%{public}@", "\(object())", "\(file)", "\(line.description)")
+ os_log(
+ .error,
+ log: log,
+ "%{public}@\n%{public}@:%{public}@",
+ "\(object())",
+ "\(file)",
+ "\(line.description)"
+ )
}
-
+
}
extension OSLog {
-
+
@inline(__always)
private static func makeOSLogInDebug(isEnabled: Bool = true, _ factory: () -> OSLog) -> OSLog {
-#if DEBUG
+ #if DEBUG
if ProcessInfo.processInfo.environment["ASYNC_MULTIPLEX_IMAGE_LOG_ENABLED"] == "1" {
return factory()
} else {
return .disabled
}
-#else
+ #else
return .disabled
-#endif
+ #endif
+ }
+
+ static let generic: OSLog = makeOSLogInDebug {
+ OSLog.init(subsystem: "app.muukii", category: "default")
+ }
+ static let view: OSLog = makeOSLogInDebug {
+ OSLog.init(subsystem: "app.muukii", category: "View")
}
-
- static let generic: OSLog = makeOSLogInDebug { OSLog.init(subsystem: "app.muukii", category: "default") }
- static let view: OSLog = makeOSLogInDebug { OSLog.init(subsystem: "app.muukii", category: "View") }
}
@MainActor
public final class DownloadManager {
-
+
public static let shared: DownloadManager = .init()
-
+
}
public protocol AsyncMultiplexImageDownloader {
-
+
func download(candidate: AsyncMultiplexImageCandidate, displaySize: CGSize) async throws -> Image
}
@@ -64,26 +82,26 @@ public enum AsyncMultiplexImagePhase {
}
public struct AsyncMultiplexImageCandidate: Hashable {
-
+
public let index: Int
public let urlRequest: URLRequest
}
public struct MultiplexImage: Hashable {
-
+
public static func == (lhs: MultiplexImage, rhs: MultiplexImage) -> Bool {
lhs.identifier == rhs.identifier
}
-
+
public func hash(into hasher: inout Hasher) {
identifier.hash(into: &hasher)
}
-
+
public let identifier: String
-
+
fileprivate private(set) var _urlsProvider: @MainActor (CGSize) -> [URL]
-
+
public init(
identifier: String,
urlsProvider: @escaping @MainActor (CGSize) -> [URL]
@@ -91,176 +109,210 @@ public struct MultiplexImage: Hashable {
self.identifier = identifier
self._urlsProvider = urlsProvider
}
-
+
public init(identifier: String, urls: [URL]) {
self.init(identifier: identifier, urlsProvider: { _ in urls })
}
-
+
}
public struct AsyncMultiplexImage: View {
private let multiplexImage: MultiplexImage
-
- private let backing: _AsyncMultiplexImage
+ private let downloader: Downloader
+ private let content: (AsyncMultiplexImagePhase) -> Content
+ private let clearsContentBeforeDownload: Bool
+ // sharing
+ @StateObject private var viewModel: _AsyncMultiplexImageViewModel = .init()
public init(
multiplexImage: MultiplexImage,
downloader: Downloader,
+ clearsContentBeforeDownload: Bool = true,
@ViewBuilder content: @escaping (AsyncMultiplexImagePhase) -> Content
) {
+ self.clearsContentBeforeDownload = clearsContentBeforeDownload
self.multiplexImage = multiplexImage
- self.backing = _AsyncMultiplexImage(multiplexImage: multiplexImage, downloader: downloader, content: content)
+ self.downloader = downloader
+ self.content = content
+
}
public var body: some View {
- backing
- .id(multiplexImage)
+ _AsyncMultiplexImage(
+ viewModel: viewModel,
+ clearsContentBeforeDownload: clearsContentBeforeDownload,
+ multiplexImage: multiplexImage,
+ downloader: downloader,
+ content: content
+ )
}
}
-private struct _AsyncMultiplexImage: View {
+@MainActor
+private final class _AsyncMultiplexImageViewModel: ObservableObject {
+
+ private var task: Task?
+
+ 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 UpdateTrigger: Equatable {
+ let size: CGSize
+ let image: MultiplexImage
+ }
@State private var candidates: [AsyncMultiplexImageCandidate] = []
-
- @State private var internalView: _internal_AsyncMultiplexImage?
-
+ @State private var item: ResultContainer.Item?
+
+ let viewModel: _AsyncMultiplexImageViewModel
+
private let multiplexImage: MultiplexImage
private let downloader: Downloader
private let content: (AsyncMultiplexImagePhase) -> Content
-
+ private let clearsContentBeforeDownload: Bool
+
public init(
+ viewModel: _AsyncMultiplexImageViewModel,
+ clearsContentBeforeDownload: Bool,
multiplexImage: MultiplexImage,
downloader: Downloader,
@ViewBuilder content: @escaping (AsyncMultiplexImagePhase) -> Content
) {
-
+
+ self.viewModel = viewModel
+ self.clearsContentBeforeDownload = clearsContentBeforeDownload
self.multiplexImage = multiplexImage
self.downloader = downloader
self.content = content
}
-
- public var body: some View {
- GeometryReader { proxy in
- Group {
- if let internalView {
- internalView
- } else {
- // for <= iOS 14
- Color.clear
- }
- }
- .onChangeWithPrevious(of: proxy.size, emitsInitial: true, perform: { newSize, _ in
-
- let urls = multiplexImage._urlsProvider(newSize)
-
- let candidates = urls.enumerated().map { i, e in AsyncMultiplexImageCandidate(index: i, urlRequest: .init(url: e)) }
-
- self.candidates = candidates
- self.internalView = .init(candidates: candidates, downloader: downloader, content: content)
- })
- }
- .id(multiplexImage)
- }
-
-}
-@_spi(Internal)
-public struct _internal_AsyncMultiplexImage: View {
-
- @State private var item: ResultContainer.Item?
- @State private var task: Task?
-
- private let downloader: Downloader
- private let candidates: [AsyncMultiplexImageCandidate]
- private let content: (AsyncMultiplexImagePhase) -> Content
-
- /// Primitive initializer
- init(
- candidates: [AsyncMultiplexImageCandidate],
- downloader: Downloader,
- @ViewBuilder content: @escaping (AsyncMultiplexImagePhase) -> Content
- ) {
- self.candidates = candidates
- self.downloader = downloader
- self.content = content
- }
-
public var body: some View {
-
+
GeometryReader { proxy in
- content({
- switch item {
- case .none:
- return .empty
- case .some(.progress(let image)):
- return .progress(image.renderingMode(.original))
- case .some(.final(let image)):
- return .success(image.renderingMode(.original))
- }
- }())
+ content(
+ {
+ switch item {
+ case .none:
+ return .empty
+ case .some(.progress(let image)):
+ return .progress(image.renderingMode(.original))
+ case .some(.final(let image)):
+ return .success(image.renderingMode(.original))
+ }
+ }()
+ )
.frame(width: proxy.size.width, height: proxy.size.height)
- .onAppear {
+ .onChangeWithPrevious(
+ of: UpdateTrigger(
+ size: proxy.size,
+ image: multiplexImage
+ ),
+ emitsInitial: true,
+ perform: { trigger, _ in
- let currentTask = Task {
- // this instance will be alive until finish
- let container = ResultContainer()
- let stream = await container.make(candidates: candidates, downloader: downloader, displaySize: proxy.size)
+ let newSize = trigger.size
- do {
- for try await item in stream {
- self.item = item
+ guard newSize.height > 0 && newSize.width > 0 else {
+ return
+ }
+
+ if clearsContentBeforeDownload {
+ var transaction = Transaction()
+ transaction.disablesAnimations = true
+ withTransaction(transaction) {
+ self.item = nil
+ }
+ }
+
+ // making new candidates
+ let urls = multiplexImage._urlsProvider(newSize)
+
+ let candidates = urls.enumerated().map { i, e in
+ AsyncMultiplexImageCandidate(index: i, urlRequest: .init(url: e))
+ }
+
+ // start download
+
+ let currentTask = Task { @MainActor in
+ // this instance will be alive until finish
+ let container = ResultContainer()
+ let stream = await container.make(
+ candidates: candidates,
+ downloader: downloader,
+ displaySize: newSize
+ )
+
+ do {
+ for try await item in stream {
+ self.item = item
+ }
+ } catch {
+ // FIXME: Error handling
}
- } catch {
- // FIXME: Error handling
}
+
+ viewModel.registerCurrentTask(currentTask)
}
+ )
+ .clipped()
- task = currentTask
- }
}
- .clipped()
- .onDisappear {
- task?.cancel()
- }
-
}
-
+
}
actor ResultContainer {
-
+
enum Item {
case progress(Image)
case final(Image)
}
-
+
var lastCandidate: AsyncMultiplexImageCandidate? = nil
-
+
var idealImageTask: Task?
var progressImagesTask: Task?
-
+
deinit {
idealImageTask?.cancel()
progressImagesTask?.cancel()
}
-
+
func make(
candidates: [AsyncMultiplexImageCandidate],
downloader: Downloader,
displaySize: CGSize
) -> AsyncThrowingStream- {
-
+
Log.debug(.`generic`, "Load: \(candidates.map { $0.urlRequest })")
-
+
return .init { continuation in
-
+
continuation.onTermination = { [weak self] termination in
-
+
guard let self else { return }
-
+
switch termination {
case .finished, .cancelled:
Task {
@@ -270,18 +322,21 @@ actor ResultContainer {
@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)
+ let result = try await downloader.download(
+ candidate: idealCandidate,
+ displaySize: displaySize
+ )
progressImagesTask?.cancel()
@@ -294,57 +349,58 @@ actor ResultContainer {
}
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 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
-
+
}
}
}
-