From bea073279aa98f62c74eec4d2905dac3d1947d0f Mon Sep 17 00:00:00 2001 From: Muukii Date: Sun, 16 Jul 2023 01:03:41 +0900 Subject: [PATCH 1/3] :evergreen_tree: Update --- .../project.pbxproj | 6 +- .../xcshareddata/swiftpm/Package.resolved | 17 +- .../AsyncMultiplexImage-Demo.xcscheme | 84 +++++ .../ContentView.swift | 8 +- .../AsyncMultiplexImage.swift | 324 ++++++++++-------- 5 files changed, 286 insertions(+), 153 deletions(-) create mode 100644 AsyncMultiplexImage-Demo/AsyncMultiplexImage-Demo.xcodeproj/xcshareddata/xcschemes/AsyncMultiplexImage-Demo.xcscheme 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..93217a4 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,18 +109,20 @@ 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 + // sharing + @StateObject private var viewModel: _AsyncMultiplexImageViewModel = .init() public init( multiplexImage: MultiplexImage, @@ -111,156 +131,170 @@ 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: View +{ + + private struct Pair: 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 - + public init( + viewModel: _AsyncMultiplexImageViewModel, multiplexImage: MultiplexImage, downloader: Downloader, @ViewBuilder content: @escaping (AsyncMultiplexImagePhase) -> Content ) { - + + self.viewModel = viewModel 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: Pair(size: proxy.size, image: multiplexImage), + emitsInitial: true, + perform: { pair, _ 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 = pair.size - do { - for try await item in stream { - self.item = item + guard newSize.height > 0 && newSize.width > 0 else { + return + } + + // 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 +304,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 +331,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 - + } } } - From 7b5ab08fb9c7d2e387b2ffae3ba92e241ccbd4db Mon Sep 17 00:00:00 2001 From: Muukii Date: Sun, 16 Jul 2023 01:14:38 +0900 Subject: [PATCH 2/3] :evergreen_tree: Update --- .../AsyncMultiplexImage.swift | 22 +++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/Sources/AsyncMultiplexImage/AsyncMultiplexImage.swift b/Sources/AsyncMultiplexImage/AsyncMultiplexImage.swift index 93217a4..9f295d6 100644 --- a/Sources/AsyncMultiplexImage/AsyncMultiplexImage.swift +++ b/Sources/AsyncMultiplexImage/AsyncMultiplexImage.swift @@ -121,15 +121,18 @@ public struct AsyncMultiplexImage 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.downloader = downloader self.content = content @@ -139,6 +142,7 @@ public struct AsyncMultiplexImage: View { - private struct Pair: Equatable { + private struct UpdateTrigger: Equatable { let size: CGSize let image: MultiplexImage } @@ -186,15 +190,18 @@ private struct _AsyncMultiplexImage 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 @@ -217,16 +224,23 @@ private struct _AsyncMultiplexImage 0 && newSize.width > 0 else { return } + if clearsContentBeforeDownload { + self.item = nil + } + // making new candidates let urls = multiplexImage._urlsProvider(newSize) From ce41c887487b231da9e964e688e269d96e14deaf Mon Sep 17 00:00:00 2001 From: Muukii Date: Sun, 16 Jul 2023 01:53:11 +0900 Subject: [PATCH 3/3] :evergreen_tree: Update --- Sources/AsyncMultiplexImage/AsyncMultiplexImage.swift | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/Sources/AsyncMultiplexImage/AsyncMultiplexImage.swift b/Sources/AsyncMultiplexImage/AsyncMultiplexImage.swift index 9f295d6..9329425 100644 --- a/Sources/AsyncMultiplexImage/AsyncMultiplexImage.swift +++ b/Sources/AsyncMultiplexImage/AsyncMultiplexImage.swift @@ -238,7 +238,11 @@ private struct _AsyncMultiplexImage