From ba86cb562b16d6072b43a8235f764d8685ace0c3 Mon Sep 17 00:00:00 2001 From: johncorner Date: Sun, 21 Apr 2024 04:01:08 -0400 Subject: [PATCH 01/27] gif support --- .../Sources/ImageDecoder/ImageDecoder.swift | 2 +- .../Sources/Model/TransientImage.swift | 8 +- .../Sources/PlainDatabase/Database.swift | 22 ++ Package.swift | 4 +- .../Store/URLImageFileStoreType.swift | 20 ++ .../URLImage/Store/URLImageStoreType.swift | 10 + Sources/URLImage/URLImage.swift | 22 ++ Sources/URLImage/Views/GIFImage.swift | 195 ++++++++++++++++++ .../URLImageFileStore_Concurrency.swift | 1 + 9 files changed, 277 insertions(+), 7 deletions(-) create mode 100644 Sources/URLImage/Views/GIFImage.swift create mode 100644 Sources/URLImageStore/URLImageFileStore_Concurrency.swift diff --git a/Dependencies/Sources/ImageDecoder/ImageDecoder.swift b/Dependencies/Sources/ImageDecoder/ImageDecoder.swift index 9d7037e..08081a6 100644 --- a/Dependencies/Sources/ImageDecoder/ImageDecoder.swift +++ b/Dependencies/Sources/ImageDecoder/ImageDecoder.swift @@ -282,7 +282,7 @@ public final class ImageDecoder { kCGImageSourceCreateThumbnailFromImageAlways: true ] - private let imageSource: CGImageSource + public let imageSource: CGImageSource private func imageSourceOptions(with subsamplingLevel: SubsamplingLevel = .default) -> CFDictionary { var options = ImageDecoder.imageSourceOptions diff --git a/Dependencies/Sources/Model/TransientImage.swift b/Dependencies/Sources/Model/TransientImage.swift index 079983f..a81e987 100644 --- a/Dependencies/Sources/Model/TransientImage.swift +++ b/Dependencies/Sources/Model/TransientImage.swift @@ -34,17 +34,17 @@ public struct TransientImage { self.cgOrientation = cgOrientation } - private let proxy: CGImageProxy + public let proxy: CGImageProxy } /// Proxy used to decode image lazily @available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) -final class CGImageProxy { +public final class CGImageProxy { - let decoder: ImageDecoder + public let decoder: ImageDecoder - let maxPixelSize: CGSize? + public let maxPixelSize: CGSize? init(decoder: ImageDecoder, maxPixelSize: CGSize?) { self.decoder = decoder diff --git a/Dependencies/Sources/PlainDatabase/Database.swift b/Dependencies/Sources/PlainDatabase/Database.swift index 8d884d8..b4dcc52 100644 --- a/Dependencies/Sources/PlainDatabase/Database.swift +++ b/Dependencies/Sources/PlainDatabase/Database.swift @@ -45,6 +45,28 @@ public final class Database { context.undoManager = nil } + public func async(_ closure: @escaping (_ context: NSManagedObjectContext) throws -> Void) async { + if #available(macOS 12.0, iOS 15.0, *) { + do { + try await context.perform(schedule: .immediate) { [unowned self] in + try closure(self.context) + } + } catch { + print(error) + } + } else { + // Fallback on earlier versions + context.perform { [unowned self] in + do { + try closure(self.context) + } + catch { + print(error) + } + } + } + } + public func async(_ closure: @escaping (_ context: NSManagedObjectContext) throws -> Void) { context.perform { [unowned self] in do { diff --git a/Package.swift b/Package.swift index f3f026e..0974530 100644 --- a/Package.swift +++ b/Package.swift @@ -6,9 +6,9 @@ import PackageDescription let package = Package( name: "URLImage", platforms: [ - .iOS(.v12), + .iOS(.v13), .tvOS(.v12), - .macOS(.v10_13), + .macOS(.v10_15), .watchOS(.v4) ], products: [ diff --git a/Sources/URLImage/Store/URLImageFileStoreType.swift b/Sources/URLImage/Store/URLImageFileStoreType.swift index 7ff0d6b..24eb055 100644 --- a/Sources/URLImage/Store/URLImageFileStoreType.swift +++ b/Sources/URLImage/Store/URLImageFileStoreType.swift @@ -26,3 +26,23 @@ public protocol URLImageFileStoreType: URLImageStoreType { /// Move image file from the temporary location to the store. func moveImageFile(from location: URL, info: URLImageStoreInfo) } + + +/// Type that declares requirements for a persistent store to store image files. +@available(macOS 10.15.0, iOS 13.0, *) +public protocol URLImageFileStoreType_Concurrency: URLImageStoreType_Concurrency { + + /// Get image from the strore. + /// + /// - parameters: + /// - keys: An array of keys used to lookup the image + /// - open: A closure used to open the image file by delegating its decoding to the calling routine + func getImage(_ keys: [URLImageKey], open: @escaping (_ location: URL) async throws -> T?) async throws -> T? + + /// Write image data to the store. + func storeImageData(_ data: Data, info: URLImageStoreInfo) async + + /// Move image file from the temporary location to the store. + func moveImageFile(from location: URL, info: URLImageStoreInfo) async +} + diff --git a/Sources/URLImage/Store/URLImageStoreType.swift b/Sources/URLImage/Store/URLImageStoreType.swift index 7a9bad8..8a8cc4b 100644 --- a/Sources/URLImage/Store/URLImageStoreType.swift +++ b/Sources/URLImage/Store/URLImageStoreType.swift @@ -17,3 +17,13 @@ public protocol URLImageStoreType { func removeImageWithIdentifier(_ identifier: String) } + +@available(macOS 10.15.0, iOS 13.0, *) +public protocol URLImageStoreType_Concurrency { + + func removeAllImages() async + + func removeImageWithURL(_ url: URL) async + + func removeImageWithIdentifier(_ identifier: String) async +} diff --git a/Sources/URLImage/URLImage.swift b/Sources/URLImage/URLImage.swift index 7529222..86ef06a 100644 --- a/Sources/URLImage/URLImage.swift +++ b/Sources/URLImage/URLImage.swift @@ -64,6 +64,28 @@ public struct URLImage : View where Empty : } } +@available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) +public extension URLImage { + init(_ url: URL, + identifier: String? = nil, + @ViewBuilder empty: @escaping () -> Empty, + @ViewBuilder inProgress: @escaping (_ progress: Float?) -> InProgress, + @ViewBuilder failure: @escaping (_ error: Error, _ retry: @escaping () -> Void) -> Failure, + @ViewBuilder content: @escaping (_ image: GIFWrapperImage) -> Content) { + + self.init(url, + identifier: identifier, + empty: empty, + inProgress: inProgress, + failure: failure, + content: { (transientImage: TransientImage) -> Content in + content( + GIFWrapperImage(decoder: transientImage.proxy) + ) + }) + } +} + @available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) public extension URLImage { diff --git a/Sources/URLImage/Views/GIFImage.swift b/Sources/URLImage/Views/GIFImage.swift new file mode 100644 index 0000000..41f65b7 --- /dev/null +++ b/Sources/URLImage/Views/GIFImage.swift @@ -0,0 +1,195 @@ +// +// SwiftUIView.swift +// +// +// Created by sonoma on 4/21/24. +// + +import SwiftUI +import Model + +@available(iOS 14.0, *) +public struct GIFWrapperImage: View { + private let decoder: CGImageProxy + @State private var image: UIImage? + + init(decoder: CGImageProxy) { + self.decoder = decoder + } + + public var body: some View { +// GIFImage(source: decoder.decoder.imageSource) + if let image = image { + Image(uiImage: image) + } else { + Color.clear.onAppear(perform: { + Task { + image = await UIImage.gif(decoder.decoder.imageSource) + } + }) + } + } +} + +public struct GIFImage: UIViewRepresentable { + private var source: CGImageSource + + init(source: CGImageSource) { + self.source = source + } + + public func makeUIView(context: Context) -> UIGIFImage { + UIGIFImage(source: source) + } + + public func updateUIView(_ uiView: UIGIFImage, context: Context) { + Task { + await uiView.updateGIF(source: source) + } + } +} + +public class UIGIFImage: UIView { + private let imageView = UIImageView() + private var source: CGImageSource? + + override init(frame: CGRect) { + super.init(frame: frame) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + convenience init(source: CGImageSource) { + self.init() + self.source = source + initView() + } + + public override func layoutSubviews() { + super.layoutSubviews() + imageView.frame = bounds + self.addSubview(imageView) + } + + func updateGIF(source: CGImageSource) async { + let image = UIImage.gifImage(source) + await updateWithImage(image) + } + + @MainActor + private func updateWithImage(_ image: UIImage?) async { + imageView.image = image + } + + private func initView() { + imageView.contentMode = .scaleAspectFit + } +} + +public extension UIImage { + static func gifImage(_ source: CGImageSource) -> UIImage? { + let count = CGImageSourceGetCount(source) + let delays = (0.. UIImage? { + gifImage(source) + } +} + +private func gcd(_ a: Int, _ b: Int) -> Int { + let absB = abs(b) + let r = abs(a) % absB + if r != 0 { + return gcd(absB, r) + } else { + return absB + } +} + +private func delayForImage(at index: Int, source: CGImageSource) -> Double { + let defaultDelay = 1.0 + + let cfProperties = CGImageSourceCopyPropertiesAtIndex(source, index, nil) + let gifPropertiesPointer = UnsafeMutablePointer.allocate(capacity: 0) + defer { + gifPropertiesPointer.deallocate() + } + let unsafePointer = Unmanaged.passUnretained(kCGImagePropertyGIFDictionary).toOpaque() + if CFDictionaryGetValueIfPresent(cfProperties, unsafePointer, gifPropertiesPointer) == false { + return defaultDelay + } + let gifProperties = unsafeBitCast(gifPropertiesPointer.pointee, to: CFDictionary.self) + var delayWrapper = unsafeBitCast(CFDictionaryGetValue(gifProperties, + Unmanaged.passUnretained(kCGImagePropertyGIFUnclampedDelayTime).toOpaque()), + to: AnyObject.self) + if delayWrapper.doubleValue == 0 { + delayWrapper = unsafeBitCast(CFDictionaryGetValue(gifProperties, + Unmanaged.passUnretained(kCGImagePropertyGIFDelayTime).toOpaque()), + to: AnyObject.self) + } + + if let delay = delayWrapper as? Double, + delay > 0 { + return delay + } else { + return defaultDelay + } +} + +//@available(iOS 13.0, *) +//struct GIFImageTest: View { +// @State private var imageData: Data? = nil +// +// var body: some View { +// VStack { +// GIFImage(name: "preview") +// .frame(height: 300) +// if let data = imageData { +// GIFImage(data: data) +// .frame(width: 300) +// } else { +// Text("Loading...") +// .onAppear(perform: loadData) +// } +// } +// } +// +// private func loadData() { +// let task = URLSession.shared.dataTask(with: URL(string: "https://github.com/globulus/swiftui-webview/raw/main/Images/preview_macos.gif?raw=true")!) { data, response, error in +// imageData = data +// } +// task.resume() +// } +//} +// +// +//struct GIFImage_Previews: PreviewProvider { +// static var previews: some View { +// GIFImageTest() +// } +//} diff --git a/Sources/URLImageStore/URLImageFileStore_Concurrency.swift b/Sources/URLImageStore/URLImageFileStore_Concurrency.swift new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/Sources/URLImageStore/URLImageFileStore_Concurrency.swift @@ -0,0 +1 @@ + From 7ec9f36ba4e26000ca2072dafeaf4d6ab48171c2 Mon Sep 17 00:00:00 2001 From: johncorner Date: Sun, 21 Apr 2024 04:15:46 -0400 Subject: [PATCH 02/27] gif init --- Sources/URLImage/URLImage.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Sources/URLImage/URLImage.swift b/Sources/URLImage/URLImage.swift index 86ef06a..867b3c5 100644 --- a/Sources/URLImage/URLImage.swift +++ b/Sources/URLImage/URLImage.swift @@ -71,7 +71,7 @@ public extension URLImage { @ViewBuilder empty: @escaping () -> Empty, @ViewBuilder inProgress: @escaping (_ progress: Float?) -> InProgress, @ViewBuilder failure: @escaping (_ error: Error, _ retry: @escaping () -> Void) -> Failure, - @ViewBuilder content: @escaping (_ image: GIFWrapperImage) -> Content) { + @ViewBuilder gifContent: @escaping (_ image: GIFWrapperImage) -> Content) { self.init(url, identifier: identifier, @@ -79,7 +79,7 @@ public extension URLImage { inProgress: inProgress, failure: failure, content: { (transientImage: TransientImage) -> Content in - content( + gifContent( GIFWrapperImage(decoder: transientImage.proxy) ) }) From 196e3202879ddedf22bf4dd91b9ae3a00ed21d1e Mon Sep 17 00:00:00 2001 From: johncorner Date: Sun, 21 Apr 2024 04:47:39 -0400 Subject: [PATCH 03/27] update git wapper method --- Sources/URLImage/URLImage.swift | 4 ++-- Sources/URLImage/Views/GIFImage.swift | 9 ++++++--- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/Sources/URLImage/URLImage.swift b/Sources/URLImage/URLImage.swift index 867b3c5..3e2fa46 100644 --- a/Sources/URLImage/URLImage.swift +++ b/Sources/URLImage/URLImage.swift @@ -71,7 +71,7 @@ public extension URLImage { @ViewBuilder empty: @escaping () -> Empty, @ViewBuilder inProgress: @escaping (_ progress: Float?) -> InProgress, @ViewBuilder failure: @escaping (_ error: Error, _ retry: @escaping () -> Void) -> Failure, - @ViewBuilder gifContent: @escaping (_ image: GIFWrapperImage) -> Content) { + @ViewBuilder gifContent: @escaping (_ image: Image) -> Content) { self.init(url, identifier: identifier, @@ -80,7 +80,7 @@ public extension URLImage { failure: failure, content: { (transientImage: TransientImage) -> Content in gifContent( - GIFWrapperImage(decoder: transientImage.proxy) + Image(uiImage: UIImage.gifImage(transientImage.proxy.decoder.imageSource) ?? UIImage()) ) }) } diff --git a/Sources/URLImage/Views/GIFImage.swift b/Sources/URLImage/Views/GIFImage.swift index 41f65b7..dc6f50b 100644 --- a/Sources/URLImage/Views/GIFImage.swift +++ b/Sources/URLImage/Views/GIFImage.swift @@ -9,18 +9,21 @@ import SwiftUI import Model @available(iOS 14.0, *) -public struct GIFWrapperImage: View { +public struct GIFWrapperImage: View { private let decoder: CGImageProxy @State private var image: UIImage? - init(decoder: CGImageProxy) { + var content: (Image) -> Content + + init(decoder: CGImageProxy, @ViewBuilder content: @escaping (Image) -> Content) { self.decoder = decoder + self.content = content } public var body: some View { // GIFImage(source: decoder.decoder.imageSource) if let image = image { - Image(uiImage: image) + content(Image(uiImage: image)) } else { Color.clear.onAppear(perform: { Task { From 8546bfdf836d00cf5138d8ae857e1cb37dc02040 Mon Sep 17 00:00:00 2001 From: johncorner Date: Sun, 21 Apr 2024 05:04:43 -0400 Subject: [PATCH 04/27] wrapper image --- Sources/URLImage/URLImage.swift | 4 +-- Sources/URLImage/Views/GIFImage.swift | 44 ++++++++++++++++++--------- 2 files changed, 31 insertions(+), 17 deletions(-) diff --git a/Sources/URLImage/URLImage.swift b/Sources/URLImage/URLImage.swift index 3e2fa46..867b3c5 100644 --- a/Sources/URLImage/URLImage.swift +++ b/Sources/URLImage/URLImage.swift @@ -71,7 +71,7 @@ public extension URLImage { @ViewBuilder empty: @escaping () -> Empty, @ViewBuilder inProgress: @escaping (_ progress: Float?) -> InProgress, @ViewBuilder failure: @escaping (_ error: Error, _ retry: @escaping () -> Void) -> Failure, - @ViewBuilder gifContent: @escaping (_ image: Image) -> Content) { + @ViewBuilder gifContent: @escaping (_ image: GIFWrapperImage) -> Content) { self.init(url, identifier: identifier, @@ -80,7 +80,7 @@ public extension URLImage { failure: failure, content: { (transientImage: TransientImage) -> Content in gifContent( - Image(uiImage: UIImage.gifImage(transientImage.proxy.decoder.imageSource) ?? UIImage()) + GIFWrapperImage(decoder: transientImage.proxy) ) }) } diff --git a/Sources/URLImage/Views/GIFImage.swift b/Sources/URLImage/Views/GIFImage.swift index dc6f50b..fe70b73 100644 --- a/Sources/URLImage/Views/GIFImage.swift +++ b/Sources/URLImage/Views/GIFImage.swift @@ -8,29 +8,43 @@ import SwiftUI import Model +//@available(iOS 14.0, *) +//public struct GIFWrapperImage: View { +// private let decoder: CGImageProxy +// @State private var image: UIImage? +// +// var content: (Image) -> Content +// +// init(decoder: CGImageProxy, @ViewBuilder content: @escaping (Image) -> Content) { +// self.decoder = decoder +// self.content = content +// } +// +// public var body: some View { +//// GIFImage(source: decoder.decoder.imageSource) +// if let image = image { +// content(Image(uiImage: image)) +// } else { +// Color.clear.onAppear(perform: { +// Task { +// image = await UIImage.gif(decoder.decoder.imageSource) +// } +// }) +// } +// } +//} + @available(iOS 14.0, *) -public struct GIFWrapperImage: View { +public struct GIFWrapperImage: View { private let decoder: CGImageProxy @State private var image: UIImage? - var content: (Image) -> Content - - init(decoder: CGImageProxy, @ViewBuilder content: @escaping (Image) -> Content) { + init(decoder: CGImageProxy) { self.decoder = decoder - self.content = content } public var body: some View { -// GIFImage(source: decoder.decoder.imageSource) - if let image = image { - content(Image(uiImage: image)) - } else { - Color.clear.onAppear(perform: { - Task { - image = await UIImage.gif(decoder.decoder.imageSource) - } - }) - } + GIFImage(source: decoder.decoder.imageSource) } } From d92374121477848f48e73414507c65b2ecb22afa Mon Sep 17 00:00:00 2001 From: johncorner Date: Sun, 21 Apr 2024 05:21:35 -0400 Subject: [PATCH 05/27] Coordinator image loader --- Sources/URLImage/Views/GIFImage.swift | 41 ++++++++++++++++----------- 1 file changed, 24 insertions(+), 17 deletions(-) diff --git a/Sources/URLImage/Views/GIFImage.swift b/Sources/URLImage/Views/GIFImage.swift index fe70b73..680aba8 100644 --- a/Sources/URLImage/Views/GIFImage.swift +++ b/Sources/URLImage/Views/GIFImage.swift @@ -37,7 +37,6 @@ import Model @available(iOS 14.0, *) public struct GIFWrapperImage: View { private let decoder: CGImageProxy - @State private var image: UIImage? init(decoder: CGImageProxy) { self.decoder = decoder @@ -50,25 +49,43 @@ public struct GIFWrapperImage: View { public struct GIFImage: UIViewRepresentable { private var source: CGImageSource + @State var image: UIImage? + + public func makeCoordinator() -> Coordinator { + Coordinator(self) + } init(source: CGImageSource) { self.source = source } public func makeUIView(context: Context) -> UIGIFImage { - UIGIFImage(source: source) + UIGIFImage(source: image) } public func updateUIView(_ uiView: UIGIFImage, context: Context) { - Task { - await uiView.updateGIF(source: source) + uiView.imageView.image = image + } + + public final class Coordinator { + var imageView: GIFImage + + init(_ imageView: GIFImage) { + self.imageView = imageView + } + + func load() async { + let data = await UIImage.gif(imageView.source) + Task { @MainActor in + imageView.image = data + } } } } public class UIGIFImage: UIView { - private let imageView = UIImageView() - private var source: CGImageSource? + let imageView = UIImageView() + private var source: UIImage? override init(frame: CGRect) { super.init(frame: frame) @@ -78,7 +95,7 @@ public class UIGIFImage: UIView { fatalError("init(coder:) has not been implemented") } - convenience init(source: CGImageSource) { + convenience init(source: UIImage?) { self.init() self.source = source initView() @@ -90,16 +107,6 @@ public class UIGIFImage: UIView { self.addSubview(imageView) } - func updateGIF(source: CGImageSource) async { - let image = UIImage.gifImage(source) - await updateWithImage(image) - } - - @MainActor - private func updateWithImage(_ image: UIImage?) async { - imageView.image = image - } - private func initView() { imageView.contentMode = .scaleAspectFit } From 5320985491a0adb2c5e2f44a5b02173fbdd66726 Mon Sep 17 00:00:00 2001 From: johncorner Date: Sun, 21 Apr 2024 05:24:24 -0400 Subject: [PATCH 06/27] fix async call --- Sources/URLImage/Views/GIFImage.swift | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/Sources/URLImage/Views/GIFImage.swift b/Sources/URLImage/Views/GIFImage.swift index 680aba8..4a9b7f2 100644 --- a/Sources/URLImage/Views/GIFImage.swift +++ b/Sources/URLImage/Views/GIFImage.swift @@ -72,6 +72,10 @@ public struct GIFImage: UIViewRepresentable { init(_ imageView: GIFImage) { self.imageView = imageView + + Task { + await load() + } } func load() async { From 68f66d1bcfcf2050ad680aa5fa22568e11ca32ea Mon Sep 17 00:00:00 2001 From: johncorner Date: Sun, 21 Apr 2024 05:48:54 -0400 Subject: [PATCH 07/27] mac support --- Sources/URLImage/Views/GIFImage.swift | 142 ++++++++++++++------------ 1 file changed, 78 insertions(+), 64 deletions(-) diff --git a/Sources/URLImage/Views/GIFImage.swift b/Sources/URLImage/Views/GIFImage.swift index 4a9b7f2..85b8791 100644 --- a/Sources/URLImage/Views/GIFImage.swift +++ b/Sources/URLImage/Views/GIFImage.swift @@ -8,33 +8,19 @@ import SwiftUI import Model -//@available(iOS 14.0, *) -//public struct GIFWrapperImage: View { -// private let decoder: CGImageProxy -// @State private var image: UIImage? -// -// var content: (Image) -> Content -// -// init(decoder: CGImageProxy, @ViewBuilder content: @escaping (Image) -> Content) { -// self.decoder = decoder -// self.content = content -// } -// -// public var body: some View { -//// GIFImage(source: decoder.decoder.imageSource) -// if let image = image { -// content(Image(uiImage: image)) -// } else { -// Color.clear.onAppear(perform: { -// Task { -// image = await UIImage.gif(decoder.decoder.imageSource) -// } -// }) -// } -// } -//} +#if os(iOS) || os(watchOS) +typealias PlatformView = UIView +typealias PlatformViewRepresentable = UIViewRepresentable +typealias PlatformImage = UIImage +typealias PlatformImageView = UIImageView +#elseif os(macOS) +typealias PlatformView = NSView +typealias PlatformImage = NSImage +typealias PlatformImageView = NSImageView +typealias PlatformViewRepresentable = NSViewRepresentable +#endif -@available(iOS 14.0, *) +@available(macOS 11.0, iOS 14.0, *) public struct GIFWrapperImage: View { private let decoder: CGImageProxy @@ -47,9 +33,10 @@ public struct GIFWrapperImage: View { } } -public struct GIFImage: UIViewRepresentable { + +struct GIFImage: PlatformViewRepresentable { private var source: CGImageSource - @State var image: UIImage? + @State var image: PlatformImage? public func makeCoordinator() -> Coordinator { Coordinator(self) @@ -59,6 +46,7 @@ public struct GIFImage: UIViewRepresentable { self.source = source } +#if os(iOS) || os(watchOS) public func makeUIView(context: Context) -> UIGIFImage { UIGIFImage(source: image) } @@ -66,6 +54,16 @@ public struct GIFImage: UIViewRepresentable { public func updateUIView(_ uiView: UIGIFImage, context: Context) { uiView.imageView.image = image } +#elseif os(macOS) + func makeNSView(context: Context) -> UIGIFImage { + UIGIFImage(source: image) + } + + func updateNSView(_ nsView: NSViewType, context: Context) { + nsView.imageView.image = image + nsView.imageView.animates = true + } +#endif public final class Coordinator { var imageView: GIFImage @@ -79,17 +77,19 @@ public struct GIFImage: UIViewRepresentable { } func load() async { - let data = await UIImage.gif(imageView.source) +#if os(iOS) || os(watchOS) + let data = await gif(imageView.source) Task { @MainActor in imageView.image = data } +#endif } } } -public class UIGIFImage: UIView { - let imageView = UIImageView() - private var source: UIImage? +class UIGIFImage: PlatformView { + let imageView = PlatformImageView() + private var source: PlatformImage? override init(frame: CGRect) { super.init(frame: frame) @@ -99,57 +99,70 @@ public class UIGIFImage: UIView { fatalError("init(coder:) has not been implemented") } - convenience init(source: UIImage?) { + convenience init(source: PlatformImage?) { self.init() self.source = source initView() } - + +#if os(iOS) || os(watchOS) public override func layoutSubviews() { super.layoutSubviews() imageView.frame = bounds - self.addSubview(imageView) + addSubview(imageView) + } +#elseif os(macOS) + override func layout() { + super.layout() + imageView.frame = bounds + addSubview(imageView) } +#endif private func initView() { +#if os(iOS) || os(watchOS) imageView.contentMode = .scaleAspectFit +#elseif os(macOS) + imageView.image = source + imageView.animates = true +#endif } + } -public extension UIImage { - static func gifImage(_ source: CGImageSource) -> UIImage? { - let count = CGImageSourceGetCount(source) - let delays = (0.. PlatformImage? { + let count = CGImageSourceGetCount(source) + let delays = (0.. UIImage? { - gifImage(source) - } + return PlatformImage.animatedImage(with: frames, + duration: Double(duration) / 1000.0) +} + +fileprivate func gif(_ source: CGImageSource) async -> PlatformImage? { + gifImage(source) } -private func gcd(_ a: Int, _ b: Int) -> Int { +fileprivate func gcd(_ a: Int, _ b: Int) -> Int { let absB = abs(b) let r = abs(a) % absB if r != 0 { @@ -159,7 +172,7 @@ private func gcd(_ a: Int, _ b: Int) -> Int { } } -private func delayForImage(at index: Int, source: CGImageSource) -> Double { +fileprivate func delayForImage(at index: Int, source: CGImageSource) -> Double { let defaultDelay = 1.0 let cfProperties = CGImageSourceCopyPropertiesAtIndex(source, index, nil) @@ -188,6 +201,7 @@ private func delayForImage(at index: Int, source: CGImageSource) -> Double { return defaultDelay } } +#endif //@available(iOS 13.0, *) //struct GIFImageTest: View { From ab30ecb76007493b7fc95ac07a21c5189483c9f1 Mon Sep 17 00:00:00 2001 From: johncorner Date: Sun, 21 Apr 2024 06:17:15 -0400 Subject: [PATCH 08/27] fix nsimage generate state --- Sources/URLImage/Views/GIFImage.swift | 52 +++++++++++++++++++++++++-- 1 file changed, 49 insertions(+), 3 deletions(-) diff --git a/Sources/URLImage/Views/GIFImage.swift b/Sources/URLImage/Views/GIFImage.swift index 85b8791..e95abbd 100644 --- a/Sources/URLImage/Views/GIFImage.swift +++ b/Sources/URLImage/Views/GIFImage.swift @@ -77,12 +77,10 @@ struct GIFImage: PlatformViewRepresentable { } func load() async { -#if os(iOS) || os(watchOS) let data = await gif(imageView.source) Task { @MainActor in imageView.image = data } -#endif } } } @@ -157,7 +155,56 @@ fileprivate func gifImage(_ source: CGImageSource) -> PlatformImage? { return PlatformImage.animatedImage(with: frames, duration: Double(duration) / 1000.0) } +#elseif os(macOS) +@available(macOS 11.0, *) +fileprivate func gifImage(_ source: CGImageSource) -> PlatformImage? { + let gifProperties = [kCGImagePropertyGIFDictionary as String: [kCGImagePropertyGIFLoopCount as String: 0]] as CFDictionary + let uuid = UUID().uuidString + let path = NSTemporaryDirectory() + "\(uuid).gif" + let count = CGImageSourceGetCount(source) + guard let destination = CGImageDestinationCreateWithURL( + NSURL(fileURLWithPath: path) as CFURL, + kUTTypeGIF, + count, + nil + ) else { + return nil + } + let delays = (0.. PlatformImage? { gifImage(source) } @@ -201,7 +248,6 @@ fileprivate func delayForImage(at index: Int, source: CGImageSource) -> Double { return defaultDelay } } -#endif //@available(iOS 13.0, *) //struct GIFImageTest: View { From 5011be81bca2677c15f1d341dae768dc11a41aeb Mon Sep 17 00:00:00 2001 From: johncorner Date: Sun, 21 Apr 2024 06:26:26 -0400 Subject: [PATCH 09/27] fix @available statement --- Sources/URLImage/Views/GIFImage.swift | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/Sources/URLImage/Views/GIFImage.swift b/Sources/URLImage/Views/GIFImage.swift index e95abbd..4b89b94 100644 --- a/Sources/URLImage/Views/GIFImage.swift +++ b/Sources/URLImage/Views/GIFImage.swift @@ -33,7 +33,7 @@ public struct GIFWrapperImage: View { } } - +@available(macOS 11.0, iOS 13.0, *) struct GIFImage: PlatformViewRepresentable { private var source: CGImageSource @State var image: PlatformImage? @@ -174,7 +174,6 @@ fileprivate func gifImage(_ source: CGImageSource) -> PlatformImage? { // store in ms and truncate to compute GCD more easily Int(delayForImage(at: $0, source: source) * 1000) } - let duration = delays.reduce(0, +) let gcd = delays.reduce(0, gcd) CGImageDestinationSetProperties(destination, gifProperties) @@ -202,6 +201,10 @@ fileprivate func gifImage(_ source: CGImageSource) -> PlatformImage? { return NSImage(data: Data(referencing: data)) } + +extension NSImage: @unchecked Sendable { + +} #endif @available(macOS 11.0, iOS 13.0, *) From f87a5f2a4872e353312d6087cd0784d0be7ba576 Mon Sep 17 00:00:00 2001 From: johncorner Date: Sun, 21 Apr 2024 06:37:08 -0400 Subject: [PATCH 10/27] fix finalizeDestination:3546: *** ERROR: image destination does not have enough images (468/42) --- Sources/URLImage/Views/GIFImage.swift | 46 ++++++++++++--------------- 1 file changed, 20 insertions(+), 26 deletions(-) diff --git a/Sources/URLImage/Views/GIFImage.swift b/Sources/URLImage/Views/GIFImage.swift index 4b89b94..67bfd35 100644 --- a/Sources/URLImage/Views/GIFImage.swift +++ b/Sources/URLImage/Views/GIFImage.swift @@ -174,20 +174,17 @@ fileprivate func gifImage(_ source: CGImageSource) -> PlatformImage? { // store in ms and truncate to compute GCD more easily Int(delayForImage(at: $0, source: source) * 1000) } - let gcd = delays.reduce(0, gcd) +// let gcd = delays.reduce(0, gcd) CGImageDestinationSetProperties(destination, gifProperties) for i in 0.. Int { } fileprivate func delayForImage(at index: Int, source: CGImageSource) -> Double { - let defaultDelay = 1.0 + var delay = 0.1 + // Get dictionaries let cfProperties = CGImageSourceCopyPropertiesAtIndex(source, index, nil) let gifPropertiesPointer = UnsafeMutablePointer.allocate(capacity: 0) - defer { - gifPropertiesPointer.deallocate() - } - let unsafePointer = Unmanaged.passUnretained(kCGImagePropertyGIFDictionary).toOpaque() - if CFDictionaryGetValueIfPresent(cfProperties, unsafePointer, gifPropertiesPointer) == false { - return defaultDelay + if CFDictionaryGetValueIfPresent(cfProperties, Unmanaged.passUnretained(kCGImagePropertyGIFDictionary).toOpaque(), gifPropertiesPointer) == false { + return delay } - let gifProperties = unsafeBitCast(gifPropertiesPointer.pointee, to: CFDictionary.self) - var delayWrapper = unsafeBitCast(CFDictionaryGetValue(gifProperties, - Unmanaged.passUnretained(kCGImagePropertyGIFUnclampedDelayTime).toOpaque()), - to: AnyObject.self) - if delayWrapper.doubleValue == 0 { - delayWrapper = unsafeBitCast(CFDictionaryGetValue(gifProperties, - Unmanaged.passUnretained(kCGImagePropertyGIFDelayTime).toOpaque()), - to: AnyObject.self) + + let gifProperties:CFDictionary = unsafeBitCast(gifPropertiesPointer.pointee, to: CFDictionary.self) + + // Get delay time + var delayObject: AnyObject = unsafeBitCast(CFDictionaryGetValue(gifProperties, Unmanaged.passUnretained(kCGImagePropertyGIFUnclampedDelayTime).toOpaque()), to: AnyObject.self) + if delayObject.doubleValue == 0 { + delayObject = unsafeBitCast(CFDictionaryGetValue(gifProperties, Unmanaged.passUnretained(kCGImagePropertyGIFDelayTime).toOpaque()), to: AnyObject.self) } - if let delay = delayWrapper as? Double, - delay > 0 { - return delay - } else { - return defaultDelay + delay = delayObject as! Double + + if delay < 0.1 { + delay = 0.1 // Make sure they're not too fast } + + return delay } //@available(iOS 13.0, *) From 1024a3f9c0f36997820680dc3a9ff1cf8a60f7b0 Mon Sep 17 00:00:00 2001 From: johncorner Date: Sun, 21 Apr 2024 07:19:58 -0400 Subject: [PATCH 11/27] more efficency load gif on macOS --- .../Sources/ImageDecoder/ImageDecoder.swift | 2 +- .../Model/TransientImage+ImageDecoder.swift | 9 +-- .../Sources/Model/TransientImage.swift | 8 ++- Sources/URLImage/URLImage.swift | 2 +- Sources/URLImage/Views/GIFImage.swift | 65 +++++-------------- 5 files changed, 29 insertions(+), 57 deletions(-) diff --git a/Dependencies/Sources/ImageDecoder/ImageDecoder.swift b/Dependencies/Sources/ImageDecoder/ImageDecoder.swift index 08081a6..fc0e0ed 100644 --- a/Dependencies/Sources/ImageDecoder/ImageDecoder.swift +++ b/Dependencies/Sources/ImageDecoder/ImageDecoder.swift @@ -86,7 +86,7 @@ public final class ImageDecoder { guard let dataProvider = CGDataProvider(url: url as CFURL) else { return nil } - + self.init() setDataProvider(dataProvider, allDataReceived: true) } diff --git a/Dependencies/Sources/Model/TransientImage+ImageDecoder.swift b/Dependencies/Sources/Model/TransientImage+ImageDecoder.swift index 1c5621c..fdae2e5 100644 --- a/Dependencies/Sources/Model/TransientImage+ImageDecoder.swift +++ b/Dependencies/Sources/Model/TransientImage+ImageDecoder.swift @@ -9,6 +9,7 @@ import Foundation import CoreGraphics import ImageIO import ImageDecoder +import DownloadManager @available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) @@ -18,7 +19,7 @@ public extension TransientImage { let decoder = ImageDecoder() decoder.setData(data, allDataReceived: true) - self.init(decoder: decoder, maxPixelSize: maxPixelSize) + self.init(decoder: decoder, presentation: .data(data), maxPixelSize: maxPixelSize) } init?(location: URL, maxPixelSize: CGSize?) { @@ -26,10 +27,10 @@ public extension TransientImage { return nil } - self.init(decoder: decoder, maxPixelSize: maxPixelSize) + self.init(decoder: decoder, presentation: .file(location.path), maxPixelSize: maxPixelSize) } - init?(decoder: ImageDecoder, maxPixelSize: CGSize?) { + init?(decoder: ImageDecoder, presentation: DownloadResult, maxPixelSize: CGSize?) { guard let uti = decoder.uti else { // Not an image return nil @@ -45,6 +46,6 @@ public extension TransientImage { let info = ImageInfo(proxy: proxy, size: size) let cgOrientation: CGImagePropertyOrientation = decoder.frameOrientation(at: 0) ?? .up - self.init(proxy: proxy, info: info, uti: uti, cgOrientation: cgOrientation) + self.init(proxy: proxy, info: info, uti: uti, presentation: presentation, cgOrientation: cgOrientation) } } diff --git a/Dependencies/Sources/Model/TransientImage.swift b/Dependencies/Sources/Model/TransientImage.swift index a81e987..412a4dc 100644 --- a/Dependencies/Sources/Model/TransientImage.swift +++ b/Dependencies/Sources/Model/TransientImage.swift @@ -8,6 +8,7 @@ import Foundation import ImageIO import ImageDecoder +import DownloadManager /// Temporary representation used after decoding an image from data or file on disk and before creating an image object for display. @@ -27,17 +28,18 @@ public struct TransientImage { public let cgOrientation: CGImagePropertyOrientation - init(proxy: CGImageProxy, info: ImageInfo, uti: String, cgOrientation: CGImagePropertyOrientation) { + init(proxy: CGImageProxy, info: ImageInfo, uti: String, presentation: DownloadResult, cgOrientation: CGImagePropertyOrientation) { self.proxy = proxy self.info = info self.uti = uti self.cgOrientation = cgOrientation + self.presentation = presentation } - + + public let presentation: DownloadResult public let proxy: CGImageProxy } - /// Proxy used to decode image lazily @available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) public final class CGImageProxy { diff --git a/Sources/URLImage/URLImage.swift b/Sources/URLImage/URLImage.swift index 867b3c5..387e04d 100644 --- a/Sources/URLImage/URLImage.swift +++ b/Sources/URLImage/URLImage.swift @@ -80,7 +80,7 @@ public extension URLImage { failure: failure, content: { (transientImage: TransientImage) -> Content in gifContent( - GIFWrapperImage(decoder: transientImage.proxy) + GIFWrapperImage(decoder: transientImage) ) }) } diff --git a/Sources/URLImage/Views/GIFImage.swift b/Sources/URLImage/Views/GIFImage.swift index 67bfd35..ace2fdc 100644 --- a/Sources/URLImage/Views/GIFImage.swift +++ b/Sources/URLImage/Views/GIFImage.swift @@ -22,27 +22,27 @@ typealias PlatformViewRepresentable = NSViewRepresentable @available(macOS 11.0, iOS 14.0, *) public struct GIFWrapperImage: View { - private let decoder: CGImageProxy + private let decoder: TransientImage - init(decoder: CGImageProxy) { + init(decoder: TransientImage) { self.decoder = decoder } public var body: some View { - GIFImage(source: decoder.decoder.imageSource) + GIFImage(source: decoder) } } -@available(macOS 11.0, iOS 13.0, *) +@available(macOS 11.0, iOS 14.0, *) struct GIFImage: PlatformViewRepresentable { - private var source: CGImageSource + private var source: TransientImage @State var image: PlatformImage? public func makeCoordinator() -> Coordinator { Coordinator(self) } - init(source: CGImageSource) { + init(source: TransientImage) { self.source = source } @@ -129,7 +129,9 @@ class UIGIFImage: PlatformView { } #if os(iOS) || os(watchOS) -fileprivate func gifImage(_ source: CGImageSource) -> PlatformImage? { +@available(iOS 14.0, *) +fileprivate func gifImage(_ image: TransientImage) -> PlatformImage? { + let source = image.proxy.decoder.imageSource let count = CGImageSourceGetCount(source) let delays = (0.. PlatformImage? { } #elseif os(macOS) @available(macOS 11.0, *) -fileprivate func gifImage(_ source: CGImageSource) -> PlatformImage? { - let gifProperties = [kCGImagePropertyGIFDictionary as String: [kCGImagePropertyGIFLoopCount as String: 0]] as CFDictionary - let uuid = UUID().uuidString - let path = NSTemporaryDirectory() + "\(uuid).gif" - let count = CGImageSourceGetCount(source) - guard let destination = CGImageDestinationCreateWithURL( - NSURL(fileURLWithPath: path) as CFURL, - kUTTypeGIF, - count, - nil - ) else { - return nil +fileprivate func gifImage(_ source: TransientImage) -> PlatformImage? { + switch source.presentation { + case .data(let data): + return PlatformImage(data: data) + case .file(let path): + return PlatformImage(contentsOfFile: path) } - let delays = (0.. PlatformImage? { +@available(macOS 11.0, iOS 14.0, *) +fileprivate func gif(_ source: TransientImage) async -> PlatformImage? { gifImage(source) } From 73ed82d4832236477bfb3d4b86b1923b5106d620 Mon Sep 17 00:00:00 2001 From: johncorner Date: Tue, 23 Apr 2024 11:15:47 -0400 Subject: [PATCH 12/27] try fix memeory problem --- Sources/URLImage/Views/GIFImage.swift | 22 +++++++++------------- 1 file changed, 9 insertions(+), 13 deletions(-) diff --git a/Sources/URLImage/Views/GIFImage.swift b/Sources/URLImage/Views/GIFImage.swift index ace2fdc..025b045 100644 --- a/Sources/URLImage/Views/GIFImage.swift +++ b/Sources/URLImage/Views/GIFImage.swift @@ -39,7 +39,7 @@ struct GIFImage: PlatformViewRepresentable { @State var image: PlatformImage? public func makeCoordinator() -> Coordinator { - Coordinator(self) + Coordinator($image, source: source) } init(source: TransientImage) { @@ -66,21 +66,17 @@ struct GIFImage: PlatformViewRepresentable { #endif public final class Coordinator { - var imageView: GIFImage + @Binding var image: PlatformImage? + let source: TransientImage - init(_ imageView: GIFImage) { - self.imageView = imageView - - Task { - await load() - } + init(_ image: Binding, source: TransientImage) { + self._image = image + self.source = source } func load() async { - let data = await gif(imageView.source) - Task { @MainActor in - imageView.image = data - } + let data = await gif(source) + image = data } } } @@ -113,7 +109,6 @@ class UIGIFImage: PlatformView { override func layout() { super.layout() imageView.frame = bounds - addSubview(imageView) } #endif @@ -121,6 +116,7 @@ class UIGIFImage: PlatformView { #if os(iOS) || os(watchOS) imageView.contentMode = .scaleAspectFit #elseif os(macOS) + addSubview(imageView) imageView.image = source imageView.animates = true #endif From ed308c979ecfefe7adc92046e1414de98b26e34d Mon Sep 17 00:00:00 2001 From: johncorner Date: Tue, 23 Apr 2024 11:23:04 -0400 Subject: [PATCH 13/27] fix imageview not visible --- Sources/URLImage/Views/GIFImage.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/URLImage/Views/GIFImage.swift b/Sources/URLImage/Views/GIFImage.swift index 025b045..77305b7 100644 --- a/Sources/URLImage/Views/GIFImage.swift +++ b/Sources/URLImage/Views/GIFImage.swift @@ -109,6 +109,7 @@ class UIGIFImage: PlatformView { override func layout() { super.layout() imageView.frame = bounds + addSubview(imageView) } #endif @@ -116,7 +117,6 @@ class UIGIFImage: PlatformView { #if os(iOS) || os(watchOS) imageView.contentMode = .scaleAspectFit #elseif os(macOS) - addSubview(imageView) imageView.image = source imageView.animates = true #endif From c1d79c2574fbde7a5e7de5221c7b1b7ca37d0fdf Mon Sep 17 00:00:00 2001 From: johncorner Date: Tue, 23 Apr 2024 12:01:33 -0400 Subject: [PATCH 14/27] remove invalid binding state --- Sources/URLImage/Views/GIFImage.swift | 31 +++++++++++++++++---------- 1 file changed, 20 insertions(+), 11 deletions(-) diff --git a/Sources/URLImage/Views/GIFImage.swift b/Sources/URLImage/Views/GIFImage.swift index 77305b7..6043fa0 100644 --- a/Sources/URLImage/Views/GIFImage.swift +++ b/Sources/URLImage/Views/GIFImage.swift @@ -36,10 +36,11 @@ public struct GIFWrapperImage: View { @available(macOS 11.0, iOS 14.0, *) struct GIFImage: PlatformViewRepresentable { private var source: TransientImage - @State var image: PlatformImage? public func makeCoordinator() -> Coordinator { - Coordinator($image, source: source) + Coordinator({ image in + print("Oops!") + }, source: source) } init(source: TransientImage) { @@ -48,35 +49,43 @@ struct GIFImage: PlatformViewRepresentable { #if os(iOS) || os(watchOS) public func makeUIView(context: Context) -> UIGIFImage { - UIGIFImage(source: image) + let view = UIGIFImage(source: nil) + context.coordinator.updateImage = { image in + view.imageView.image = image + } + return view } public func updateUIView(_ uiView: UIGIFImage, context: Context) { - uiView.imageView.image = image + } #elseif os(macOS) func makeNSView(context: Context) -> UIGIFImage { - UIGIFImage(source: image) + let view = UIGIFImage(source: nil) + context.coordinator.updateImage = { image in + view.imageView.image = image + view.imageView.animates = true + } + return view } func updateNSView(_ nsView: NSViewType, context: Context) { - nsView.imageView.image = image - nsView.imageView.animates = true + } #endif public final class Coordinator { - @Binding var image: PlatformImage? let source: TransientImage + var updateImage: (PlatformImage?) -> Void - init(_ image: Binding, source: TransientImage) { - self._image = image + init(_ updator: @escaping (PlatformImage?) -> Void, source: TransientImage) { + self.updateImage = updator self.source = source } func load() async { let data = await gif(source) - image = data + updateImage(data) } } } From 441fe6e0bcb4ab04e242c3c5ffb6388a5da4910c Mon Sep 17 00:00:00 2001 From: johncorner Date: Tue, 23 Apr 2024 12:08:54 -0400 Subject: [PATCH 15/27] fix missing update gif data method --- Sources/URLImage/Views/GIFImage.swift | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/Sources/URLImage/Views/GIFImage.swift b/Sources/URLImage/Views/GIFImage.swift index 6043fa0..c4e474b 100644 --- a/Sources/URLImage/Views/GIFImage.swift +++ b/Sources/URLImage/Views/GIFImage.swift @@ -53,6 +53,9 @@ struct GIFImage: PlatformViewRepresentable { context.coordinator.updateImage = { image in view.imageView.image = image } + Task { + await context.coordinator.load() + } return view } @@ -66,6 +69,9 @@ struct GIFImage: PlatformViewRepresentable { view.imageView.image = image view.imageView.animates = true } + Task { + await context.coordinator.load() + } return view } @@ -85,7 +91,9 @@ struct GIFImage: PlatformViewRepresentable { func load() async { let data = await gif(source) - updateImage(data) + Task { @MainActor in + updateImage(data) + } } } } From 8333470daf019960e9c0691e44a95fc14f185c98 Mon Sep 17 00:00:00 2001 From: johncorner Date: Thu, 25 Apr 2024 00:36:54 -0400 Subject: [PATCH 16/27] GIFImage impelment --- Sources/URLImage/URLImage.swift | 23 -- Sources/URLImage/Views/GIFImage.swift | 222 ++++++----------- .../URLImage/Views/RemoteGIFImageView.swift | 223 ++++++++++++++++++ 3 files changed, 297 insertions(+), 171 deletions(-) create mode 100644 Sources/URLImage/Views/RemoteGIFImageView.swift diff --git a/Sources/URLImage/URLImage.swift b/Sources/URLImage/URLImage.swift index 387e04d..7d6db55 100644 --- a/Sources/URLImage/URLImage.swift +++ b/Sources/URLImage/URLImage.swift @@ -64,29 +64,6 @@ public struct URLImage : View where Empty : } } -@available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) -public extension URLImage { - init(_ url: URL, - identifier: String? = nil, - @ViewBuilder empty: @escaping () -> Empty, - @ViewBuilder inProgress: @escaping (_ progress: Float?) -> InProgress, - @ViewBuilder failure: @escaping (_ error: Error, _ retry: @escaping () -> Void) -> Failure, - @ViewBuilder gifContent: @escaping (_ image: GIFWrapperImage) -> Content) { - - self.init(url, - identifier: identifier, - empty: empty, - inProgress: inProgress, - failure: failure, - content: { (transientImage: TransientImage) -> Content in - gifContent( - GIFWrapperImage(decoder: transientImage) - ) - }) - } -} - - @available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) public extension URLImage { diff --git a/Sources/URLImage/Views/GIFImage.swift b/Sources/URLImage/Views/GIFImage.swift index c4e474b..7e38c8b 100644 --- a/Sources/URLImage/Views/GIFImage.swift +++ b/Sources/URLImage/Views/GIFImage.swift @@ -1,6 +1,6 @@ // // SwiftUIView.swift -// +// // // Created by sonoma on 4/21/24. // @@ -9,98 +9,82 @@ import SwiftUI import Model #if os(iOS) || os(watchOS) -typealias PlatformView = UIView -typealias PlatformViewRepresentable = UIViewRepresentable -typealias PlatformImage = UIImage -typealias PlatformImageView = UIImageView +public typealias PlatformView = UIView +public typealias PlatformViewRepresentable = UIViewRepresentable +public typealias PlatformImage = UIImage +public typealias PlatformImageView = UIImageView #elseif os(macOS) -typealias PlatformView = NSView -typealias PlatformImage = NSImage -typealias PlatformImageView = NSImageView -typealias PlatformViewRepresentable = NSViewRepresentable +public typealias PlatformView = NSView +public typealias PlatformImage = NSImage +public typealias PlatformImageView = NSImageView +public typealias PlatformViewRepresentable = NSViewRepresentable #endif -@available(macOS 11.0, iOS 14.0, *) -public struct GIFWrapperImage: View { - private let decoder: TransientImage - - init(decoder: TransientImage) { - self.decoder = decoder +@available(macOS 12.0, iOS 15.0, *) +public struct GIFImage : View where Empty : View, + InProgress : View, + Failure : View, + Content : View { + @Environment(\.urlImageService) var urlImageService + @Environment(\.urlImageOptions) var options + + var url: URL + + private let empty: () -> Empty + private let inProgress: (_ progress: Float?) -> InProgress + private let failure: (_ error: Error, _ retry: @escaping () -> Void) -> Failure + private let content: (_ image: GIFImageView) -> Content + + public init(_ url: URL, + @ViewBuilder empty: @escaping () -> Empty, + @ViewBuilder inProgress: @escaping (_ progress: Float?) -> InProgress, + @ViewBuilder failure: @escaping (_ error: Error, _ retry: @escaping () -> Void) -> Failure, + @ViewBuilder content: @escaping (_ transientImage: GIFImageView) -> Content) { + + self.url = url + self.empty = empty + self.inProgress = inProgress + self.failure = failure + self.content = content } public var body: some View { - GIFImage(source: decoder) + let remoteImage = urlImageService.makeRemoteImage(url: url, identifier: nil, options: options) + return RemoteGIFImageView(remoteImage: remoteImage, + loadOptions: options.loadOptions, + empty: empty, + inProgress: inProgress, + failure: failure, + content: content) } } @available(macOS 11.0, iOS 14.0, *) -struct GIFImage: PlatformViewRepresentable { - private var source: TransientImage - - public func makeCoordinator() -> Coordinator { - Coordinator({ image in - print("Oops!") - }, source: source) - } - - init(source: TransientImage) { - self.source = source - } +public struct GIFImageView: PlatformViewRepresentable { + var image: PlatformImage #if os(iOS) || os(watchOS) public func makeUIView(context: Context) -> UIGIFImage { - let view = UIGIFImage(source: nil) - context.coordinator.updateImage = { image in - view.imageView.image = image - } - Task { - await context.coordinator.load() - } - return view + UIGIFImage(source: image) } public func updateUIView(_ uiView: UIGIFImage, context: Context) { } #elseif os(macOS) - func makeNSView(context: Context) -> UIGIFImage { - let view = UIGIFImage(source: nil) - context.coordinator.updateImage = { image in - view.imageView.image = image - view.imageView.animates = true - } - Task { - await context.coordinator.load() - } - return view + public func makeNSView(context: Context) -> UIGIFImage { + UIGIFImage(source: image) } - func updateNSView(_ nsView: NSViewType, context: Context) { + public func updateNSView(_ nsView: NSViewType, context: Context) { } #endif - - public final class Coordinator { - let source: TransientImage - var updateImage: (PlatformImage?) -> Void - - init(_ updator: @escaping (PlatformImage?) -> Void, source: TransientImage) { - self.updateImage = updator - self.source = source - } - - func load() async { - let data = await gif(source) - Task { @MainActor in - updateImage(data) - } - } - } } -class UIGIFImage: PlatformView { +public final class UIGIFImage: PlatformView { let imageView = PlatformImageView() - private var source: PlatformImage? + var source: PlatformImage? override init(frame: CGRect) { super.init(frame: frame) @@ -110,12 +94,12 @@ class UIGIFImage: PlatformView { fatalError("init(coder:) has not been implemented") } - convenience init(source: PlatformImage?) { + convenience init(source: PlatformImage) { self.init() self.source = source initView() } - + #if os(iOS) || os(watchOS) public override func layoutSubviews() { super.layoutSubviews() @@ -123,7 +107,7 @@ class UIGIFImage: PlatformView { addSubview(imageView) } #elseif os(macOS) - override func layout() { + public override func layout() { super.layout() imageView.frame = bounds addSubview(imageView) @@ -133,105 +117,46 @@ class UIGIFImage: PlatformView { private func initView() { #if os(iOS) || os(watchOS) imageView.contentMode = .scaleAspectFit + imageView.image = source #elseif os(macOS) + imageView.imageScaling = .scaleAxesIndependently imageView.image = source imageView.animates = true #endif } - } -#if os(iOS) || os(watchOS) -@available(iOS 14.0, *) -fileprivate func gifImage(_ image: TransientImage) -> PlatformImage? { - let source = image.proxy.decoder.imageSource - let count = CGImageSourceGetCount(source) - let delays = (0.. UIScreen { + UIApplication.shared.connectedScenes + .compactMap({ $0 as? UIWindowScene }) + .first(where: { + $0.activationState == .foregroundActive + })?.screen ?? UIScreen.main } - return PlatformImage.animatedImage(with: frames, - duration: Double(duration) / 1000.0) -} + public func nativeScale() -> CGFloat { + keyScreen().nativeScale + } #elseif os(macOS) -@available(macOS 11.0, *) -fileprivate func gifImage(_ source: TransientImage) -> PlatformImage? { - switch source.presentation { - case .data(let data): - return PlatformImage(data: data) - case .file(let path): - return PlatformImage(contentsOfFile: path) + public func nativeScale() -> CGFloat { + NSScreen.main?.backingScaleFactor ?? 1 } -} - -extension NSImage: @unchecked Sendable { - -} #endif - -@available(macOS 11.0, iOS 14.0, *) -fileprivate func gif(_ source: TransientImage) async -> PlatformImage? { - gifImage(source) } -fileprivate func gcd(_ a: Int, _ b: Int) -> Int { - let absB = abs(b) - let r = abs(a) % absB - if r != 0 { - return gcd(absB, r) - } else { - return absB - } -} -fileprivate func delayForImage(at index: Int, source: CGImageSource) -> Double { - var delay = 0.1 - - // Get dictionaries - let cfProperties = CGImageSourceCopyPropertiesAtIndex(source, index, nil) - let gifPropertiesPointer = UnsafeMutablePointer.allocate(capacity: 0) - if CFDictionaryGetValueIfPresent(cfProperties, Unmanaged.passUnretained(kCGImagePropertyGIFDictionary).toOpaque(), gifPropertiesPointer) == false { - return delay - } - - let gifProperties:CFDictionary = unsafeBitCast(gifPropertiesPointer.pointee, to: CFDictionary.self) - - // Get delay time - var delayObject: AnyObject = unsafeBitCast(CFDictionaryGetValue(gifProperties, Unmanaged.passUnretained(kCGImagePropertyGIFUnclampedDelayTime).toOpaque()), to: AnyObject.self) - if delayObject.doubleValue == 0 { - delayObject = unsafeBitCast(CFDictionaryGetValue(gifProperties, Unmanaged.passUnretained(kCGImagePropertyGIFDelayTime).toOpaque()), to: AnyObject.self) - } - - delay = delayObject as! Double - - if delay < 0.1 { - delay = 0.1 // Make sure they're not too fast - } - - return delay -} //@available(iOS 13.0, *) //struct GIFImageTest: View { // @State private var imageData: Data? = nil -// +// // var body: some View { // VStack { // GIFImage(name: "preview") @@ -245,7 +170,7 @@ fileprivate func delayForImage(at index: Int, source: CGImageSource) -> Double { // } // } // } -// +// // private func loadData() { // let task = URLSession.shared.dataTask(with: URL(string: "https://github.com/globulus/swiftui-webview/raw/main/Images/preview_macos.gif?raw=true")!) { data, response, error in // imageData = data @@ -260,3 +185,4 @@ fileprivate func delayForImage(at index: Int, source: CGImageSource) -> Double { // GIFImageTest() // } //} + diff --git a/Sources/URLImage/Views/RemoteGIFImageView.swift b/Sources/URLImage/Views/RemoteGIFImageView.swift new file mode 100644 index 0000000..afcfa39 --- /dev/null +++ b/Sources/URLImage/Views/RemoteGIFImageView.swift @@ -0,0 +1,223 @@ +// +// SwiftUIView.swift +// +// +// Created by sonoma on 4/24/24. +// + +import SwiftUI +import Model + +@available(macOS 12.0, iOS 15.0, tvOS 14.0, watchOS 7.0, *) +struct RemoteGIFImageView : View where Empty : View, + InProgress : View, + Failure : View, + Content : View { + @ObservedObject private(set) var remoteImage: RemoteImage + @Environment(\.urlImageService) var urlImageService + @Environment(\.urlImageOptions) var options + @State private var image: PlatformImage? + + let loadOptions: URLImageOptions.LoadOptions + + let empty: () -> Empty + let inProgress: (_ progress: Float?) -> InProgress + let failure: (_ error: Error, _ retry: @escaping () -> Void) -> Failure + let content: (_ value: GIFImageView) -> Content + + init(remoteImage: RemoteImage, + loadOptions: URLImageOptions.LoadOptions, + @ViewBuilder empty: @escaping () -> Empty, + @ViewBuilder inProgress: @escaping (_ progress: Float?) -> InProgress, + @ViewBuilder failure: @escaping (_ error: Error, _ retry: @escaping () -> Void) -> Failure, + @ViewBuilder content: @escaping (_ value: GIFImageView) -> Content) { + + self.remoteImage = remoteImage + self.loadOptions = loadOptions + + self.empty = empty + self.inProgress = inProgress + self.failure = failure + self.content = content + + if loadOptions.contains(.loadImmediately) { + remoteImage.load() + prepare(remoteImage.loadingState) + } + } + + var body: some View { + ZStack { + switch remoteImage.loadingState { + case .initial: + empty() + + case .inProgress(let progress): + inProgress(progress) + + case .success(let next): + if let image = image { + content( + GIFImageView(image: image) + ) + .aspectRatio(next.info.size, contentMode: .fit) + } else { + inProgress(1.0) + } + case .failure(let error): + failure(error) { + remoteImage.load() + } + } + } + .onAppear { + if loadOptions.contains(.loadOnAppear) { + remoteImage.load() + } + } + .onDisappear { + if loadOptions.contains(.cancelOnDisappear) { + remoteImage.cancel() + } + } + .onReceive(remoteImage.$loadingState, perform: prepare(_:)) + } + + private func prepare(_ state: RemoteImage.LoadingState) { + if case .success(let next) = state { + Task { + await load(options.maxPixelSize) + } + } + } + +// private func transientImage(_ pass: TransientImage) -> PlatformImage { +// if options.maxPixelSize == nil { +//#if os(macOS) +// return gifImage(pass) ?? PlatformImage(cgImage: pass.cgImage, size: pass.info.size) +//#else +// return gifImage(pass) ?? PlatformImage(cgImage: pass.cgImage) +//#endif +// } +// let transient = TransientImage(decoder: pass.proxy.decoder, presentation: pass.presentation, maxPixelSize: options.maxPixelSize) ?? pass +// +//#if os(macOS) +// return gifImage(transient) ?? PlatformImage(cgImage: pass.cgImage, size: pass.info.size) +//#else +// return gifImage(transient) ?? PlatformImage(cgImage: pass.cgImage) +//#endif +// } + + private func load(_ maxPixelSize: CGSize?) async { + guard let fileStore = urlImageService.fileStore else { + print("fileStore missing") + return + } + + do { + for try await value in fileStore.getImagePublisher([.url(remoteImage.download.url)], maxPixelSize: maxPixelSize).values { + guard let value = value else { + continue + } + let data = await gif(value) + image = data + } + } catch { + print("retrive image with \(remoteImage.download.url) failed. \(error)") + } + } +} + +#if os(iOS) || os(watchOS) +@available(iOS 14.0, *) +fileprivate func gifImage(_ image: TransientImage) -> PlatformImage? { + let source = image.proxy.decoder.imageSource + let count = CGImageSourceGetCount(source) + let delays = (0.. PlatformImage? { + switch source.presentation { + case .data(let data): + return PlatformImage(data: data) + case .file(let path): + let image = PlatformImage(contentsOfFile: path) + print("cache image file \(String(describing: image)) load from \(path)") + return image + } +} + +extension NSImage: @unchecked Sendable { + +} +#endif + +@available(macOS 11.0, iOS 14.0, *) +fileprivate func gif(_ source: TransientImage) async -> PlatformImage? { + gifImage(source) +} + +fileprivate func gcd(_ a: Int, _ b: Int) -> Int { + let absB = abs(b) + let r = abs(a) % absB + if r != 0 { + return gcd(absB, r) + } else { + return absB + } +} + +fileprivate func delayForImage(at index: Int, source: CGImageSource) -> Double { + var delay = 0.1 + + // Get dictionaries + let cfProperties = CGImageSourceCopyPropertiesAtIndex(source, index, nil) + let gifPropertiesPointer = UnsafeMutablePointer.allocate(capacity: 0) + if CFDictionaryGetValueIfPresent(cfProperties, Unmanaged.passUnretained(kCGImagePropertyGIFDictionary).toOpaque(), gifPropertiesPointer) == false { + return delay + } + + let gifProperties:CFDictionary = unsafeBitCast(gifPropertiesPointer.pointee, to: CFDictionary.self) + + // Get delay time + var delayObject: AnyObject = unsafeBitCast(CFDictionaryGetValue(gifProperties, Unmanaged.passUnretained(kCGImagePropertyGIFUnclampedDelayTime).toOpaque()), to: AnyObject.self) + if delayObject.doubleValue == 0 { + delayObject = unsafeBitCast(CFDictionaryGetValue(gifProperties, Unmanaged.passUnretained(kCGImagePropertyGIFDelayTime).toOpaque()), to: AnyObject.self) + } + + delay = delayObject as! Double + + if delay < 0.1 { + delay = 0.1 // Make sure they're not too fast + } + + return delay +} + + +//#Preview { +// RemoteGIFImageView() +//} From 1d5ab58499c799b40ca9b70a92ab396ff583abab Mon Sep 17 00:00:00 2001 From: johncorner Date: Thu, 25 Apr 2024 02:05:44 -0400 Subject: [PATCH 17/27] upgrade iOS version --- Package.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Package.swift b/Package.swift index 0974530..ac2362a 100644 --- a/Package.swift +++ b/Package.swift @@ -6,7 +6,7 @@ import PackageDescription let package = Package( name: "URLImage", platforms: [ - .iOS(.v13), + .iOS(.v14), .tvOS(.v12), .macOS(.v10_15), .watchOS(.v4) From 2587c1471ef8c93801bc9fd5bf02cc1993fb936e Mon Sep 17 00:00:00 2001 From: johncorner Date: Thu, 25 Apr 2024 05:07:03 -0400 Subject: [PATCH 18/27] fix SPM dependencies --- Package.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Package.swift b/Package.swift index ac2362a..12c313b 100644 --- a/Package.swift +++ b/Package.swift @@ -52,7 +52,7 @@ let package = Package( path: "Dependencies/Sources/Log"), .target( name: "Model", - dependencies: [ "ImageDecoder" ], + dependencies: [ "ImageDecoder", "DownloadManager" ], path: "Dependencies/Sources/Model"), .testTarget( name: "URLImageTests", From c71542c6ba5021426582770e433ab8909ce94e7e Mon Sep 17 00:00:00 2001 From: johncorner Date: Fri, 26 Apr 2024 09:07:12 -0400 Subject: [PATCH 19/27] fix gif image size not display in correct aspect ratio --- Sources/URLImage/Views/GIFImage.swift | 12 +++++++++++- Sources/URLImage/Views/RemoteGIFImageView.swift | 1 - 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/Sources/URLImage/Views/GIFImage.swift b/Sources/URLImage/Views/GIFImage.swift index 7e38c8b..fb6104e 100644 --- a/Sources/URLImage/Views/GIFImage.swift +++ b/Sources/URLImage/Views/GIFImage.swift @@ -60,7 +60,17 @@ public struct GIFImage : View where Empty : } @available(macOS 11.0, iOS 14.0, *) -public struct GIFImageView: PlatformViewRepresentable { +public struct GIFImageView: View { + var image: PlatformImage + + public var body: some View { + GIFRepresentView(image: image) + .aspectRatio(image.size, contentMode: .fit) + } +} + +@available(macOS 11.0, iOS 14.0, *) +struct GIFRepresentView: PlatformViewRepresentable { var image: PlatformImage #if os(iOS) || os(watchOS) diff --git a/Sources/URLImage/Views/RemoteGIFImageView.swift b/Sources/URLImage/Views/RemoteGIFImageView.swift index afcfa39..147bbba 100644 --- a/Sources/URLImage/Views/RemoteGIFImageView.swift +++ b/Sources/URLImage/Views/RemoteGIFImageView.swift @@ -60,7 +60,6 @@ struct RemoteGIFImageView : View where Empt content( GIFImageView(image: image) ) - .aspectRatio(next.info.size, contentMode: .fit) } else { inProgress(1.0) } From 43ae6544fcdbdb0cf181ceeffcfcec0ab4b975f7 Mon Sep 17 00:00:00 2001 From: johncorner Date: Fri, 26 Apr 2024 10:03:45 -0400 Subject: [PATCH 20/27] add aspectResizeble(ratio:contentMode:) api, it will behavior like Image .aspectRatio(_:contentMode:) api if called, otherwise wil display real image size --- Sources/URLImage/Views/GIFImage.swift | 59 +++++++++++++++++++++++++-- 1 file changed, 55 insertions(+), 4 deletions(-) diff --git a/Sources/URLImage/Views/GIFImage.swift b/Sources/URLImage/Views/GIFImage.swift index fb6104e..763ad92 100644 --- a/Sources/URLImage/Views/GIFImage.swift +++ b/Sources/URLImage/Views/GIFImage.swift @@ -62,16 +62,33 @@ public struct GIFImage : View where Empty : @available(macOS 11.0, iOS 14.0, *) public struct GIFImageView: View { var image: PlatformImage + @Environment(\.imageConfigures) var imageConfigures + + init(image: PlatformImage) { + self.image = image + } public var body: some View { - GIFRepresentView(image: image) - .aspectRatio(image.size, contentMode: .fit) + if imageConfigures.resizeble, let aspectRatio = imageConfigures.aspectRatio { + GIFRepresentView(image: image) + .aspectRatio(aspectRatio, contentMode: imageConfigures.contentMode == .fit ? .fit:.fill) + } else { + GIFRepresentView(image: image) + .frame(width: image.size.width, height: image.size.height) + } + } +} + +public extension View { + func aspectResizeble(ratio: CGFloat, contentMode: ContentMode = .fit) -> some View { + self.environment(\.imageConfigures, ImageConfigures(aspectRatio: ratio, contentMode: contentMode, resizeble: true)) } } @available(macOS 11.0, iOS 14.0, *) struct GIFRepresentView: PlatformViewRepresentable { var image: PlatformImage + @Environment(\.imageConfigures) var imageConfigures #if os(iOS) || os(watchOS) public func makeUIView(context: Context) -> UIGIFImage { @@ -95,6 +112,7 @@ struct GIFRepresentView: PlatformViewRepresentable { public final class UIGIFImage: PlatformView { let imageView = PlatformImageView() var source: PlatformImage? + var imageConfigures: ImageConfigures? override init(frame: CGRect) { super.init(frame: frame) @@ -126,10 +144,10 @@ public final class UIGIFImage: PlatformView { private func initView() { #if os(iOS) || os(watchOS) - imageView.contentMode = .scaleAspectFit + imageView.contentMode = imageConfigures?.contentMode == .fit ? .scaleAspectFit:.scaleAspectFill imageView.image = source #elseif os(macOS) - imageView.imageScaling = .scaleAxesIndependently + imageView.imageScaling = imageConfigures?.contentMode == .fit ? .scaleAxesIndependently:.scaleProportionallyUpOrDown imageView.image = source imageView.animates = true #endif @@ -161,6 +179,39 @@ public struct Scenes { #endif } +public enum ContentMode { + case fit + case fill +} + +struct ImageConfigures { + var aspectRatio: CGFloat? + var contentMode: ContentMode + var resizeble: Bool + + func aspectRatio(_ aspectRatio: CGFloat) -> ImageConfigures { + var configures = self + configures.aspectRatio = aspectRatio + return configures + } + + func contentMode(_ contentMode: ContentMode) -> ImageConfigures { + var configures = self + configures.contentMode = contentMode + return configures + } +} + +struct ImageConfiguresEnvironmentKey: EnvironmentKey { + static var defaultValue = ImageConfigures(aspectRatio: nil, contentMode: .fit, resizeble: false) +} + +extension EnvironmentValues { + var imageConfigures: ImageConfigures { + get { self[ImageConfiguresEnvironmentKey.self] } + set { self[ImageConfiguresEnvironmentKey.self] = newValue } + } +} //@available(iOS 13.0, *) From 9f3e9b9c9c7bc4824dda5fca539037234e21bad3 Mon Sep 17 00:00:00 2001 From: johncorner Date: Fri, 26 Apr 2024 10:15:47 -0400 Subject: [PATCH 21/27] update README --- README.md | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/README.md b/README.md index 9b9d990..df94c3e 100644 --- a/README.md +++ b/README.md @@ -143,6 +143,30 @@ struct MyApp: App { } ``` +### GIF + +By default, `URLImage` can display GIF images, but it only displays the first frame of the GIF as a static image. To display the ***animated GIF***, you should use `GIFImage`. The default display mode for image is similar to SwiftUI's default `Image`, but to achieve scaling effects, you need to call the `.aspectResizeble(ratio:,contentMode:)` method. Additionally, ***Zoom In*** operation is supported. + +```swift +GIFImage(item.imageURL) { + // This view is displayed before download starts + EmptyView() +} inProgress: { progress in + // Display progress + Text("Loading...") +} failure: { error, retry in + // Display error and retry button + VStack { + Text(error.localizedDescription) + Button("Retry", action: retry) + } +} content: { image in + // Downloaded GIF image + image + .aspectResizeble(ratio: ratio, contentMode: .fit) +} +``` + ### Image Information You can use `ImageInfo` structure if you need information about an image, like actual size, or access the underlying `CGImage` object. `ImageInfo` is an argument of `content` view builder closure. From 3a6e8a0c4791799ede73f05fbe10d41d4ab11329 Mon Sep 17 00:00:00 2001 From: johncorner Date: Mon, 27 May 2024 01:19:33 -0400 Subject: [PATCH 22/27] remove unowned self reference --- .../Sources/PlainDatabase/Database.swift | 35 +++++++++++++------ 1 file changed, 25 insertions(+), 10 deletions(-) diff --git a/Dependencies/Sources/PlainDatabase/Database.swift b/Dependencies/Sources/PlainDatabase/Database.swift index b4dcc52..ed977bc 100644 --- a/Dependencies/Sources/PlainDatabase/Database.swift +++ b/Dependencies/Sources/PlainDatabase/Database.swift @@ -48,17 +48,23 @@ public final class Database { public func async(_ closure: @escaping (_ context: NSManagedObjectContext) throws -> Void) async { if #available(macOS 12.0, iOS 15.0, *) { do { - try await context.perform(schedule: .immediate) { [unowned self] in - try closure(self.context) + try await context.perform(schedule: .immediate) { [weak context] in + guard let context = context else { + return + } + try closure(context) } } catch { print(error) } } else { // Fallback on earlier versions - context.perform { [unowned self] in + context.perform { [weak context] in + guard let context = context else { + return + } do { - try closure(self.context) + try closure(context) } catch { print(error) @@ -68,9 +74,12 @@ public final class Database { } public func async(_ closure: @escaping (_ context: NSManagedObjectContext) throws -> Void) { - context.perform { [unowned self] in + context.perform { [weak context] in + guard let context = context else { + return + } do { - try closure(self.context) + try closure(context) } catch { print(error) @@ -79,9 +88,12 @@ public final class Database { } public func sync(_ closure: (_ context: NSManagedObjectContext) throws -> Void) { - context.performAndWait { [unowned self] in + context.performAndWait { [weak context] in + guard let context = context else { + return + } do { - try closure(self.context) + try closure(context) } catch { print(error) @@ -93,9 +105,12 @@ public final class Database { public func sync(_ closure: (_ context: NSManagedObjectContext) throws -> T) throws -> T { var result: Result? = nil - context.performAndWait { [unowned self] in + context.performAndWait { [weak context] in + guard let context = context else { + return + } do { - result = .success(try closure(self.context)) + result = .success(try closure(context)) } catch { result = .failure(error) From f0f5ab05ae42a55dc1f7672ea5186bcc41ae9c02 Mon Sep 17 00:00:00 2001 From: johncorner Date: Mon, 8 Jul 2024 10:43:31 -0400 Subject: [PATCH 23/27] fix URLImageOption read in init life cycle --- Sources/URLImage/URLImage.swift | 32 ++++++++++++----- Sources/URLImage/Views/GIFImage.swift | 15 ++++---- .../URLImage/Views/RemoteGIFImageView.swift | 34 +++++++++++-------- 3 files changed, 51 insertions(+), 30 deletions(-) diff --git a/Sources/URLImage/URLImage.swift b/Sources/URLImage/URLImage.swift index 7d6db55..34da80c 100644 --- a/Sources/URLImage/URLImage.swift +++ b/Sources/URLImage/URLImage.swift @@ -32,14 +32,14 @@ public struct URLImage : View where Empty : let identifier: String? public var body: some View { - let remoteImage = service.makeRemoteImage(url: url, identifier: identifier, options: urlImageOptions) - - return RemoteImageView(remoteImage: remoteImage, - loadOptions: urlImageOptions.loadOptions, - empty: empty, - inProgress: inProgress, - failure: failure, - content: content) + InstalledRemoteView(service: service, url: url, identifier: identifier, options: urlImageOptions) { remoteImage in + RemoteImageView(remoteImage: remoteImage, + loadOptions: urlImageOptions.loadOptions, + empty: empty, + inProgress: inProgress, + failure: failure, + content: content) + } } private let empty: () -> Empty @@ -284,3 +284,19 @@ public extension URLImage where InProgress == Content, content: { image in content(.success(image)) }) } } + +struct InstalledRemoteView: View { + var service: URLImageService + var remoteImge: RemoteImage + var content: (RemoteImage) -> Content + + init(service: URLImageService, url: URL, identifier: String?, options: URLImageOptions, @ViewBuilder content: @escaping (RemoteImage) -> Content) { + self.service = service + self.remoteImge = service.makeRemoteImage(url: url, identifier: identifier, options: options) + self.content = content + } + + var body: some View { + content(remoteImge) + } +} diff --git a/Sources/URLImage/Views/GIFImage.swift b/Sources/URLImage/Views/GIFImage.swift index 763ad92..5924f12 100644 --- a/Sources/URLImage/Views/GIFImage.swift +++ b/Sources/URLImage/Views/GIFImage.swift @@ -49,13 +49,14 @@ public struct GIFImage : View where Empty : } public var body: some View { - let remoteImage = urlImageService.makeRemoteImage(url: url, identifier: nil, options: options) - return RemoteGIFImageView(remoteImage: remoteImage, - loadOptions: options.loadOptions, - empty: empty, - inProgress: inProgress, - failure: failure, - content: content) + InstalledRemoteView(service: urlImageService, url: url, identifier: nil, options: options) { remoteImage in + RemoteGIFImageView(remoteImage: remoteImage, + loadOptions: options.loadOptions, + empty: empty, + inProgress: inProgress, + failure: failure, + content: content) + } } } diff --git a/Sources/URLImage/Views/RemoteGIFImageView.swift b/Sources/URLImage/Views/RemoteGIFImageView.swift index 147bbba..93d8b1e 100644 --- a/Sources/URLImage/Views/RemoteGIFImageView.swift +++ b/Sources/URLImage/Views/RemoteGIFImageView.swift @@ -13,7 +13,7 @@ struct RemoteGIFImageView : View where Empt InProgress : View, Failure : View, Content : View { - @ObservedObject private(set) var remoteImage: RemoteImage + @ObservedObject private var remoteImage: RemoteImage @Environment(\.urlImageService) var urlImageService @Environment(\.urlImageOptions) var options @State private var image: PlatformImage? @@ -42,7 +42,6 @@ struct RemoteGIFImageView : View where Empt if loadOptions.contains(.loadImmediately) { remoteImage.load() - prepare(remoteImage.loadingState) } } @@ -79,14 +78,17 @@ struct RemoteGIFImageView : View where Empt remoteImage.cancel() } } - .onReceive(remoteImage.$loadingState, perform: prepare(_:)) + .onReceive(remoteImage.$loadingState.receive(on: DispatchQueue.main), perform: { newValue in + prepare(newValue) + }) + .task { + prepare(remoteImage.loadingState) + } } private func prepare(_ state: RemoteImage.LoadingState) { if case .success(let next) = state { - Task { - await load(options.maxPixelSize) - } + load(options.maxPixelSize) } } @@ -107,22 +109,24 @@ struct RemoteGIFImageView : View where Empt //#endif // } - private func load(_ maxPixelSize: CGSize?) async { + private func load(_ maxPixelSize: CGSize?) { guard let fileStore = urlImageService.fileStore else { print("fileStore missing") return } - do { - for try await value in fileStore.getImagePublisher([.url(remoteImage.download.url)], maxPixelSize: maxPixelSize).values { - guard let value = value else { - continue + Task { + do { + for try await value in fileStore.getImagePublisher([.url(remoteImage.download.url)], maxPixelSize: maxPixelSize).values { + guard let value = value else { + continue + } + let data = await gif(value) + image = data } - let data = await gif(value) - image = data + } catch { + print("retrive image with \(remoteImage.download.url) failed. \(error)") } - } catch { - print("retrive image with \(remoteImage.download.url) failed. \(error)") } } } From ba69f937bc71a2ccb65f48447cee17f3bff6809e Mon Sep 17 00:00:00 2001 From: johncorner Date: Sat, 27 Jul 2024 02:56:16 -0400 Subject: [PATCH 24/27] baseline image gallary function --- Sources/URLImage/URLImage.swift | 1 + Sources/URLImage/Views/RemoteImageView.swift | 5 ++--- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Sources/URLImage/URLImage.swift b/Sources/URLImage/URLImage.swift index 34da80c..9bb8261 100644 --- a/Sources/URLImage/URLImage.swift +++ b/Sources/URLImage/URLImage.swift @@ -285,6 +285,7 @@ public extension URLImage where InProgress == Content, } } +@available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) struct InstalledRemoteView: View { var service: URLImageService var remoteImge: RemoteImage diff --git a/Sources/URLImage/Views/RemoteImageView.swift b/Sources/URLImage/Views/RemoteImageView.swift index 52b1fff..aa8c93d 100644 --- a/Sources/URLImage/Views/RemoteImageView.swift +++ b/Sources/URLImage/Views/RemoteImageView.swift @@ -39,7 +39,7 @@ struct RemoteImageView : View where Empty : self.failure = failure self.content = content - if loadOptions.contains(.loadImmediately) { + if loadOptions.contains(.loadImmediately), !remoteImage.loadingState.isSuccess { remoteImage.load() } } @@ -55,7 +55,6 @@ struct RemoteImageView : View where Empty : case .success(let value): content(value) - case .failure(let error): failure(error) { remoteImage.load() @@ -63,7 +62,7 @@ struct RemoteImageView : View where Empty : } } .onAppear { - if loadOptions.contains(.loadOnAppear) { + if loadOptions.contains(.loadOnAppear), !remoteImage.loadingState.isSuccess { remoteImage.load() } } From 3cc0a76abe98fc2f3a9a33e6b099b98260af03b5 Mon Sep 17 00:00:00 2001 From: johncorner Date: Tue, 24 Sep 2024 10:25:02 -0400 Subject: [PATCH 25/27] swift 6 error fixed --- .../Download/DownloadPublisher.swift | 22 +- .../DownloadManager/DownloadManager.swift | 241 +++++++- .../Sources/ImageDecoder/ImageDecoder.swift | 2 +- Package.resolved | 25 + Package.swift | 3 +- Sources/URLImage/Common/Backport.swift | 53 ++ .../URLImage/RemoteImage/RemoteImage.swift | 531 +++++++++++++----- .../RemoteImage/RemoteImageLoadingState.swift | 18 + .../Service/URLImageService+RemoteImage.swift | 13 +- .../Store/Common/URLImageStoreInfo.swift | 6 + .../Store/URLImageFileStoreType+Combine.swift | 19 + Sources/URLImage/URLImage.swift | 31 +- Sources/URLImage/URLImageOptions.swift | 5 + .../URLImage/Views/RemoteGIFImageView.swift | 80 ++- Sources/URLImage/Views/RemoteImageView.swift | 68 ++- 15 files changed, 875 insertions(+), 242 deletions(-) create mode 100644 Package.resolved create mode 100644 Sources/URLImage/Common/Backport.swift diff --git a/Dependencies/Sources/DownloadManager/Download/DownloadPublisher.swift b/Dependencies/Sources/DownloadManager/Download/DownloadPublisher.swift index 512649e..ca2802c 100644 --- a/Dependencies/Sources/DownloadManager/Download/DownloadPublisher.swift +++ b/Dependencies/Sources/DownloadManager/Download/DownloadPublisher.swift @@ -21,16 +21,16 @@ public struct DownloadPublisher: Publisher { DownloadPublisher.Failure == S.Failure, DownloadPublisher.Output == S.Input { - let subscription = DownloadSubscription(subscriber: subscriber, download: download, manager: manager) + let subscription = DownloadSubscription(subscriber: subscriber, download: download, coordinator: coordinator) subscriber.receive(subscription: subscription) } - init(download: Download, manager: DownloadManager) { + init(download: Download, coordinator: URLSessionCoordinator) { self.download = download - self.manager = manager + self.coordinator = coordinator } - private let manager: DownloadManager + private let coordinator: URLSessionCoordinator } @@ -43,12 +43,12 @@ final class DownloadSubscription: Subscription private let download: Download - private unowned let manager: DownloadManager + private unowned let coordinator: URLSessionCoordinator - init(subscriber: SubscriberType, download: Download, manager: DownloadManager) { + init(subscriber: SubscriberType, download: Download, coordinator: URLSessionCoordinator) { self.subscriber = subscriber self.download = download - self.manager = manager + self.coordinator = coordinator } func request(_ demand: Subscribers.Demand) { @@ -56,7 +56,7 @@ final class DownloadSubscription: Subscription log_debug(self, #function, "download.id = \(download.id), download.url = \(self.download.url)", detail: log_detailed) - manager.coordinator.startDownload(download, + coordinator.startDownload(download, receiveResponse: { _ in }, receiveData: { _, _ in @@ -90,13 +90,13 @@ final class DownloadSubscription: Subscription self.subscriber?.receive(completion: .failure(error)) } - self.manager.reset(download: self.download) +// self.manager.reset(download: self.download) }) } func cancel() { log_debug(self, #function, "download.id = \(download.id), download.url = \(self.download.url)", detail: log_detailed) - manager.coordinator.cancelDownload(download) - manager.reset(download: download) + coordinator.cancelDownload(download) +// manager.reset(download: download) } } diff --git a/Dependencies/Sources/DownloadManager/DownloadManager.swift b/Dependencies/Sources/DownloadManager/DownloadManager.swift index 1bcd59b..cd069cc 100644 --- a/Dependencies/Sources/DownloadManager/DownloadManager.swift +++ b/Dependencies/Sources/DownloadManager/DownloadManager.swift @@ -7,7 +7,7 @@ import Foundation import Combine - +import AsyncExtensions @available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) public final class DownloadManager { @@ -20,34 +20,231 @@ public final class DownloadManager { public typealias DownloadTaskPublisher = Publishers.Share - public func publisher(for download: Download) -> DownloadTaskPublisher { - sync { - let publisher = publishers[download] ?? DownloadPublisher(download: download, manager: self).share() - publishers[download] = publisher - - return publisher - } - } - - public func reset(download: Download) { - async { [weak self] in - guard let self = self else { - return +// public func publisher(for download: Download) -> DownloadTaskPublisher { +// sync { +// let publisher = publishers[download] ?? DownloadPublisher(download: download, manager: self).share() +// publishers[download] = publisher +// +// return publisher +// } +// } + +// public func download(for download: Download) -> AsyncStream> { +// AsyncStream { continuation in +// sync { +// let current = publishers[download] +// let publisher = current ?? DownloadPublisher(download: download, manager: self).share() +// print("current downloader task for \(download.url) - \(current), publisher \(publisher)") +// publishers[download] = publisher +// var cancelHash: Int? +// let cancellable = publisher.sink { [weak self] completion in +// switch completion { +// case .finished: +// break +// case .failure(let error): +// continuation.yield(with: .success(.failure(error))) +// } +// continuation.finish() +// if let cancelHash { +// self?.sync { +// guard let self else { return } +// if let cancel = self.cancellableSets.first(where: { $0.hashValue == cancelHash }) { +// self.cancellableSets.remove(cancel) +// } +// self.cancellableHashTable[download]?.removeAll(where: { $0 == cancelHash }) +// } +// } +// } receiveValue: { output in +// continuation.yield(.success(output)) +// } +// cancellableSets.insert(cancellable) +// cancelHash = cancellable.hashValue +// var values = cancellableHashTable[download] ?? [] +// values.append(cancelHash!) +// cancellableHashTable[download] = values +// } +// } +// } + + public func download(for download: Download) -> AsyncStream> { + let coordinator = self.coordinator + let publishers = self.publishers + return AsyncStream { continuation in + Task { + let _ = await publishers.store(download, coordinator: coordinator, action: { result in + switch result { + case .success(let info): + continuation.yield(.success(info)) + case .failure(let error): + continuation.yield(with: .success(.failure(error))) + } + }) } - - self.publishers[download] = nil } } - private var publishers: [Download: DownloadTaskPublisher] = [:] +// public func reset(download: Download) { +// async { [weak self] in +// guard let self = self else { +// return +// } +// +// self.publishers[download] = nil +// if let hashValues = self.cancellableHashTable[download] { +// let cancellables = self.cancellableSets.filter({ hashValues.contains($0.hashValue) }) +// for cancellable in cancellables { +// self.cancellableSets.remove(cancellable) +// cancellable.cancel() +// } +// } +// } +// Task { +// await publishers.remove(download) +// } +// } + +// private var publishers: [Download: DownloadTaskPublisher] = [:] + private let publishers = PublishersHolder() private let serialQueue = DispatchQueue(label: "DownloadManager.serialQueue") - private func async(_ closure: @escaping () -> Void) { - serialQueue.async(execute: closure) - } +// private func async(_ closure: @escaping () -> Void) { +// serialQueue.async(execute: closure) +// } +// +// private func sync(_ closure: () -> T) -> T { +// serialQueue.sync(execute: closure) +// } +} - private func sync(_ closure: () -> T) -> T { - serialQueue.sync(execute: closure) +//@available(macOS 11.0, *) +//struct Downloader: AsyncSequence { +// typealias AsyncIterator = Iterator +// typealias Element = DownloadInfo +// let download: Download +// let manager: DownloadManager +// +// struct Iterator: AsyncIteratorProtocol { +// let download: Download +// let manager: DownloadManager +// +// func next() async throws -> Element? { +// +// } +// } +// +// func makeAsyncIterator() -> Iterator { +// Iterator(download: download, manager: manager) +// +// manager.coordinator.startDownload(download, +// receiveResponse: { _ in +// }, +// receiveData: { _, _ in +// }, +// reportProgress: { _, progress in +// let _ = self.subscriber?.receive(.progress(progress)) +// }, +// completion: { [weak self] _, result in +// guard let self = self else { +// return +// } +// +// switch result { +// case .success(let downloadResult): +// switch downloadResult { +// case .data(let data): +// break +// case .file(let path): +// break +// } +// case .failure(let error): +// break +// } +// +// self.manager.reset(download: self.download) +// }) +// } +//} + +enum DownloadEventError: Error { + case cancelled +} + +@available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) +actor PublishersHolder { + private var publishers: [URL: DownloadManager.DownloadTaskPublisher] = [:] + private var cancellableHashTable = [UUID: AnyCancellable]() + private var cancellables = [URL: Set]() + private let subject = AsyncBufferedChannel() + private var loaded = false + + deinit { + subject.finish() + } + + func store(_ download: Download, coordinator: URLSessionCoordinator, action: @escaping (Result) -> Void) -> DownloadManager.DownloadTaskPublisher { + if !loaded { + loaded = true + let subject = self.subject + Task { + for await uuid in subject { + pop(uuid) + } + print("publisher obseration finished.") + } + } + + let publisher: Publishers.Share + if let current = publishers[download.url] { + publisher = current + print("use exists task for \(download.url)") + } else { + publisher = DownloadPublisher(download: download, coordinator: coordinator).share() + publishers[download.url] = publisher + print("create new publisher for \(download.url)") + } + let uuid = download.id + let subject = self.subject + cancellableHashTable[uuid] = publisher + .handleEvents(receiveCancel: { + action(.failure(DownloadEventError.cancelled)) + }).sink { completion in + switch completion { + case .finished: + break + case .failure(let error): + action(.failure(error)) + } + + subject.send(uuid) + } receiveValue: { output in + action(.success(output)) + } + var uuids = cancellables[download.url] ?? [] + uuids.insert(uuid) + cancellables[download.url] = uuids + return publisher + } + +// func remove(_ download: Download) { +// publishers.removeValue(forKey: download) +// for uuid in cancellables[download] ?? [] { +// cancellableHashTable[uuid]?.cancel() +// cancellableHashTable.removeValue(forKey: uuid) +// } +// cancellables.removeValue(forKey: download) +// } + + func pop(_ uuid: UUID) { + if let task = cancellableHashTable[uuid] { + cancellableHashTable.removeValue(forKey: uuid) + task.cancel() + } + + for (key, value) in cancellables.filter({ $0.value.contains(uuid) }) { + var next = value + next.remove(uuid) + cancellables[key] = next + } } } diff --git a/Dependencies/Sources/ImageDecoder/ImageDecoder.swift b/Dependencies/Sources/ImageDecoder/ImageDecoder.swift index fc0e0ed..a5a97d1 100644 --- a/Dependencies/Sources/ImageDecoder/ImageDecoder.swift +++ b/Dependencies/Sources/ImageDecoder/ImageDecoder.swift @@ -233,7 +233,7 @@ public final class ImageDecoder { case .synchronous: options = imageSourceOptions(with: subsamplingLevel) - image = CGImageSourceCreateImageAtIndex(imageSource, index, options) + image = CGImageSourceCreateThumbnailAtIndex(imageSource, index, options) } // WebKit has support for xbm images but we don't diff --git a/Package.resolved b/Package.resolved new file mode 100644 index 0000000..7a613de --- /dev/null +++ b/Package.resolved @@ -0,0 +1,25 @@ +{ + "object": { + "pins": [ + { + "package": "AsyncExtensions", + "repositoryURL": "https://github.com/0xfeedface1993/AsyncExtensions.git", + "state": { + "branch": null, + "revision": "e053d0fff37f352525554830aaa6573babdb4184", + "version": "1.0.3" + } + }, + { + "package": "swift-collections", + "repositoryURL": "https://github.com/apple/swift-collections.git", + "state": { + "branch": null, + "revision": "9bf03ff58ce34478e66aaee630e491823326fd06", + "version": "1.1.3" + } + } + ] + }, + "version": 1 +} diff --git a/Package.swift b/Package.swift index 12c313b..1eed8f2 100644 --- a/Package.swift +++ b/Package.swift @@ -23,6 +23,7 @@ let package = Package( dependencies: [ // Dependencies declare other packages that this package depends on. // .package(url: /* package url */, from: "1.0.0"), + .package(url: "https://github.com/0xfeedface1993/AsyncExtensions.git", from: "1.0.3") ], targets: [ // Targets are the basic building blocks of a package. A target can define a module or a test suite. @@ -45,7 +46,7 @@ let package = Package( path: "Dependencies/Sources/PlainDatabase"), .target( name: "DownloadManager", - dependencies: [ "Log" ], + dependencies: [ "Log", "AsyncExtensions" ], path: "Dependencies/Sources/DownloadManager"), .target( name: "Log", diff --git a/Sources/URLImage/Common/Backport.swift b/Sources/URLImage/Common/Backport.swift new file mode 100644 index 0000000..39b16c7 --- /dev/null +++ b/Sources/URLImage/Common/Backport.swift @@ -0,0 +1,53 @@ +// +// File.swift +// URLImage +// +// Created by sonoma on 8/1/24. +// + +import Foundation +import SwiftUI + +struct Backport { + let content: Content +} + +extension View { + nonisolated var backport: Backport { + Backport(content: self) + } +} + +extension Backport { + @inlinable + @ViewBuilder + func task(priority: TaskPriority = .userInitiated, _ action: @escaping @Sendable () async -> Void) -> some View { + if #available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) { + content.task(priority: priority, action) + } else { + content.modifier(TaskModifier(priority: priority, action: action)) + } + } +} + +struct TaskModifier: ViewModifier { + @State private var loaded = false + var priority: TaskPriority + var action: () async -> Void + + init(priority: TaskPriority, action: @escaping @Sendable () async -> Void) { + self.priority = priority + self.action = action + } + + func body(content: Content) -> some View { + content.onAppear { + if !loaded { + loaded = true + Task(priority: priority, operation: { + await action() + }) + } + } + } +} diff --git a/Sources/URLImage/RemoteImage/RemoteImage.swift b/Sources/URLImage/RemoteImage/RemoteImage.swift index 7e9d166..f568732 100644 --- a/Sources/URLImage/RemoteImage/RemoteImage.swift +++ b/Sources/URLImage/RemoteImage/RemoteImage.swift @@ -12,6 +12,12 @@ import DownloadManager import ImageDecoder import Log +let queue = DispatchQueue(label: "com.url.image.workitem") + +@available(macOS 11.0, *) +final class RemoteImageWrapper: ObservableObject { + @Published var remote: RemoteImage? +} @available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) public final class RemoteImage : ObservableObject { @@ -25,84 +31,183 @@ public final class RemoteImage : ObservableObject { let identifier: String? let options: URLImageOptions + + var stateCancellable: AnyCancellable? + var downloadTask: Task? init(service: URLImageService, download: Download, identifier: String?, options: URLImageOptions) { self.service = service self.download = download self.identifier = identifier self.options = options + self.stateCancellable = loadingStatePublisher + .receive(on: DispatchQueue.main) + .sink(receiveValue: { [weak self] state in + self?.slowLoadingState = state + }) log_debug(nil, #function, download.url.absoluteString) + } deinit { + stateCancellable?.cancel() +// downloadTask?.cancel() log_debug(nil, #function, download.url.absoluteString, detail: log_detailed) } public typealias LoadingState = RemoteImageLoadingState /// External loading state used to update the view - @Published public private(set) var loadingState: LoadingState = .initial { - willSet { - log_debug(self, #function, "\(download.url) will transition from \(loadingState) to \(newValue)", detail: log_detailed) - } +// @Published public private(set) var loadingState: LoadingState = .initial { +// willSet { +// log_debug(self, #function, "\(download.url) will transition from \(loadingState) to \(newValue)", detail: log_detailed) +// } +// } + + private(set) var loadingState = CurrentValueSubject(.initial) + + @Published public private(set) var slowLoadingState: LoadingState = .initial + + private var progressStatePublisher: AnyPublisher { + loadingState + .filter({ $0.isInProgress }) + .collect(.byTime(queue, .milliseconds(100))) + .compactMap(\.last) + .eraseToAnyPublisher() + } + + private var nonProgressStatePublisher: AnyPublisher { + loadingState + .filter({ !$0.isInProgress }) + .eraseToAnyPublisher() + } + + private var loadingStatePublisher: AnyPublisher { + nonProgressStatePublisher + .merge(with: progressStatePublisher) + .scan(LoadingState.initial, { current, next in + next.isInProgress && current.isComplete ? current:next + }) + .removeDuplicates() + .eraseToAnyPublisher() } - public func load() { +// public func load() { +// guard !isLoading else { +// return +// } +// +// log_debug(self, #function, "Start load for: \(download.url)", detail: log_normal) +// +// isLoading = true +// +// switch options.fetchPolicy { +// case .returnStoreElseLoad(let downloadDelay): +// guard !isLoadedSuccessfully else { +// // Already loaded +// isLoading = false +// return +// } +// +// guard !loadFromInMemoryStore() else { +// // Loaded from the in-memory store +// isLoading = false +// return +// } +// +// // Disk lookup +// // scheduleReturnStored(afterDelay: nil) { [weak self] success in +// // guard let self = self else { return } +// // +// // if !success { +// // self.scheduleDownload(afterDelay: downloadDelay, secondStoreLookup: true) +// // } +// // } +// Task { +// let success = await scheduleReturnStored(afterDelay: nil) +// if !success { +// self.scheduleDownload(afterDelay: downloadDelay, secondStoreLookup: true) +// } +// } +// +// case .returnStoreDontLoad: +// guard !isLoadedSuccessfully else { +// // Already loaded +// isLoading = false +// return +// } +// +// guard !loadFromInMemoryStore() else { +// // Loaded from the in-memory store +// isLoading = false +// return +// } +// +// // Disk lookup +// // scheduleReturnStored(afterDelay: nil) { [weak self] success in +// // guard let self = self else { return } +// // +// // if !success { +// // // Complete +// // self.loadingState = .initial +// // self.isLoading = false +// // } +// // } +// Task { +// let success = await scheduleReturnStored(afterDelay: nil) +// if !success { +// await updateLoadingState(.initial) +// await updateIsLoading(false) +// } +// } +// } +// } + public func load() async { guard !isLoading else { return } - + log_debug(self, #function, "Start load for: \(download.url)", detail: log_normal) - - isLoading = true - + + await updateIsLoading(true) + switch options.fetchPolicy { - case .returnStoreElseLoad(let downloadDelay): - guard !isLoadedSuccessfully else { - // Already loaded - isLoading = false - return - } - - guard !loadFromInMemoryStore() else { - // Loaded from the in-memory store - isLoading = false - return - } - - // Disk lookup - scheduleReturnStored(afterDelay: nil) { [weak self] success in - guard let self = self else { return } - - if !success { - self.scheduleDownload(afterDelay: downloadDelay, secondStoreLookup: true) - } - } - - case .returnStoreDontLoad: - guard !isLoadedSuccessfully else { - // Already loaded - isLoading = false - return - } - - guard !loadFromInMemoryStore() else { - // Loaded from the in-memory store - isLoading = false - return - } - - // Disk lookup - scheduleReturnStored(afterDelay: nil) { [weak self] success in - guard let self = self else { return } - - if !success { - // Complete - self.loadingState = .initial - self.isLoading = false - } - } + case .returnStoreElseLoad(let downloadDelay): + guard !isLoadedSuccessfully else { + // Already loaded + await updateIsLoading(false) + return + } + + guard !loadFromInMemoryStore() else { + // Loaded from the in-memory store + await updateIsLoading(false) + return + } + + let success = await scheduleReturnStored(afterDelay: nil) + if !success { + self.scheduleDownload(afterDelay: downloadDelay, secondStoreLookup: true) + } + case .returnStoreDontLoad: + guard !isLoadedSuccessfully else { + // Already loaded + await updateIsLoading(false) + return + } + + guard !loadFromInMemoryStore() else { + // Loaded from the in-memory store + await updateIsLoading(false) + return + } + + let success = await scheduleReturnStored(afterDelay: nil) + if !success { + updateLoadingState(.initial) + await updateIsLoading(false) + } } } @@ -127,6 +232,10 @@ public final class RemoteImage : ObservableObject { delayedDownload?.cancel() delayedDownload = nil + +// downloadTask?.cancel() + downloadTask?.cancel() + downloadTask = nil } /// Internal loading state @@ -142,7 +251,7 @@ public final class RemoteImage : ObservableObject { extension RemoteImage { private var isLoadedSuccessfully: Bool { - switch loadingState { + switch loadingState.value { case .success: return true default: @@ -153,6 +262,24 @@ extension RemoteImage { /// Rerturn an image from the in memory store. /// /// Sets `loadingState` to `.success` if an image is in the in-memory store and returns `true`. Otherwise returns `false` without changing the state. +// private func loadFromInMemoryStore() -> Bool { +// guard let store = service.inMemoryStore else { +// log_debug(self, #function, "Not using in memory store for \(download.url)", detail: log_normal) +// return false +// } +// +// guard let transientImage: TransientImage = store.getImage(keys) else { +// log_debug(self, #function, "Image for \(download.url) not in the in memory store", detail: log_normal) +// return false +// } +// +// // Complete +// self.loadingState = .success(transientImage) +// log_debug(self, #function, "Image for \(download.url) is in the in memory store", detail: log_normal) +// +// return true +// } + private func loadFromInMemoryStore() -> Bool { guard let store = service.inMemoryStore else { log_debug(self, #function, "Not using in memory store for \(download.url)", detail: log_normal) @@ -165,147 +292,239 @@ extension RemoteImage { } // Complete - self.loadingState = .success(transientImage) + self.updateLoadingState(.success(transientImage)) log_debug(self, #function, "Image for \(download.url) is in the in memory store", detail: log_normal) return true } - private func scheduleReturnStored(afterDelay delay: TimeInterval?, completion: @escaping (_ success: Bool) -> Void) { +// private func scheduleReturnStored(afterDelay delay: TimeInterval?, completion: @escaping (_ success: Bool) -> Void) { +// guard let delay = delay else { +// // Read from store immediately if no delay needed +// returnStored(completion) +// return +// } +// +// delayedReturnStored?.cancel() +// delayedReturnStored = DispatchWorkItem { [weak self] in +// guard let self = self else { return } +// self.returnStored(completion) +// } +// +// queue.asyncAfter(deadline: .now() + delay, execute: delayedReturnStored!) +// } + + private func scheduleReturnStored(afterDelay delay: TimeInterval?) async -> Bool { guard let delay = delay else { // Read from store immediately if no delay needed - returnStored(completion) - return + return await returnStored() } - - delayedReturnStored?.cancel() - delayedReturnStored = DispatchWorkItem { [weak self] in - guard let self = self else { return } - self.returnStored(completion) + + if #available(iOS 16.0, macOS 13, *) { + try? await Task.sleep(for: .seconds(delay)) + } else { + // Fallback on earlier versions + try? await Task.sleep(nanoseconds: UInt64(1_000_000_000 * delay)) } - - DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: delayedReturnStored!) + + return await returnStored() } // Second store lookup is necessary for a case if the same image was downloaded by another instance of RemoteImage +// private func scheduleDownload(afterDelay delay: TimeInterval? = nil, secondStoreLookup: Bool = false) { +// guard let delay = delay else { +// // Start download immediately if no delay needed +// startDownload() +// return +// } +// +// delayedDownload?.cancel() +// delayedDownload = DispatchWorkItem { [weak self] in +// guard let self = self else { return } +// +// if secondStoreLookup { +// self.returnStored { [weak self] success in +// guard let self = self else { return } +// +// if !success { +// self.startDownload() +// } +// } +// } +// else { +// self.startDownload() +// } +// } +// +// queue.asyncAfter(deadline: .now() + delay, execute: delayedDownload!) +// } + private func scheduleDownload(afterDelay delay: TimeInterval? = nil, secondStoreLookup: Bool = false) { - guard let delay = delay else { + guard let _ = delay else { // Start download immediately if no delay needed - startDownload() + Task { + await startDownload() + } return } - - delayedDownload?.cancel() - delayedDownload = DispatchWorkItem { [weak self] in - guard let self = self else { return } - - if secondStoreLookup { - self.returnStored { [weak self] success in - guard let self = self else { return } - - if !success { - self.startDownload() - } + + if secondStoreLookup { + Task { + let success = await returnStored() + if !success { + await startDownload() } } - else { - self.startDownload() + } else { + Task { + await startDownload() } } - - DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: delayedDownload!) } - private func startDownload() { - loadingState = .inProgress(nil) - - service.downloadManager.publisher(for: download) - .sink { [weak self] result in - guard let self = self else { - return - } - - switch result { - case .finished: - break - - case .failure(let error): - // This route happens when download fails - self.updateLoadingState(.failure(error)) - } - } - receiveValue: { [weak self] info in - guard let self = self else { - return - } - +// private func startDownload() { +// loadingState = .inProgress(nil) +// +// service.downloadManager.publisher(for: download) +// .sink { [weak self] result in +// guard let self = self else { +// return +// } +// +// switch result { +// case .finished: +// break +// +// case .failure(let error): +// // This route happens when download fails +// self.updateLoadingState(.failure(error)) +// } +// } +// receiveValue: { [weak self] info in +// guard let self = self else { +// return +// } +// +// switch info { +// case .progress(let progress): +// self.updateLoadingState(.inProgress(progress)) +// case .completion(let result): +// do { +// let transientImage = try self.service.decode(result: result, +// download: self.download, +// identifier: self.identifier, +// options: self.options) +// self.updateLoadingState(.success(transientImage)) +// } +// catch { +// // This route happens when download succeeds, but decoding fails +// self.updateLoadingState(.failure(error)) +// } +// } +// } +// .store(in: &cancellables) +// } + private func startDownload() async { + updateLoadingState(.inProgress(nil)) + + let infos = service.downloadManager.download(for: download) + for await update in infos { + switch update { + case .success(let info): switch info { case .progress(let progress): - self.updateLoadingState(.inProgress(progress)) + updateLoadingState(.inProgress(progress)) case .completion(let result): do { - let transientImage = try self.service.decode(result: result, - download: self.download, - identifier: self.identifier, - options: self.options) - self.updateLoadingState(.success(transientImage)) - } - catch { + let transientImage = try service.decode(result: result, + download: download, + identifier: identifier, + options: options) + updateLoadingState(.success(transientImage)) + } catch { // This route happens when download succeeds, but decoding fails - self.updateLoadingState(.failure(error)) + updateLoadingState(.failure(error)) } } + case .failure(let error): + updateLoadingState(.failure(error)) } - .store(in: &cancellables) + } } - private func returnStored(_ completion: @escaping (_ success: Bool) -> Void) { - loadingState = .inProgress(nil) +// private func returnStored(_ completion: @escaping (_ success: Bool) -> Void) { +// Task { @MainActor in +// loadingState = .inProgress(nil) +// } +// +// guard let store = service.fileStore else { +// completion(false) +// return +// } +// +// store.getImagePublisher(keys, maxPixelSize: options.maxPixelSize) +// .receive(on: DispatchQueue.main) +// .catch { _ in +// Just(nil) +// } +// .sink { [weak self] in +// guard let self = self else { +// return +// } +// +// if let transientImage = $0 { +// log_debug(self, #function, "Image for \(self.download.url) is in the disk store", detail: log_normal) +// // Store in memory +// let info = URLImageStoreInfo(url: self.download.url, +// identifier: self.identifier, +// uti: transientImage.uti) +// +// self.service.inMemoryStore?.store(transientImage, info: info) +// +// // Complete +// self.loadingState = .success(transientImage) +// completion(true) +// } +// else { +// log_debug(self, #function, "Image for \(self.download.url) not in the disk store", detail: log_normal) +// completion(false) +// } +// } +// .store(in: &cancellables) +// } + + @MainActor + private func returnStored() async -> Bool { + loadingState.send(.inProgress(nil)) guard let store = service.fileStore else { - completion(false) - return + return false } - store.getImagePublisher(keys, maxPixelSize: options.maxPixelSize) - .receive(on: DispatchQueue.main) - .catch { _ in - Just(nil) - } - .sink { [weak self] in - guard let self = self else { - return - } - - if let transientImage = $0 { - log_debug(self, #function, "Image for \(self.download.url) is in the disk store", detail: log_normal) - // Store in memory - let info = URLImageStoreInfo(url: self.download.url, - identifier: self.identifier, - uti: transientImage.uti) + let transientImage = try? await store.getImage(keys, maxPixelSize: options.maxPixelSize) + guard let transientImage else { + log_debug(self, #function, "Image for \(download.url) not in the disk store", detail: log_normal) + return false + } + + log_debug(self, #function, "Image for \(self.download.url) is in the disk store", detail: log_normal) + // Store in memory + let info = URLImageStoreInfo(url: download.url, identifier: identifier, uti: transientImage.uti) - self.service.inMemoryStore?.store(transientImage, info: info) + service.inMemoryStore?.store(transientImage, info: info) - // Complete - self.loadingState = .success(transientImage) - completion(true) - } - else { - log_debug(self, #function, "Image for \(self.download.url) not in the disk store", detail: log_normal) - completion(false) - } - } - .store(in: &cancellables) + // Complete + self.loadingState.send(.success(transientImage)) + return true } - + private func updateLoadingState(_ loadingState: LoadingState) { - DispatchQueue.main.async { [weak self] in - guard let self = self else { - return - } - - self.loadingState = loadingState - } + self.loadingState.send(loadingState) + } + + @MainActor + private func updateIsLoading(_ loading: Bool) { + self.isLoading = loading } /// Helper to return `URLImageStoreKey` objects based on `URLImageOptions` and `Download` properties diff --git a/Sources/URLImage/RemoteImage/RemoteImageLoadingState.swift b/Sources/URLImage/RemoteImage/RemoteImageLoadingState.swift index c263977..1f9245b 100644 --- a/Sources/URLImage/RemoteImage/RemoteImageLoadingState.swift +++ b/Sources/URLImage/RemoteImage/RemoteImageLoadingState.swift @@ -28,6 +28,24 @@ public enum RemoteImageLoadingState { case failure(_ error: Error) } +@available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) +extension RemoteImageLoadingState: Equatable { + public static func == (lhs: RemoteImageLoadingState, rhs: RemoteImageLoadingState) -> Bool { + switch (lhs, rhs) { + case (.initial, .initial): + return true + case (.inProgress(let lp), .inProgress(let rp)): + return lp == rp + case (.success(let lv), .success(let rv)): + return lv.image == rv.image + case (.failure(_), .failure(_)): + return true + default: + return false + } + } +} + @available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) public extension RemoteImageLoadingState { diff --git a/Sources/URLImage/Service/URLImageService+RemoteImage.swift b/Sources/URLImage/Service/URLImageService+RemoteImage.swift index 5c36f81..c6331f6 100644 --- a/Sources/URLImage/Service/URLImageService+RemoteImage.swift +++ b/Sources/URLImage/Service/URLImageService+RemoteImage.swift @@ -47,13 +47,14 @@ extension URLImageService { } private var cancellable: AnyCancellable? + private var task: Task? func request(_ demand: Subscribers.Demand) { guard demand > 0 else { return } - cancellable = remoteImage.$loadingState.sink(receiveValue: { [weak self] loadingState in + cancellable = remoteImage.loadingState.sink(receiveValue: { [weak self] loadingState in guard let self = self else { return } @@ -74,12 +75,20 @@ extension URLImageService { } }) - remoteImage.load() + task = Task { + await withTaskCancellationHandler { + await remoteImage.load() + } onCancel: { + remoteImage.cancel() + } + } } func cancel() { remoteImage.cancel() + task?.cancel() cancellable = nil + task = nil } } diff --git a/Sources/URLImage/Store/Common/URLImageStoreInfo.swift b/Sources/URLImage/Store/Common/URLImageStoreInfo.swift index f159e44..ad6fe99 100644 --- a/Sources/URLImage/Store/Common/URLImageStoreInfo.swift +++ b/Sources/URLImage/Store/Common/URLImageStoreInfo.swift @@ -19,4 +19,10 @@ public struct URLImageStoreInfo { /// The uniform type identifier (UTI) of the image. public var uti: String + + public init(url: URL, identifier: String? = nil, uti: String) { + self.url = url + self.identifier = identifier + self.uti = uti + } } diff --git a/Sources/URLImage/Store/URLImageFileStoreType+Combine.swift b/Sources/URLImage/Store/URLImageFileStoreType+Combine.swift index 8af4037..88aab10 100644 --- a/Sources/URLImage/Store/URLImageFileStoreType+Combine.swift +++ b/Sources/URLImage/Store/URLImageFileStoreType+Combine.swift @@ -28,4 +28,23 @@ extension URLImageFileStoreType { } }.eraseToAnyPublisher() } + + func getImage(_ keys: [URLImageKey], maxPixelSize: CGSize?) async throws -> TransientImage? { + try await withCheckedThrowingContinuation { continuation in + getImage(keys) { location -> TransientImage in + guard let transientImage = TransientImage(location: location, maxPixelSize: maxPixelSize) else { + throw URLImageError.decode + } + + return transientImage + } completion: { result in + switch result { + case .success(let image): + continuation.resume(returning: image) + case .failure(let error): + continuation.resume(throwing: error) + } + } + } + } } diff --git a/Sources/URLImage/URLImage.swift b/Sources/URLImage/URLImage.swift index 9bb8261..f627947 100644 --- a/Sources/URLImage/URLImage.swift +++ b/Sources/URLImage/URLImage.swift @@ -288,16 +288,41 @@ public extension URLImage where InProgress == Content, @available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) struct InstalledRemoteView: View { var service: URLImageService - var remoteImge: RemoteImage var content: (RemoteImage) -> Content + var url: URL + var identifier: String? + var options: URLImageOptions + @State private var remoteImage: RemoteImage? init(service: URLImageService, url: URL, identifier: String?, options: URLImageOptions, @ViewBuilder content: @escaping (RemoteImage) -> Content) { self.service = service - self.remoteImge = service.makeRemoteImage(url: url, identifier: identifier, options: options) self.content = content + self.url = url + self.identifier = identifier + self.options = options } var body: some View { - content(remoteImge) + if let remoteImge = remoteImage { + content(remoteImge) + } else { + Color.clear.backport.task { + await inital() + } + } + } + + private func inital() async { + let image = service.makeRemoteImage(url: url, identifier: identifier, options: options) + remoteImage = image + if options.loadOptions.contains(.loadImmediately) { + await withTaskCancellationHandler { + await image.load() + } onCancel: { + image.cancel() + } + } } } + + diff --git a/Sources/URLImage/URLImageOptions.swift b/Sources/URLImage/URLImageOptions.swift index 0b28d72..9205af5 100644 --- a/Sources/URLImage/URLImageOptions.swift +++ b/Sources/URLImage/URLImageOptions.swift @@ -55,15 +55,19 @@ public struct URLImageOptions { /// Maximum size of a decoded image in pixels. If this property is not specified, the width and height of a decoded is not limited and may be as big as the image itself. public var maxPixelSize: CGSize? + + public var loadingAnimated: Bool public init(fetchPolicy: FetchPolicy = .returnStoreElseLoad(downloadDelay: 0.25), loadOptions: LoadOptions = [ .loadImmediately ], + loadingAnimated: Bool = true, urlRequestConfiguration: Download.URLRequestConfiguration? = nil, maxPixelSize: CGSize? = nil) { self.fetchPolicy = fetchPolicy self.loadOptions = loadOptions self.urlRequestConfiguration = urlRequestConfiguration self.maxPixelSize = maxPixelSize + self.loadingAnimated = loadingAnimated } } @@ -75,6 +79,7 @@ extension URLImageOptions: Hashable { hasher.combine(fetchPolicy) hasher.combine(loadOptions) hasher.combine(urlRequestConfiguration) + hasher.combine(loadingAnimated) if let maxPixelSize = maxPixelSize { hasher.combine(maxPixelSize.width) diff --git a/Sources/URLImage/Views/RemoteGIFImageView.swift b/Sources/URLImage/Views/RemoteGIFImageView.swift index 93d8b1e..69cc2a9 100644 --- a/Sources/URLImage/Views/RemoteGIFImageView.swift +++ b/Sources/URLImage/Views/RemoteGIFImageView.swift @@ -40,21 +40,21 @@ struct RemoteGIFImageView : View where Empt self.failure = failure self.content = content - if loadOptions.contains(.loadImmediately) { - remoteImage.load() - } +// if loadOptions.contains(.loadImmediately) { +// remoteImage.load() +// } } var body: some View { ZStack { - switch remoteImage.loadingState { + switch remoteImage.slowLoadingState { case .initial: empty() case .inProgress(let progress): inProgress(progress) - case .success(let next): + case .success(_): if let image = image { content( GIFImageView(image: image) @@ -63,14 +63,12 @@ struct RemoteGIFImageView : View where Empt inProgress(1.0) } case .failure(let error): - failure(error) { - remoteImage.load() - } + failure(error, loadRemoteImage) } } .onAppear { if loadOptions.contains(.loadOnAppear) { - remoteImage.load() + loadRemoteImage() } } .onDisappear { @@ -78,17 +76,35 @@ struct RemoteGIFImageView : View where Empt remoteImage.cancel() } } - .onReceive(remoteImage.$loadingState.receive(on: DispatchQueue.main), perform: { newValue in - prepare(newValue) + .onReceive(remoteImage.$slowLoadingState.receive(on: DispatchQueue.main), perform: { newValue in + Task { + await prepare(newValue) + } }) .task { - prepare(remoteImage.loadingState) + await prepare(remoteImage.slowLoadingState) + } + } + + private func prepare(_ state: RemoteImage.LoadingState) async { + if case .success(_) = state { + if let image = await loadMemoryStore(options.maxPixelSize) { + self.image = image + return + } + await load(options.maxPixelSize) } } - private func prepare(_ state: RemoteImage.LoadingState) { - if case .success(let next) = state { - load(options.maxPixelSize) + private func loadRemoteImage() { + Task { + await withTaskCancellationHandler(operation: { + await remoteImage.load() + }, onCancel: { + Task { + await remoteImage.cancel() + } + }) } } @@ -109,25 +125,37 @@ struct RemoteGIFImageView : View where Empt //#endif // } - private func load(_ maxPixelSize: CGSize?) { + private func load(_ maxPixelSize: CGSize?) async { guard let fileStore = urlImageService.fileStore else { print("fileStore missing") return } - Task { - do { - for try await value in fileStore.getImagePublisher([.url(remoteImage.download.url)], maxPixelSize: maxPixelSize).values { - guard let value = value else { - continue - } - let data = await gif(value) - image = data + do { + for try await value in fileStore.getImagePublisher([.url(remoteImage.download.url)], maxPixelSize: maxPixelSize).values { + guard let value = value else { + continue } - } catch { - print("retrive image with \(remoteImage.download.url) failed. \(error)") + let data = await gif(value) + image = data } + } catch { + print("retrive image with \(remoteImage.download.url) failed. \(error)") + } + } + + private func loadMemoryStore(_ maxPixelSize: CGSize?) async -> PlatformImage? { + guard let memoryStore = urlImageService.inMemoryStore else { + print("memory store missing") + return nil } + + guard let value: TransientImage = memoryStore.getImage([.url(remoteImage.download.url)]) else { + print("\(remoteImage.download.url) not cached in memory store") + return nil + } + + return await gif(value) } } diff --git a/Sources/URLImage/Views/RemoteImageView.swift b/Sources/URLImage/Views/RemoteImageView.swift index aa8c93d..44fa363 100644 --- a/Sources/URLImage/Views/RemoteImageView.swift +++ b/Sources/URLImage/Views/RemoteImageView.swift @@ -15,7 +15,8 @@ struct RemoteImageView : View where Empty : InProgress : View, Failure : View, Content : View { - @ObservedObject private(set) var remoteImage: RemoteImage + @ObservedObject var remoteImage: RemoteImage + @Environment(\.urlImageOptions) var urlImageOptions let loadOptions: URLImageOptions.LoadOptions @@ -23,6 +24,9 @@ struct RemoteImageView : View where Empty : let inProgress: (_ progress: Float?) -> InProgress let failure: (_ error: Error, _ retry: @escaping () -> Void) -> Failure let content: (_ value: TransientImage) -> Content + + @Namespace var namespace + @State var animateState: RemoteImageLoadingState = .initial init(remoteImage: RemoteImage, loadOptions: URLImageOptions.LoadOptions, @@ -39,31 +43,33 @@ struct RemoteImageView : View where Empty : self.failure = failure self.content = content - if loadOptions.contains(.loadImmediately), !remoteImage.loadingState.isSuccess { - remoteImage.load() - } +// if loadOptions.contains(.loadImmediately), !remoteImage.loadingState.isSuccess { +// remoteImage.load() +// } } - + var body: some View { ZStack { - switch remoteImage.loadingState { - case .initial: - empty() - - case .inProgress(let progress): - inProgress(progress) - - case .success(let value): - content(value) - case .failure(let error): - failure(error) { - remoteImage.load() - } + switch animateState { + case .initial: + empty() + .matchedGeometryEffect(id: remoteImage.download.url, in: namespace) + case .inProgress(let progress): + inProgress(progress) + .matchedGeometryEffect(id: remoteImage.download.url, in: namespace) + case .success(let value): + content(value) + .matchedGeometryEffect(id: remoteImage.download.url, in: namespace) + case .failure(let error): + failure(error) { + loadRemoteImage() + } + .matchedGeometryEffect(id: remoteImage.download.url, in: namespace) } } .onAppear { - if loadOptions.contains(.loadOnAppear), !remoteImage.loadingState.isSuccess { - remoteImage.load() + if loadOptions.contains(.loadOnAppear), !remoteImage.slowLoadingState.isSuccess { + loadRemoteImage() } } .onDisappear { @@ -71,5 +77,27 @@ struct RemoteImageView : View where Empty : remoteImage.cancel() } } + .onReceive(remoteImage.$slowLoadingState) { newValue in + guard urlImageOptions.loadingAnimated else { + animateState = newValue + return + } + + withAnimation(.smooth) { + animateState = newValue + } + } + } + + private func loadRemoteImage() { + Task { + await withTaskCancellationHandler(operation: { + await remoteImage.load() + }, onCancel: { + Task { + await remoteImage.cancel() + } + }) + } } } From 7ec4a33fad49c43b6cdd1bfb4e74522d4610ae8c Mon Sep 17 00:00:00 2001 From: johncorner Date: Tue, 24 Sep 2024 10:55:42 -0400 Subject: [PATCH 26/27] swift 6 error --- .../DownloadManager/Download/Download.swift | 8 +++---- .../Download/DownloadPublisher.swift | 2 +- .../Download/DownloadTask.swift | 4 ++-- .../Download/DownloadTypes.swift | 12 +++++----- .../DownloadManager/DownloadManager.swift | 23 +++++++++++-------- .../URLSession/URLSessionCoordinator.swift | 4 ++-- .../URLSession/URLSessionDelegate.swift | 8 +++---- .../Sources/ImageDecoder/ImageDecoder.swift | 8 +++---- Dependencies/Sources/Model/ImageInfo.swift | 2 +- .../Sources/Model/TransientImage.swift | 4 ++-- Package.swift | 2 +- Sources/URLImage/Common/Backport.swift | 1 + Sources/URLImage/Common/Synchronized.swift | 2 +- .../URLImage/EnvironmentValues+URLImage.swift | 8 +++---- .../URLImage/RemoteImage/RemoteImage.swift | 2 +- .../Service/URLImageService+RemoteImage.swift | 2 +- .../URLImage/Service/URLImageService.swift | 2 +- Sources/URLImage/URLImageOptions.swift | 2 +- Sources/URLImage/Views/GIFImage.swift | 8 +++---- 19 files changed, 54 insertions(+), 50 deletions(-) diff --git a/Dependencies/Sources/DownloadManager/Download/Download.swift b/Dependencies/Sources/DownloadManager/Download/Download.swift index 60a5afc..71dd3bf 100644 --- a/Dependencies/Sources/DownloadManager/Download/Download.swift +++ b/Dependencies/Sources/DownloadManager/Download/Download.swift @@ -11,13 +11,13 @@ import Foundation /** The `Download` object describes a single file download. */ -public struct Download { +public struct Download: Sendable { public var url: URL public var id: UUID - public enum Destination : Codable, Hashable { + public enum Destination : Codable, Hashable, Sendable { /// Download to a shared buffer in memory case inMemory @@ -70,7 +70,7 @@ public struct Download { public var destination: Destination - public struct DownloadPolicy : OptionSet, Codable, Hashable { + public struct DownloadPolicy : OptionSet, Codable, Hashable, Sendable { public let rawValue: Int @@ -84,7 +84,7 @@ public struct Download { public var downloadPolicy: DownloadPolicy - public struct URLRequestConfiguration : Hashable, Codable { + public struct URLRequestConfiguration : Hashable, Codable, Sendable { public var allHTTPHeaderFields: [String : String]? diff --git a/Dependencies/Sources/DownloadManager/Download/DownloadPublisher.swift b/Dependencies/Sources/DownloadManager/Download/DownloadPublisher.swift index ca2802c..16631cf 100644 --- a/Dependencies/Sources/DownloadManager/Download/DownloadPublisher.swift +++ b/Dependencies/Sources/DownloadManager/Download/DownloadPublisher.swift @@ -35,7 +35,7 @@ public struct DownloadPublisher: Publisher { @available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) -final class DownloadSubscription: Subscription +final class DownloadSubscription: Subscription, @unchecked Sendable where SubscriberType.Input == DownloadInfo, SubscriberType.Failure == DownloadError { diff --git a/Dependencies/Sources/DownloadManager/Download/DownloadTask.swift b/Dependencies/Sources/DownloadManager/Download/DownloadTask.swift index 4374de7..7e46e3c 100644 --- a/Dependencies/Sources/DownloadManager/Download/DownloadTask.swift +++ b/Dependencies/Sources/DownloadManager/Download/DownloadTask.swift @@ -9,9 +9,9 @@ import Foundation /// `DownloadTask` is a wrapper around `URLSessionTask` that accumulates received data in a memory buffer. -final class DownloadTask { +final class DownloadTask: @unchecked Sendable { - final class Observer { + final class Observer: @unchecked Sendable { private var receiveResponse: DownloadReceiveResponse? diff --git a/Dependencies/Sources/DownloadManager/Download/DownloadTypes.swift b/Dependencies/Sources/DownloadManager/Download/DownloadTypes.swift index 83f7bd5..42cc484 100644 --- a/Dependencies/Sources/DownloadManager/Download/DownloadTypes.swift +++ b/Dependencies/Sources/DownloadManager/Download/DownloadTypes.swift @@ -8,7 +8,7 @@ import Foundation -public enum DownloadResult { +public enum DownloadResult: Sendable { case data(_ data: Data) @@ -16,7 +16,7 @@ public enum DownloadResult { } -public enum DownloadInfo { +public enum DownloadInfo: Sendable { case progress(_ progress: Float?) @@ -28,7 +28,7 @@ extension DownloadResult : Hashable {} public typealias DownloadError = Error -public typealias DownloadReceiveResponse = (_ download: Download) -> Void -public typealias DownloadReceiveData = (_ download: Download, _ data: Data) -> Void -public typealias DownloadReportProgress = (_ download: Download, _ progress: Float?) -> Void -public typealias DownloadCompletion = (_ download: Download, _ result: Result) -> Void +public typealias DownloadReceiveResponse = @Sendable (_ download: Download) -> Void +public typealias DownloadReceiveData = @Sendable (_ download: Download, _ data: Data) -> Void +public typealias DownloadReportProgress = @Sendable (_ download: Download, _ progress: Float?) -> Void +public typealias DownloadCompletion = @Sendable (_ download: Download, _ result: Result) -> Void diff --git a/Dependencies/Sources/DownloadManager/DownloadManager.swift b/Dependencies/Sources/DownloadManager/DownloadManager.swift index cd069cc..6e18838 100644 --- a/Dependencies/Sources/DownloadManager/DownloadManager.swift +++ b/Dependencies/Sources/DownloadManager/DownloadManager.swift @@ -6,7 +6,7 @@ // import Foundation -import Combine +@preconcurrency import Combine import AsyncExtensions @available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) @@ -69,16 +69,19 @@ public final class DownloadManager { public func download(for download: Download) -> AsyncStream> { let coordinator = self.coordinator let publishers = self.publishers + let task: @Sendable (AsyncStream>.Continuation) async -> Void = { continuation in + let _ = await publishers.store(download, coordinator: coordinator, action: { result in + switch result { + case .success(let info): + continuation.yield(.success(info)) + case .failure(let error): + continuation.yield(with: .success(.failure(error))) + } + }) + } return AsyncStream { continuation in Task { - let _ = await publishers.store(download, coordinator: coordinator, action: { result in - switch result { - case .success(let info): - continuation.yield(.success(info)) - case .failure(let error): - continuation.yield(with: .success(.failure(error))) - } - }) + await task(continuation) } } } @@ -171,7 +174,7 @@ enum DownloadEventError: Error { } @available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) -actor PublishersHolder { +actor PublishersHolder: @unchecked Sendable { private var publishers: [URL: DownloadManager.DownloadTaskPublisher] = [:] private var cancellableHashTable = [UUID: AnyCancellable]() private var cancellables = [URL: Set]() diff --git a/Dependencies/Sources/DownloadManager/URLSession/URLSessionCoordinator.swift b/Dependencies/Sources/DownloadManager/URLSession/URLSessionCoordinator.swift index be5ac94..64a3b59 100644 --- a/Dependencies/Sources/DownloadManager/URLSession/URLSessionCoordinator.swift +++ b/Dependencies/Sources/DownloadManager/URLSession/URLSessionCoordinator.swift @@ -12,7 +12,7 @@ import Log /// `URLSessionCoordinator` manages `URLSession` instance and forwards callbacks to responding `DownloadController` instances. @available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) -final class URLSessionCoordinator { +final class URLSessionCoordinator: @unchecked Sendable { init(urlSessionConfiguration: URLSessionConfiguration) { let delegate = URLSessionDelegate() @@ -184,7 +184,7 @@ final class URLSessionCoordinator { private let serialQueue = DispatchQueue(label: "URLSessionCoordinator.serialQueue") - private func async(_ closure: @escaping () -> Void) { + private func async(_ closure: @Sendable @escaping () -> Void) { serialQueue.async(execute: closure) } diff --git a/Dependencies/Sources/DownloadManager/URLSession/URLSessionDelegate.swift b/Dependencies/Sources/DownloadManager/URLSession/URLSessionDelegate.swift index d400f56..da69252 100644 --- a/Dependencies/Sources/DownloadManager/URLSession/URLSessionDelegate.swift +++ b/Dependencies/Sources/DownloadManager/URLSession/URLSessionDelegate.swift @@ -9,12 +9,12 @@ import Foundation import Log -final class URLSessionDelegate : NSObject { +final class URLSessionDelegate : NSObject, @unchecked Sendable { // URLSessionTaskDelegate /// func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) - typealias TaskDidCompleteWithError = (_ task: URLSessionTask, _ error: Error?) -> Void + typealias TaskDidCompleteWithError = @Sendable (_ task: URLSessionTask, _ error: Error?) -> Void private var taskDidCompleteWithError: TaskDidCompleteWithError? @@ -27,7 +27,7 @@ final class URLSessionDelegate : NSObject { // URLSessionDataDelegate /// func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive response: URLResponse, completionHandler: @escaping (URLSession.ResponseDisposition) -> Void) - typealias DataTaskDidReceiveResponse = (_ task: URLSessionDataTask, _ response: URLResponse, _ completionHandler: @escaping (URLSession.ResponseDisposition) -> Void) -> Void + typealias DataTaskDidReceiveResponse = (_ task: URLSessionDataTask, _ response: URLResponse, _ completionHandler: @Sendable @escaping (URLSession.ResponseDisposition) -> Void) -> Void private var dataTaskDidReceiveResponse: DataTaskDidReceiveResponse? @@ -117,7 +117,7 @@ extension URLSessionDelegate : URLSessionTaskDelegate { extension URLSessionDelegate : URLSessionDataDelegate { - func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive response: URLResponse, completionHandler: @escaping (URLSession.ResponseDisposition) -> Void) { + func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive response: URLResponse, completionHandler: @Sendable @escaping (URLSession.ResponseDisposition) -> Void) { log_debug(self, #function, "\(String(describing: dataTask.originalRequest))", detail: log_detailed) dataTaskDidReceiveResponse?(dataTask, response, completionHandler) } diff --git a/Dependencies/Sources/ImageDecoder/ImageDecoder.swift b/Dependencies/Sources/ImageDecoder/ImageDecoder.swift index a5a97d1..4195cc7 100644 --- a/Dependencies/Sources/ImageDecoder/ImageDecoder.swift +++ b/Dependencies/Sources/ImageDecoder/ImageDecoder.swift @@ -6,7 +6,7 @@ // // ImageDecoder is based on ImageDecoderCG from WebCore https://trac.webkit.org/browser/webkit/trunk/Source/WebCore/platform/graphics/cg/ImageDecoderCG.cpp -import ImageIO +@preconcurrency import ImageIO import Foundation #if canImport(MobileCoreServices) @@ -19,7 +19,7 @@ import Cocoa @available(iOS 14.0, tvOS 14.0, macOS 11.0, watchOS 7.0, *) -public final class ImageDecoder { +public final class ImageDecoder: @unchecked Sendable { public struct DecodingOptions { @@ -273,11 +273,11 @@ public final class ImageDecoder { // MARK: - Private - private static let imageSourceOptions: [CFString: Any] = [ + nonisolated(unsafe) private static let imageSourceOptions: [CFString: Any] = [ kCGImageSourceShouldCache: true ] - private static let imageSourceAsyncOptions: [CFString: Any] = [ + nonisolated(unsafe) private static let imageSourceAsyncOptions: [CFString: Any] = [ kCGImageSourceShouldCacheImmediately: true, kCGImageSourceCreateThumbnailFromImageAlways: true ] diff --git a/Dependencies/Sources/Model/ImageInfo.swift b/Dependencies/Sources/Model/ImageInfo.swift index 5852572..25e8f68 100644 --- a/Dependencies/Sources/Model/ImageInfo.swift +++ b/Dependencies/Sources/Model/ImageInfo.swift @@ -9,7 +9,7 @@ import CoreGraphics @available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) -public struct ImageInfo { +public struct ImageInfo: Sendable { /// Decoded image public var cgImage: CGImage { diff --git a/Dependencies/Sources/Model/TransientImage.swift b/Dependencies/Sources/Model/TransientImage.swift index 412a4dc..b4a9161 100644 --- a/Dependencies/Sources/Model/TransientImage.swift +++ b/Dependencies/Sources/Model/TransientImage.swift @@ -13,7 +13,7 @@ import DownloadManager /// Temporary representation used after decoding an image from data or file on disk and before creating an image object for display. @available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) -public struct TransientImage { +public struct TransientImage: Sendable { public var cgImage: CGImage { proxy.cgImage @@ -42,7 +42,7 @@ public struct TransientImage { /// Proxy used to decode image lazily @available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) -public final class CGImageProxy { +public final class CGImageProxy: @unchecked Sendable { public let decoder: ImageDecoder diff --git a/Package.swift b/Package.swift index 1eed8f2..c3fec61 100644 --- a/Package.swift +++ b/Package.swift @@ -1,4 +1,4 @@ -// swift-tools-version:5.3 +// swift-tools-version:6.0 // The swift-tools-version declares the minimum version of Swift required to build this package. import PackageDescription diff --git a/Sources/URLImage/Common/Backport.swift b/Sources/URLImage/Common/Backport.swift index 39b16c7..0a1862f 100644 --- a/Sources/URLImage/Common/Backport.swift +++ b/Sources/URLImage/Common/Backport.swift @@ -20,6 +20,7 @@ extension View { extension Backport { @inlinable + @MainActor @ViewBuilder func task(priority: TaskPriority = .userInitiated, _ action: @escaping @Sendable () async -> Void) -> some View { if #available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) { diff --git a/Sources/URLImage/Common/Synchronized.swift b/Sources/URLImage/Common/Synchronized.swift index 3b6bb36..6a0ad5e 100644 --- a/Sources/URLImage/Common/Synchronized.swift +++ b/Sources/URLImage/Common/Synchronized.swift @@ -12,7 +12,7 @@ import Foundation /// /// `Synchronized` provides getter/setter synchronization, thread safety of the wrapped value is up to its implementation. @propertyWrapper -public final class Synchronized { +public final class Synchronized: @unchecked Sendable { public var wrappedValue: Value { get { diff --git a/Sources/URLImage/EnvironmentValues+URLImage.swift b/Sources/URLImage/EnvironmentValues+URLImage.swift index 1486577..8af3755 100644 --- a/Sources/URLImage/EnvironmentValues+URLImage.swift +++ b/Sources/URLImage/EnvironmentValues+URLImage.swift @@ -9,16 +9,16 @@ import SwiftUI @available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) -private struct URLImageServiceEnvironmentKey: EnvironmentKey { +private struct URLImageServiceEnvironmentKey: @preconcurrency EnvironmentKey { - static let defaultValue: URLImageService = URLImageService() + @MainActor static let defaultValue: URLImageService = URLImageService() } @available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) -private struct URLImageOptionsEnvironmentKey: EnvironmentKey { +private struct URLImageOptionsEnvironmentKey: @preconcurrency EnvironmentKey { - static let defaultValue: URLImageOptions = URLImageOptions() + @MainActor static let defaultValue: URLImageOptions = URLImageOptions() } diff --git a/Sources/URLImage/RemoteImage/RemoteImage.swift b/Sources/URLImage/RemoteImage/RemoteImage.swift index f568732..48adfe2 100644 --- a/Sources/URLImage/RemoteImage/RemoteImage.swift +++ b/Sources/URLImage/RemoteImage/RemoteImage.swift @@ -20,7 +20,7 @@ final class RemoteImageWrapper: ObservableObject { } @available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) -public final class RemoteImage : ObservableObject { +public final class RemoteImage : ObservableObject, @unchecked Sendable { /// Reference to URLImageService used to download and store the image. unowned let service: URLImageService diff --git a/Sources/URLImage/Service/URLImageService+RemoteImage.swift b/Sources/URLImage/Service/URLImageService+RemoteImage.swift index c6331f6..50579d5 100644 --- a/Sources/URLImage/Service/URLImageService+RemoteImage.swift +++ b/Sources/URLImage/Service/URLImageService+RemoteImage.swift @@ -34,7 +34,7 @@ extension URLImageService { } } - final class RemoteImageSubscription: Subscription where SubscriberType.Input == ImageInfo, + final class RemoteImageSubscription: Subscription, @unchecked Sendable where SubscriberType.Input == ImageInfo, SubscriberType.Failure == Error { private var subscriber: SubscriberType? diff --git a/Sources/URLImage/Service/URLImageService.swift b/Sources/URLImage/Service/URLImageService.swift index 8d81bec..28c3cad 100644 --- a/Sources/URLImage/Service/URLImageService.swift +++ b/Sources/URLImage/Service/URLImageService.swift @@ -13,7 +13,7 @@ import DownloadManager @available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) -public class URLImageService { +public class URLImageService: @unchecked Sendable { public init(fileStore: URLImageFileStoreType? = nil, inMemoryStore: URLImageInMemoryStoreType? = nil) { self.fileStore = fileStore diff --git a/Sources/URLImage/URLImageOptions.swift b/Sources/URLImage/URLImageOptions.swift index 9205af5..331698b 100644 --- a/Sources/URLImage/URLImageOptions.swift +++ b/Sources/URLImage/URLImageOptions.swift @@ -27,7 +27,7 @@ public struct URLImageOptions { } /// Controls some aspects of download process - public struct LoadOptions: OptionSet, Hashable { + public struct LoadOptions: OptionSet, Hashable, Sendable { public let rawValue: Int diff --git a/Sources/URLImage/Views/GIFImage.swift b/Sources/URLImage/Views/GIFImage.swift index 5924f12..eb75ce0 100644 --- a/Sources/URLImage/Views/GIFImage.swift +++ b/Sources/URLImage/Views/GIFImage.swift @@ -162,7 +162,7 @@ public struct Scenes { } #if os(iOS) || os(watchOS) - func keyScreen() -> UIScreen { + @MainActor func keyScreen() -> UIScreen { UIApplication.shared.connectedScenes .compactMap({ $0 as? UIWindowScene }) .first(where: { @@ -170,7 +170,7 @@ public struct Scenes { })?.screen ?? UIScreen.main } - public func nativeScale() -> CGFloat { + @MainActor public func nativeScale() -> CGFloat { keyScreen().nativeScale } #elseif os(macOS) @@ -203,8 +203,8 @@ struct ImageConfigures { } } -struct ImageConfiguresEnvironmentKey: EnvironmentKey { - static var defaultValue = ImageConfigures(aspectRatio: nil, contentMode: .fit, resizeble: false) +struct ImageConfiguresEnvironmentKey: @preconcurrency EnvironmentKey { + @MainActor static var defaultValue = ImageConfigures(aspectRatio: nil, contentMode: .fit, resizeble: false) } extension EnvironmentValues { From 9b550963329282f70ac6ea321ae53c82c82f672e Mon Sep 17 00:00:00 2001 From: johncorner Date: Mon, 30 Sep 2024 22:51:43 -0400 Subject: [PATCH 27/27] fix swift concurency bug --- .../Download/DownloadPublisher.swift | 33 +++--- .../DownloadManager/DownloadManager.swift | 25 ++-- Dependencies/Sources/FileIndex/File.swift | 2 +- .../Sources/FileIndex/FileIndex.swift | 4 +- .../Sources/ImageDecoder/ImageDecoder.swift | 21 ++-- Dependencies/Sources/Model/ImageInfo.swift | 1 + .../Model/TransientImage+ImageDecoder.swift | 4 + .../Sources/Model/TransientImage.swift | 8 +- .../CoreDataAttributeDescription.swift | 2 +- .../CoreDataEntityDescription.swift | 2 +- .../CoreDataFetchIndexDescription.swift | 6 +- .../CoreDataModelDescription.swift | 2 +- .../Sources/PlainDatabase/Database.swift | 4 +- .../Sources/PlainDatabase/PlainDatabase.swift | 4 +- .../Common/TransientImage+SwiftUI.swift | 1 + Sources/URLImage/Common/URLImageKey.swift | 2 +- .../URLImage/EnvironmentValues+URLImage.swift | 35 +----- .../URLImage/RemoteImage/RemoteImage.swift | 97 +++++++++------- .../RemoteImage/RemoteImageLoadingState.swift | 6 +- .../Service/URLImageService+Decode.swift | 2 +- .../Service/URLImageService+RemoteImage.swift | 108 +++++++++++++----- .../Store/Common/URLImageStoreInfo.swift | 2 +- .../Store/URLImageFileStoreType+Combine.swift | 5 +- .../Store/URLImageFileStoreType.swift | 4 +- .../Store/URLImageInMemoryStoreType.swift | 3 +- Sources/URLImage/URLImage.swift | 6 +- Sources/URLImage/URLImageOptions.swift | 4 +- Sources/URLImage/Views/GIFImage.swift | 16 +-- .../URLImage/Views/RemoteGIFImageView.swift | 63 +++++----- Sources/URLImage/Views/RemoteImageView.swift | 9 +- Sources/URLImageStore/URLImageFileStore.swift | 71 ++++++------ 31 files changed, 284 insertions(+), 268 deletions(-) diff --git a/Dependencies/Sources/DownloadManager/Download/DownloadPublisher.swift b/Dependencies/Sources/DownloadManager/Download/DownloadPublisher.swift index 16631cf..e03900c 100644 --- a/Dependencies/Sources/DownloadManager/Download/DownloadPublisher.swift +++ b/Dependencies/Sources/DownloadManager/Download/DownloadPublisher.swift @@ -35,9 +35,7 @@ public struct DownloadPublisher: Publisher { @available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) -final class DownloadSubscription: Subscription, @unchecked Sendable - where SubscriberType.Input == DownloadInfo, - SubscriberType.Failure == DownloadError +final class DownloadSubscription: Subscription where SubscriberType.Input == DownloadInfo, SubscriberType.Failure == DownloadError { private var subscriber: SubscriberType? @@ -55,39 +53,34 @@ final class DownloadSubscription: Subscription, @unc guard demand > 0 else { return } log_debug(self, #function, "download.id = \(download.id), download.url = \(self.download.url)", detail: log_detailed) + nonisolated(unsafe) let subscriber = subscriber + let download = download + let type = Self.self coordinator.startDownload(download, receiveResponse: { _ in }, receiveData: { _, _ in }, - reportProgress: { [weak self] _, progress in - guard let self = self else { - return - } - - let _ = self.subscriber?.receive(.progress(progress)) + reportProgress: { _, progress in + let _ = subscriber?.receive(.progress(progress)) }, - completion: { [weak self] _, result in - guard let self = self else { - return - } - + completion: { _, result in switch result { case .success(let downloadResult): switch downloadResult { case .data(let data): - log_debug(self, #function, "download.id = \(self.download.id), download.url = \(self.download.url), downloaded \(data.count) bytes", detail: log_detailed) + log_debug(type, #function, "download.id = \(download.id), download.url = \(download.url), downloaded \(data.count) bytes", detail: log_detailed) case .file(let path): - log_debug(self, #function, "download.id = \(self.download.id), download.url = \(self.download.url), downloaded file to \(path)", detail: log_detailed) + log_debug(type, #function, "download.id = \(download.id), download.url = \(download.url), downloaded file to \(path)", detail: log_detailed) } - let _ = self.subscriber?.receive(.completion(downloadResult)) - self.subscriber?.receive(completion: .finished) + let _ = subscriber?.receive(.completion(downloadResult)) + subscriber?.receive(completion: .finished) case .failure(let error): - log_debug(self, #function, "download.id = \(self.download.id), download.url = \(self.download.url), downloaded failed \(error)", detail: log_detailed) - self.subscriber?.receive(completion: .failure(error)) + log_debug(type, #function, "download.id = \(download.id), download.url = \(download.url), downloaded failed \(error)", detail: log_detailed) + subscriber?.receive(completion: .failure(error)) } // self.manager.reset(download: self.download) diff --git a/Dependencies/Sources/DownloadManager/DownloadManager.swift b/Dependencies/Sources/DownloadManager/DownloadManager.swift index 6e18838..d540d48 100644 --- a/Dependencies/Sources/DownloadManager/DownloadManager.swift +++ b/Dependencies/Sources/DownloadManager/DownloadManager.swift @@ -69,19 +69,17 @@ public final class DownloadManager { public func download(for download: Download) -> AsyncStream> { let coordinator = self.coordinator let publishers = self.publishers - let task: @Sendable (AsyncStream>.Continuation) async -> Void = { continuation in - let _ = await publishers.store(download, coordinator: coordinator, action: { result in - switch result { - case .success(let info): - continuation.yield(.success(info)) - case .failure(let error): - continuation.yield(with: .success(.failure(error))) - } - }) - } return AsyncStream { continuation in Task { - await task(continuation) + let _ = await publishers.store(download, coordinator: coordinator, action: { result in + switch result { + case .success(let info): + print("yield \(info)") + continuation.yield(.success(info)) + case .failure(let error): + continuation.yield(with: .success(.failure(error))) + } + }) } } } @@ -174,7 +172,7 @@ enum DownloadEventError: Error { } @available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) -actor PublishersHolder: @unchecked Sendable { +actor PublishersHolder: Sendable { private var publishers: [URL: DownloadManager.DownloadTaskPublisher] = [:] private var cancellableHashTable = [UUID: AnyCancellable]() private var cancellables = [URL: Set]() @@ -185,7 +183,7 @@ actor PublishersHolder: @unchecked Sendable { subject.finish() } - func store(_ download: Download, coordinator: URLSessionCoordinator, action: @escaping (Result) -> Void) -> DownloadManager.DownloadTaskPublisher { + func store(_ download: Download, coordinator: URLSessionCoordinator, action: @escaping (Result) -> Void) { if !loaded { loaded = true let subject = self.subject @@ -226,7 +224,6 @@ actor PublishersHolder: @unchecked Sendable { var uuids = cancellables[download.url] ?? [] uuids.insert(uuid) cancellables[download.url] = uuids - return publisher } // func remove(_ download: Download) { diff --git a/Dependencies/Sources/FileIndex/File.swift b/Dependencies/Sources/FileIndex/File.swift index 9aeec72..42e90bf 100644 --- a/Dependencies/Sources/FileIndex/File.swift +++ b/Dependencies/Sources/FileIndex/File.swift @@ -11,7 +11,7 @@ import PlainDatabase @available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) -public struct File : Identifiable { +public struct File : Identifiable, Sendable { public var id: String diff --git a/Dependencies/Sources/FileIndex/FileIndex.swift b/Dependencies/Sources/FileIndex/FileIndex.swift index a212c0e..8bc3fd1 100644 --- a/Dependencies/Sources/FileIndex/FileIndex.swift +++ b/Dependencies/Sources/FileIndex/FileIndex.swift @@ -13,9 +13,9 @@ import Model @available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) -public class FileIndex { +public final class FileIndex: Sendable { - public struct Configuration { + public struct Configuration: Sendable { /// URL of the directory to keep file index, typically `~/Library/Caches/
` public var directoryURL: URL diff --git a/Dependencies/Sources/ImageDecoder/ImageDecoder.swift b/Dependencies/Sources/ImageDecoder/ImageDecoder.swift index 4195cc7..33d44cf 100644 --- a/Dependencies/Sources/ImageDecoder/ImageDecoder.swift +++ b/Dependencies/Sources/ImageDecoder/ImageDecoder.swift @@ -17,9 +17,8 @@ import MobileCoreServices import Cocoa #endif - @available(iOS 14.0, tvOS 14.0, macOS 11.0, watchOS 7.0, *) -public final class ImageDecoder: @unchecked Sendable { +public final class ImageDecoder: Sendable { public struct DecodingOptions { @@ -91,18 +90,18 @@ public final class ImageDecoder: @unchecked Sendable { setDataProvider(dataProvider, allDataReceived: true) } - public private(set) var isAllDataReceived: Bool = false - +// public var isAllDataReceived: Bool = false + public func setData(_ data: Data, allDataReceived: Bool) { - assert(!isAllDataReceived) - - isAllDataReceived = allDataReceived +// assert(!isAllDataReceived) +// +// isAllDataReceived = allDataReceived CGImageSourceUpdateData(imageSource, data as CFData, allDataReceived) } public func setDataProvider(_ dataProvider: CGDataProvider, allDataReceived: Bool) { - assert(!isAllDataReceived) - isAllDataReceived = allDataReceived +// assert(!isAllDataReceived) +// isAllDataReceived = allDataReceived CGImageSourceUpdateDataProvider(imageSource, dataProvider, allDataReceived) } @@ -125,8 +124,8 @@ public final class ImageDecoder: @unchecked Sendable { fallthrough case .statusReadingHeader: // Ragnaros yells: TOO SOON! You have awakened me TOO SOON, Executus! - return isAllDataReceived ? .error : .unknown - +// return isAllDataReceived ? .error : .unknown + return .error case .statusIncomplete: // WebCore checks isSupportedImageType here and returns error if not: // if (!isSupportedImageType(uti)) diff --git a/Dependencies/Sources/Model/ImageInfo.swift b/Dependencies/Sources/Model/ImageInfo.swift index 25e8f68..af07e15 100644 --- a/Dependencies/Sources/Model/ImageInfo.swift +++ b/Dependencies/Sources/Model/ImageInfo.swift @@ -12,6 +12,7 @@ import CoreGraphics public struct ImageInfo: Sendable { /// Decoded image + @MainActor public var cgImage: CGImage { proxy.cgImage } diff --git a/Dependencies/Sources/Model/TransientImage+ImageDecoder.swift b/Dependencies/Sources/Model/TransientImage+ImageDecoder.swift index fdae2e5..9ebe35e 100644 --- a/Dependencies/Sources/Model/TransientImage+ImageDecoder.swift +++ b/Dependencies/Sources/Model/TransientImage+ImageDecoder.swift @@ -14,6 +14,10 @@ import DownloadManager @available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) public extension TransientImage { + + static func create(data: Data, maxPixelSize: CGSize?) async -> Self? { + Self.init(data: data, maxPixelSize: maxPixelSize) + } init?(data: Data, maxPixelSize: CGSize?) { let decoder = ImageDecoder() diff --git a/Dependencies/Sources/Model/TransientImage.swift b/Dependencies/Sources/Model/TransientImage.swift index b4a9161..1e74d5a 100644 --- a/Dependencies/Sources/Model/TransientImage.swift +++ b/Dependencies/Sources/Model/TransientImage.swift @@ -15,7 +15,7 @@ import DownloadManager @available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) public struct TransientImage: Sendable { - public var cgImage: CGImage { + @MainActor public var cgImage: CGImage { proxy.cgImage } @@ -42,7 +42,7 @@ public struct TransientImage: Sendable { /// Proxy used to decode image lazily @available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) -public final class CGImageProxy: @unchecked Sendable { +public final class CGImageProxy: Sendable { public let decoder: ImageDecoder @@ -60,8 +60,8 @@ public final class CGImageProxy: @unchecked Sendable { return decodedCGImage! } - - private var decodedCGImage: CGImage? + + nonisolated(unsafe) private var decodedCGImage: CGImage? private func decodeImage() { if let sizeForDrawing = maxPixelSize { diff --git a/Dependencies/Sources/PlainDatabase/CoreDataModel/CoreDataAttributeDescription.swift b/Dependencies/Sources/PlainDatabase/CoreDataModel/CoreDataAttributeDescription.swift index 322e9a5..c76d5c6 100644 --- a/Dependencies/Sources/PlainDatabase/CoreDataModel/CoreDataAttributeDescription.swift +++ b/Dependencies/Sources/PlainDatabase/CoreDataModel/CoreDataAttributeDescription.swift @@ -10,7 +10,7 @@ import CoreData /// Used to create `NSAttributeDescription` @available(iOS 14.0, tvOS 14.0, macOS 11.0, watchOS 7.0, *) -public struct CoreDataAttributeDescription { +public struct CoreDataAttributeDescription: Sendable { public static func attribute(name: String, type: NSAttributeType, isOptional: Bool = false) -> CoreDataAttributeDescription { CoreDataAttributeDescription(name: name, attributeType: type, isOptional: isOptional) diff --git a/Dependencies/Sources/PlainDatabase/CoreDataModel/CoreDataEntityDescription.swift b/Dependencies/Sources/PlainDatabase/CoreDataModel/CoreDataEntityDescription.swift index 095cecb..32f3c96 100644 --- a/Dependencies/Sources/PlainDatabase/CoreDataModel/CoreDataEntityDescription.swift +++ b/Dependencies/Sources/PlainDatabase/CoreDataModel/CoreDataEntityDescription.swift @@ -10,7 +10,7 @@ import CoreData /// Used to create `NSEntityDescription` @available(iOS 14.0, tvOS 14.0, macOS 11.0, watchOS 7.0, *) -public struct CoreDataEntityDescription { +public struct CoreDataEntityDescription: Sendable { public init(name: String, managedObjectClassName: String, attributes: [CoreDataAttributeDescription], indexes: [CoreDataFetchIndexDescription]) { self.name = name diff --git a/Dependencies/Sources/PlainDatabase/CoreDataModel/CoreDataFetchIndexDescription.swift b/Dependencies/Sources/PlainDatabase/CoreDataModel/CoreDataFetchIndexDescription.swift index b2b69a8..e680ebd 100644 --- a/Dependencies/Sources/PlainDatabase/CoreDataModel/CoreDataFetchIndexDescription.swift +++ b/Dependencies/Sources/PlainDatabase/CoreDataModel/CoreDataFetchIndexDescription.swift @@ -10,12 +10,12 @@ import CoreData /// Describes `NSFetchIndexDescription` @available(iOS 14.0, tvOS 14.0, macOS 11.0, watchOS 7.0, *) -public struct CoreDataFetchIndexDescription { +public struct CoreDataFetchIndexDescription: Sendable { /// Describes `NSFetchIndexElementDescription` - public struct Element { + public struct Element: Sendable { - public enum Property { + public enum Property: Sendable { case property(name: String) } diff --git a/Dependencies/Sources/PlainDatabase/CoreDataModel/CoreDataModelDescription.swift b/Dependencies/Sources/PlainDatabase/CoreDataModel/CoreDataModelDescription.swift index 8318841..257f213 100644 --- a/Dependencies/Sources/PlainDatabase/CoreDataModel/CoreDataModelDescription.swift +++ b/Dependencies/Sources/PlainDatabase/CoreDataModel/CoreDataModelDescription.swift @@ -10,7 +10,7 @@ import CoreData /// Used to create `NSManagedObjectModel`. @available(iOS 14.0, tvOS 14.0, macOS 11.0, watchOS 7.0, *) -public struct CoreDataModelDescription { +public struct CoreDataModelDescription: Sendable { public var entity: CoreDataEntityDescription diff --git a/Dependencies/Sources/PlainDatabase/Database.swift b/Dependencies/Sources/PlainDatabase/Database.swift index ed977bc..e09d953 100644 --- a/Dependencies/Sources/PlainDatabase/Database.swift +++ b/Dependencies/Sources/PlainDatabase/Database.swift @@ -6,11 +6,11 @@ // import Foundation -import CoreData +@preconcurrency import CoreData @available(iOS 14.0, tvOS 14.0, macOS 11.0, watchOS 7.0, *) -public final class Database { +public final class Database: Sendable { public struct Configuration { diff --git a/Dependencies/Sources/PlainDatabase/PlainDatabase.swift b/Dependencies/Sources/PlainDatabase/PlainDatabase.swift index 88fcc2c..0fff9f7 100644 --- a/Dependencies/Sources/PlainDatabase/PlainDatabase.swift +++ b/Dependencies/Sources/PlainDatabase/PlainDatabase.swift @@ -13,7 +13,7 @@ import CoreData public typealias PlainDatabaseObject = ManagedObjectCodable -public enum PredicateOperator { +public enum PredicateOperator: Sendable { case lessThan @@ -70,7 +70,7 @@ public enum PredicateOperator { A database that stores a plain list of same type objects. */ @available(iOS 14.0, tvOS 14.0, macOS 11.0, watchOS 7.0, *) -public final class PlainDatabase { +public final class PlainDatabase: Sendable { public init(configuration: Database.Configuration, modelDescription: CoreDataModelDescription) { database = Database(configuration: configuration, model: modelDescription.model) diff --git a/Sources/URLImage/Common/TransientImage+SwiftUI.swift b/Sources/URLImage/Common/TransientImage+SwiftUI.swift index a4dbb94..eba52dd 100644 --- a/Sources/URLImage/Common/TransientImage+SwiftUI.swift +++ b/Sources/URLImage/Common/TransientImage+SwiftUI.swift @@ -12,6 +12,7 @@ import Model @available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) public extension TransientImage { + @MainActor var image: Image { let orientation = Image.Orientation(cgOrientation) return Image(decorative: self.cgImage, scale: 1.0, orientation: orientation) diff --git a/Sources/URLImage/Common/URLImageKey.swift b/Sources/URLImage/Common/URLImageKey.swift index eaf8134..4cc7f5a 100644 --- a/Sources/URLImage/Common/URLImageKey.swift +++ b/Sources/URLImage/Common/URLImageKey.swift @@ -9,7 +9,7 @@ import Foundation /// Various key types used to access images. -public enum URLImageKey { +public enum URLImageKey: Sendable { /// Unique identifier as a string case identifier(_ identifier: String) diff --git a/Sources/URLImage/EnvironmentValues+URLImage.swift b/Sources/URLImage/EnvironmentValues+URLImage.swift index 8af3755..28b2ccf 100644 --- a/Sources/URLImage/EnvironmentValues+URLImage.swift +++ b/Sources/URLImage/EnvironmentValues+URLImage.swift @@ -7,43 +7,12 @@ import SwiftUI - -@available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) -private struct URLImageServiceEnvironmentKey: @preconcurrency EnvironmentKey { - - @MainActor static let defaultValue: URLImageService = URLImageService() -} - - -@available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) -private struct URLImageOptionsEnvironmentKey: @preconcurrency EnvironmentKey { - - @MainActor static let defaultValue: URLImageOptions = URLImageOptions() -} - - @available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) public extension EnvironmentValues { /// Service used by instances of the `URLImage` view - var urlImageService: URLImageService { - get { - self[URLImageServiceEnvironmentKey.self] - } - - set { - self[URLImageServiceEnvironmentKey.self] = newValue - } - } + @Entry var urlImageService: URLImageService = URLImageService() /// Options object used by instances of the `URLImage` view - var urlImageOptions: URLImageOptions { - get { - self[URLImageOptionsEnvironmentKey.self] - } - - set { - self[URLImageOptionsEnvironmentKey.self] = newValue - } - } + @Entry var urlImageOptions: URLImageOptions = URLImageOptions() } diff --git a/Sources/URLImage/RemoteImage/RemoteImage.swift b/Sources/URLImage/RemoteImage/RemoteImage.swift index 48adfe2..500bb78 100644 --- a/Sources/URLImage/RemoteImage/RemoteImage.swift +++ b/Sources/URLImage/RemoteImage/RemoteImage.swift @@ -5,14 +5,14 @@ // Created by Dmytro Anokhin on 25/08/2020. // -import SwiftUI -import Combine +@preconcurrency import SwiftUI +@preconcurrency import Combine import Model import DownloadManager import ImageDecoder import Log -let queue = DispatchQueue(label: "com.url.image.workitem") +fileprivate let queue = DispatchQueue(label: "com.url.image.workitem") @available(macOS 11.0, *) final class RemoteImageWrapper: ObservableObject { @@ -20,7 +20,8 @@ final class RemoteImageWrapper: ObservableObject { } @available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) -public final class RemoteImage : ObservableObject, @unchecked Sendable { +@MainActor +public final class RemoteImage : ObservableObject { /// Reference to URLImageService used to download and store the image. unowned let service: URLImageService @@ -32,26 +33,30 @@ public final class RemoteImage : ObservableObject, @unchecked Sendable { let options: URLImageOptions - var stateCancellable: AnyCancellable? - var downloadTask: Task? + nonisolated(unsafe) var stateCancellable: AnyCancellable? + nonisolated(unsafe) var downloadTask: Task? init(service: URLImageService, download: Download, identifier: String?, options: URLImageOptions) { self.service = service self.download = download self.identifier = identifier self.options = options + stateBind() + + log_debug(nil, #function, download.url.absoluteString) + + } + + private func stateBind() { self.stateCancellable = loadingStatePublisher .receive(on: DispatchQueue.main) .sink(receiveValue: { [weak self] state in - self?.slowLoadingState = state + self?.slowLoadingState.send(state) }) - - log_debug(nil, #function, download.url.absoluteString) - } deinit { - stateCancellable?.cancel() +// stateCancellable?.cancel() // downloadTask?.cancel() log_debug(nil, #function, download.url.absoluteString, detail: log_detailed) } @@ -65,14 +70,19 @@ public final class RemoteImage : ObservableObject, @unchecked Sendable { // } // } - private(set) var loadingState = CurrentValueSubject(.initial) - - @Published public private(set) var slowLoadingState: LoadingState = .initial + let loadingState = CurrentValueSubject(.initial) + let slowLoadingState = CurrentValueSubject(.initial) +// @Published public private(set) var slowLoadingState: LoadingState = .initial +// nonisolated(unsafe) public var slowLoadingState: AnyPublisher { +// loadingState +// .prepend(.initial) +// .eraseToAnyPublisher() +// } private var progressStatePublisher: AnyPublisher { loadingState .filter({ $0.isInProgress }) - .collect(.byTime(queue, .milliseconds(100))) + .collect(.byTime(DispatchQueue.main, .milliseconds(100))) .compactMap(\.last) .eraseToAnyPublisher() } @@ -170,43 +180,43 @@ public final class RemoteImage : ObservableObject, @unchecked Sendable { log_debug(self, #function, "Start load for: \(download.url)", detail: log_normal) - await updateIsLoading(true) + updateIsLoading(true) switch options.fetchPolicy { case .returnStoreElseLoad(let downloadDelay): guard !isLoadedSuccessfully else { // Already loaded - await updateIsLoading(false) + updateIsLoading(false) return } guard !loadFromInMemoryStore() else { // Loaded from the in-memory store - await updateIsLoading(false) + updateIsLoading(false) return } let success = await scheduleReturnStored(afterDelay: nil) if !success { - self.scheduleDownload(afterDelay: downloadDelay, secondStoreLookup: true) + await self.scheduleDownload(afterDelay: downloadDelay, secondStoreLookup: true) } case .returnStoreDontLoad: guard !isLoadedSuccessfully else { // Already loaded - await updateIsLoading(false) + updateIsLoading(false) return } guard !loadFromInMemoryStore() else { // Loaded from the in-memory store - await updateIsLoading(false) + updateIsLoading(false) return } let success = await scheduleReturnStored(afterDelay: nil) if !success { updateLoadingState(.initial) - await updateIsLoading(false) + updateIsLoading(false) } } } @@ -240,7 +250,6 @@ public final class RemoteImage : ObservableObject, @unchecked Sendable { /// Internal loading state private var isLoading: Bool = false - private var cancellables = Set() private var delayedReturnStored: DispatchWorkItem? private var delayedDownload: DispatchWorkItem? @@ -428,23 +437,27 @@ extension RemoteImage { updateLoadingState(.inProgress(nil)) let infos = service.downloadManager.download(for: download) - for await update in infos { - switch update { - case .success(let info): - switch info { - case .progress(let progress): - updateLoadingState(.inProgress(progress)) - case .completion(let result): - do { - let transientImage = try service.decode(result: result, - download: download, - identifier: identifier, - options: options) - updateLoadingState(.success(transientImage)) - } catch { - // This route happens when download succeeds, but decoding fails - updateLoadingState(.failure(error)) - } + let download = download + let identifier = identifier + let options = options + let service = service + for await info in infos { + switch info { + case .success(let success): + switch success { + case .progress(let progress): + updateLoadingState(.inProgress(progress)) + case .completion(let result): + do { + let transientImage = try await service.decode(result: result, + download: download, + identifier: identifier, + options: options) + updateLoadingState(.success(transientImage)) + } catch { + // This route happens when download succeeds, but decoding fails + updateLoadingState(.failure(error)) + } } case .failure(let error): updateLoadingState(.failure(error)) @@ -493,7 +506,6 @@ extension RemoteImage { // .store(in: &cancellables) // } - @MainActor private func returnStored() async -> Bool { loadingState.send(.inProgress(nil)) @@ -514,7 +526,7 @@ extension RemoteImage { service.inMemoryStore?.store(transientImage, info: info) // Complete - self.loadingState.send(.success(transientImage)) + loadingState.send(.success(transientImage)) return true } @@ -522,7 +534,6 @@ extension RemoteImage { self.loadingState.send(loadingState) } - @MainActor private func updateIsLoading(_ loading: Bool) { self.isLoading = loading } diff --git a/Sources/URLImage/RemoteImage/RemoteImageLoadingState.swift b/Sources/URLImage/RemoteImage/RemoteImageLoadingState.swift index 1f9245b..64d9c1e 100644 --- a/Sources/URLImage/RemoteImage/RemoteImageLoadingState.swift +++ b/Sources/URLImage/RemoteImage/RemoteImageLoadingState.swift @@ -17,7 +17,7 @@ import Model /// This dual purpose allows the view to use switch statement in its `body` and return different view in each case. /// @available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) -public enum RemoteImageLoadingState { +public enum RemoteImageLoadingState: Sendable { case initial @@ -29,8 +29,8 @@ public enum RemoteImageLoadingState { } @available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) -extension RemoteImageLoadingState: Equatable { - public static func == (lhs: RemoteImageLoadingState, rhs: RemoteImageLoadingState) -> Bool { +extension RemoteImageLoadingState: @preconcurrency Equatable { + @MainActor public static func == (lhs: RemoteImageLoadingState, rhs: RemoteImageLoadingState) -> Bool { switch (lhs, rhs) { case (.initial, .initial): return true diff --git a/Sources/URLImage/Service/URLImageService+Decode.swift b/Sources/URLImage/Service/URLImageService+Decode.swift index 2905af7..2f2b6ea 100644 --- a/Sources/URLImage/Service/URLImageService+Decode.swift +++ b/Sources/URLImage/Service/URLImageService+Decode.swift @@ -13,7 +13,7 @@ import DownloadManager @available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) extension URLImageService { - func decode(result: DownloadResult, download: Download, identifier: String?, options: URLImageOptions) throws -> TransientImage { + func decode(result: DownloadResult, download: Download, identifier: String?, options: URLImageOptions) async throws -> TransientImage { switch result { case .data(let data): diff --git a/Sources/URLImage/Service/URLImageService+RemoteImage.swift b/Sources/URLImage/Service/URLImageService+RemoteImage.swift index 50579d5..0a2bb14 100644 --- a/Sources/URLImage/Service/URLImageService+RemoteImage.swift +++ b/Sources/URLImage/Service/URLImageService+RemoteImage.swift @@ -6,10 +6,11 @@ // import Foundation -import Combine +@preconcurrency import Combine import Model import DownloadManager +extension Published.Publisher: @unchecked Sendable where Output: Sendable { } @available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) extension URLImageService { @@ -34,7 +35,7 @@ extension URLImageService { } } - final class RemoteImageSubscription: Subscription, @unchecked Sendable where SubscriberType.Input == ImageInfo, + final class RemoteImageSubscription: Subscription where SubscriberType.Input == ImageInfo, SubscriberType.Failure == Error { private var subscriber: SubscriberType? @@ -48,51 +49,102 @@ extension URLImageService { private var cancellable: AnyCancellable? private var task: Task? + private var task2: Task? func request(_ demand: Subscribers.Demand) { guard demand > 0 else { return } - - cancellable = remoteImage.loadingState.sink(receiveValue: { [weak self] loadingState in - guard let self = self else { - return - } - - switch loadingState { - case .initial: - break - - case .inProgress: - break - - case .success(let transientImage): - let _ = self.subscriber?.receive(transientImage.info) - self.subscriber?.receive(completion: .finished) - - case .failure(let error): - self.subscriber?.receive(completion: .failure(error)) + + let remote = remoteImage + nonisolated(unsafe) let subscriber = subscriber + + let operation: @Sendable () async -> Void = { + if #available(macOS 12.0, iOS 15, *) { + let state = await remote.loadingState + for await loadingState in state.values { + switch loadingState { + case .initial: + break + + case .inProgress: + break + + case .success(let transientImage): + let _ = subscriber?.receive(transientImage.info) + subscriber?.receive(completion: .finished) + + case .failure(let error): + subscriber?.receive(completion: .failure(error)) + } + } + } else { + // Fallback on earlier versions } - }) - +// await remote.loadingState.sink(receiveValue: { loadingState in +// switch loadingState { +// case .initial: +// break +// +// case .inProgress: +// break +// +// case .success(let transientImage): +// let _ = subscriber?.receive(transientImage.info) +// subscriber?.receive(completion: .finished) +// +// case .failure(let error): +// subscriber?.receive(completion: .failure(error)) +// } +// }) + } + + task2 = Task(operation: operation) +// cancellable = remote.loadingState.sink(receiveValue: { [weak self] loadingState in +// guard let self = self else { +// return +// } +// +// switch loadingState { +// case .initial: +// break +// +// case .inProgress: +// break +// +// case .success(let transientImage): +// let _ = self.subscriber?.receive(transientImage.info) +// self.subscriber?.receive(completion: .finished) +// +// case .failure(let error): +// self.subscriber?.receive(completion: .failure(error)) +// } +// }) + task = Task { await withTaskCancellationHandler { - await remoteImage.load() + await remote.load() } onCancel: { - remoteImage.cancel() + Task { + await remote.cancel() + } } } } func cancel() { - remoteImage.cancel() + let remote = remoteImage + Task { + await remote.cancel() + } task?.cancel() + task2?.cancel() cancellable = nil task = nil } } - public func makeRemoteImage(url: URL, identifier: String?, options: URLImageOptions) -> RemoteImage { + @MainActor public func makeRemoteImage(url: URL, identifier: String?, options: URLImageOptions) -> RemoteImage { let inMemory = fileStore == nil let destination = makeDownloadDestination(inMemory: inMemory) @@ -103,7 +155,7 @@ extension URLImageService { return RemoteImage(service: self, download: download, identifier: identifier, options: options) } - public func remoteImagePublisher(_ url: URL, identifier: String?, options: URLImageOptions = URLImageOptions()) -> RemoteImagePublisher { + @MainActor public func remoteImagePublisher(_ url: URL, identifier: String?, options: URLImageOptions = URLImageOptions()) -> RemoteImagePublisher { let remoteImage = makeRemoteImage(url: url, identifier: identifier, options: options) return RemoteImagePublisher(remoteImage: remoteImage) } diff --git a/Sources/URLImage/Store/Common/URLImageStoreInfo.swift b/Sources/URLImage/Store/Common/URLImageStoreInfo.swift index ad6fe99..b417f1f 100644 --- a/Sources/URLImage/Store/Common/URLImageStoreInfo.swift +++ b/Sources/URLImage/Store/Common/URLImageStoreInfo.swift @@ -9,7 +9,7 @@ import Foundation /// Information that describes an image in a store -public struct URLImageStoreInfo { +public struct URLImageStoreInfo: Sendable { /// Original URL of the image public var url: URL diff --git a/Sources/URLImage/Store/URLImageFileStoreType+Combine.swift b/Sources/URLImage/Store/URLImageFileStoreType+Combine.swift index 88aab10..776d1c2 100644 --- a/Sources/URLImage/Store/URLImageFileStoreType+Combine.swift +++ b/Sources/URLImage/Store/URLImageFileStoreType+Combine.swift @@ -16,15 +16,15 @@ extension URLImageFileStoreType { func getImagePublisher(_ keys: [URLImageKey], maxPixelSize: CGSize?) -> AnyPublisher { Future { promise in + nonisolated(unsafe) let promised = promise self.getImage(keys) { location -> TransientImage in guard let transientImage = TransientImage(location: location, maxPixelSize: maxPixelSize) else { throw URLImageError.decode } - return transientImage } completion: { result in - promise(result) + promised(result) } }.eraseToAnyPublisher() } @@ -35,7 +35,6 @@ extension URLImageFileStoreType { guard let transientImage = TransientImage(location: location, maxPixelSize: maxPixelSize) else { throw URLImageError.decode } - return transientImage } completion: { result in switch result { diff --git a/Sources/URLImage/Store/URLImageFileStoreType.swift b/Sources/URLImage/Store/URLImageFileStoreType.swift index 24eb055..bd6a97c 100644 --- a/Sources/URLImage/Store/URLImageFileStoreType.swift +++ b/Sources/URLImage/Store/URLImageFileStoreType.swift @@ -17,8 +17,8 @@ public protocol URLImageFileStoreType: URLImageStoreType { /// - keys: An array of keys used to lookup the image /// - open: A closure used to open the image file by delegating its decoding to the calling routine func getImage(_ keys: [URLImageKey], - open: @escaping (_ location: URL) throws -> T?, - completion: @escaping (_ result: Result) -> Void) + open: @Sendable @escaping (_ location: URL) throws -> T?, + completion: @Sendable @escaping (_ result: Result) -> Void) /// Write image data to the store. func storeImageData(_ data: Data, info: URLImageStoreInfo) diff --git a/Sources/URLImage/Store/URLImageInMemoryStoreType.swift b/Sources/URLImage/Store/URLImageInMemoryStoreType.swift index 45013de..e90ee85 100644 --- a/Sources/URLImage/Store/URLImageInMemoryStoreType.swift +++ b/Sources/URLImage/Store/URLImageInMemoryStoreType.swift @@ -21,12 +21,13 @@ public protocol URLImageInMemoryStoreType: URLImageStoreType { @available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) public extension URLImageInMemoryStoreType { - + @MainActor func getImage(_ identifier: String) -> CGImage? { let transientImage: TransientImage? = getImage([ .identifier(identifier) ]) return transientImage?.cgImage } + @MainActor func getImage(_ url: URL) -> CGImage? { let transientImage: TransientImage? = getImage([ .url(url) ]) return transientImage?.cgImage diff --git a/Sources/URLImage/URLImage.swift b/Sources/URLImage/URLImage.swift index f627947..b5f26cc 100644 --- a/Sources/URLImage/URLImage.swift +++ b/Sources/URLImage/URLImage.swift @@ -316,11 +316,7 @@ struct InstalledRemoteView: View { let image = service.makeRemoteImage(url: url, identifier: identifier, options: options) remoteImage = image if options.loadOptions.contains(.loadImmediately) { - await withTaskCancellationHandler { - await image.load() - } onCancel: { - image.cancel() - } + await image.load() } } } diff --git a/Sources/URLImage/URLImageOptions.swift b/Sources/URLImage/URLImageOptions.swift index 331698b..42b0c24 100644 --- a/Sources/URLImage/URLImageOptions.swift +++ b/Sources/URLImage/URLImageOptions.swift @@ -12,10 +12,10 @@ import DownloadManager /// Options to control how the image is downloaded and stored @available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) -public struct URLImageOptions { +public struct URLImageOptions: Sendable { /// The `FetchPolicy` allows to choose between returning stored image or downloading the remote one. - public enum FetchPolicy: Hashable { + public enum FetchPolicy: Hashable, Sendable { /// Return an image from the store or download it /// diff --git a/Sources/URLImage/Views/GIFImage.swift b/Sources/URLImage/Views/GIFImage.swift index eb75ce0..5b2a7e2 100644 --- a/Sources/URLImage/Views/GIFImage.swift +++ b/Sources/URLImage/Views/GIFImage.swift @@ -156,7 +156,7 @@ public final class UIGIFImage: PlatformView { } -public struct Scenes { +public struct Scenes: Sendable { public init() { } @@ -180,12 +180,12 @@ public struct Scenes { #endif } -public enum ContentMode { +public enum ContentMode: Sendable { case fit case fill } -struct ImageConfigures { +struct ImageConfigures: Sendable { var aspectRatio: CGFloat? var contentMode: ContentMode var resizeble: Bool @@ -203,18 +203,10 @@ struct ImageConfigures { } } -struct ImageConfiguresEnvironmentKey: @preconcurrency EnvironmentKey { - @MainActor static var defaultValue = ImageConfigures(aspectRatio: nil, contentMode: .fit, resizeble: false) -} - extension EnvironmentValues { - var imageConfigures: ImageConfigures { - get { self[ImageConfiguresEnvironmentKey.self] } - set { self[ImageConfiguresEnvironmentKey.self] = newValue } - } + @Entry var imageConfigures: ImageConfigures = ImageConfigures(aspectRatio: nil, contentMode: .fit, resizeble: false) } - //@available(iOS 13.0, *) //struct GIFImageTest: View { // @State private var imageData: Data? = nil diff --git a/Sources/URLImage/Views/RemoteGIFImageView.swift b/Sources/URLImage/Views/RemoteGIFImageView.swift index 69cc2a9..4080f35 100644 --- a/Sources/URLImage/Views/RemoteGIFImageView.swift +++ b/Sources/URLImage/Views/RemoteGIFImageView.swift @@ -17,6 +17,7 @@ struct RemoteGIFImageView : View where Empt @Environment(\.urlImageService) var urlImageService @Environment(\.urlImageOptions) var options @State private var image: PlatformImage? + @State var animateState: RemoteImageLoadingState = .initial let loadOptions: URLImageOptions.LoadOptions @@ -47,7 +48,7 @@ struct RemoteGIFImageView : View where Empt var body: some View { ZStack { - switch remoteImage.slowLoadingState { + switch animateState { case .initial: empty() @@ -67,7 +68,7 @@ struct RemoteGIFImageView : View where Empt } } .onAppear { - if loadOptions.contains(.loadOnAppear) { + if loadOptions.contains(.loadOnAppear), !remoteImage.slowLoadingState.value.isSuccess { loadRemoteImage() } } @@ -76,13 +77,11 @@ struct RemoteGIFImageView : View where Empt remoteImage.cancel() } } - .onReceive(remoteImage.$slowLoadingState.receive(on: DispatchQueue.main), perform: { newValue in + .onReceive(remoteImage.slowLoadingState) { newValue in Task { await prepare(newValue) } - }) - .task { - await prepare(remoteImage.slowLoadingState) + animateState = newValue } } @@ -97,14 +96,9 @@ struct RemoteGIFImageView : View where Empt } private func loadRemoteImage() { + let remote = remoteImage Task { - await withTaskCancellationHandler(operation: { - await remoteImage.load() - }, onCancel: { - Task { - await remoteImage.cancel() - } - }) + await remote.load() } } @@ -132,13 +126,12 @@ struct RemoteGIFImageView : View where Empt } do { - for try await value in fileStore.getImagePublisher([.url(remoteImage.download.url)], maxPixelSize: maxPixelSize).values { - guard let value = value else { - continue - } - let data = await gif(value) - image = data + let value = try await fileStore.getImage([.url(remoteImage.download.url)], maxPixelSize: maxPixelSize) + guard let value else { + return } + let data = await gif(value, maxSize: options.maxPixelSize) + image = data } catch { print("retrive image with \(remoteImage.download.url) failed. \(error)") } @@ -155,25 +148,25 @@ struct RemoteGIFImageView : View where Empt return nil } - return await gif(value) + return await gif(value, maxSize: options.maxPixelSize) } } #if os(iOS) || os(watchOS) @available(iOS 14.0, *) -fileprivate func gifImage(_ image: TransientImage) -> PlatformImage? { - let source = image.proxy.decoder.imageSource - let count = CGImageSourceGetCount(source) - let delays = (0.. PlatformImage? { + let decoder = image.proxy.decoder + let count = decoder.frameCount + var delays = [Int]() + for delay in 0.. PlatformImage? { } #elseif os(macOS) @available(macOS 11.0, *) -fileprivate func gifImage(_ source: TransientImage) -> PlatformImage? { +fileprivate func gifImage(_ source: TransientImage, maxSize: CGSize?) async -> PlatformImage? { switch source.presentation { case .data(let data): return PlatformImage(data: data) case .file(let path): let image = PlatformImage(contentsOfFile: path) - print("cache image file \(String(describing: image)) load from \(path)") + if let image { + print("cache image file \(image) load from \(path)") + } else { + print("cache image file nil load from \(path)") + } return image } } @@ -206,9 +203,13 @@ extension NSImage: @unchecked Sendable { } #endif +//fileprivate func gifCacheImageData(_ source: Imaged) -> PlatformImage? { +// +//} + @available(macOS 11.0, iOS 14.0, *) -fileprivate func gif(_ source: TransientImage) async -> PlatformImage? { - gifImage(source) +fileprivate func gif(_ source: TransientImage, maxSize: CGSize?) async -> PlatformImage? { + await gifImage(source, maxSize: maxSize) } fileprivate func gcd(_ a: Int, _ b: Int) -> Int { diff --git a/Sources/URLImage/Views/RemoteImageView.swift b/Sources/URLImage/Views/RemoteImageView.swift index 44fa363..d21a38c 100644 --- a/Sources/URLImage/Views/RemoteImageView.swift +++ b/Sources/URLImage/Views/RemoteImageView.swift @@ -68,7 +68,7 @@ struct RemoteImageView : View where Empty : } } .onAppear { - if loadOptions.contains(.loadOnAppear), !remoteImage.slowLoadingState.isSuccess { + if loadOptions.contains(.loadOnAppear), !remoteImage.slowLoadingState.value.isSuccess { loadRemoteImage() } } @@ -77,7 +77,7 @@ struct RemoteImageView : View where Empty : remoteImage.cancel() } } - .onReceive(remoteImage.$slowLoadingState) { newValue in + .onReceive(remoteImage.slowLoadingState) { newValue in guard urlImageOptions.loadingAnimated else { animateState = newValue return @@ -90,12 +90,13 @@ struct RemoteImageView : View where Empty : } private func loadRemoteImage() { + let remote = remoteImage Task { await withTaskCancellationHandler(operation: { - await remoteImage.load() + await remote.load() }, onCancel: { Task { - await remoteImage.cancel() + await remote.cancel() } }) } diff --git a/Sources/URLImageStore/URLImageFileStore.swift b/Sources/URLImageStore/URLImageFileStore.swift index 9411330..681a90a 100644 --- a/Sources/URLImageStore/URLImageFileStore.swift +++ b/Sources/URLImageStore/URLImageFileStore.swift @@ -14,7 +14,7 @@ import ImageDecoder @available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) -public final class URLImageFileStore { +public final class URLImageFileStore: Sendable { let fileIndex: FileIndex @@ -35,26 +35,26 @@ public final class URLImageFileStore { public func getImage(_ identifier: String, maxPixelSize: CGSize? = nil, completionQueue: DispatchQueue? = nil, - completion: @escaping (_ image: CGImage?) -> Void) { + completion: @Sendable @escaping (_ image: CGImage?) -> Void) { getImage([ .identifier(identifier) ], maxPixelSize: maxPixelSize, completionQueue: completionQueue, completion: completion) } public func getImage(_ url: URL, maxPixelSize: CGSize? = nil, completionQueue: DispatchQueue? = nil, - completion: @escaping (_ image: CGImage?) -> Void) { + completion: @Sendable @escaping (_ image: CGImage?) -> Void) { getImage([ .url(url) ], maxPixelSize: maxPixelSize, completionQueue: completionQueue, completion: completion) } public func getImageLocation(_ identifier: String, completionQueue: DispatchQueue? = nil, - completion: @escaping (_ location: URL?) -> Void) { + completion: @Sendable @escaping (_ location: URL?) -> Void) { getImageLocation([ .identifier(identifier) ], completionQueue: completionQueue, completion: completion) } public func getImageLocation(_ url: URL, completionQueue: DispatchQueue? = nil, - completion: @escaping (_ location: URL?) -> Void) { + completion: @Sendable @escaping (_ location: URL?) -> Void) { getImageLocation([ .url(url) ], completionQueue: completionQueue, completion: completion) } @@ -126,7 +126,7 @@ public final class URLImageFileStore { private func getImageLocation(_ keys: [URLImageKey], completionQueue: DispatchQueue? = nil, - completion: @escaping (_ location: URL?) -> Void) { + completion: @Sendable @escaping (_ location: URL?) -> Void) { fileIndexQueue.async { [weak self] in guard let self = self else { @@ -165,36 +165,35 @@ public final class URLImageFileStore { private func getImage(_ keys: [URLImageKey], maxPixelSize: CGSize? = nil, completionQueue: DispatchQueue? = nil, - completion: @escaping (_ image: CGImage?) -> Void) { + completion: @Sendable @escaping (_ image: CGImage?) -> Void) { getImage(keys, open: { location -> CGImage? in - guard let decoder = ImageDecoder(url: location) else { - return nil - } - - if let sizeForDrawing = maxPixelSize { - let decodingOptions = ImageDecoder.DecodingOptions(mode: .asynchronous, sizeForDrawing: sizeForDrawing) - return decoder.createFrameImage(at: 0, decodingOptions: decodingOptions)! - } else { - return decoder.createFrameImage(at: 0)! - } - }, - completion: { result in - let queue = completionQueue ?? DispatchQueue.global() - - switch result { - - case .success(let image): - queue.async { - completion(image) - } - - case .failure: - queue.async { - completion(nil) - } - } - }) + guard let decoder = ImageDecoder(url: location) else { + return nil + } + + if let sizeForDrawing = maxPixelSize { + let decodingOptions = ImageDecoder.DecodingOptions(mode: .asynchronous, sizeForDrawing: sizeForDrawing) + return decoder.createFrameImage(at: 0, decodingOptions: decodingOptions)! + } else { + return decoder.createFrameImage(at: 0)! + } + }, completion: { result in + let queue = completionQueue ?? DispatchQueue.global() + + switch result { + + case .success(let image): + queue.async { + completion(image) + } + + case .failure: + queue.async { + completion(nil) + } + } + }) } } @@ -245,8 +244,8 @@ extension URLImageFileStore: URLImageFileStoreType { } public func getImage(_ keys: [URLImageKey], - open: @escaping (_ location: URL) throws -> T?, - completion: @escaping (_ result: Result) -> Void) { + open: @Sendable @escaping (_ location: URL) throws -> T?, + completion: @Sendable @escaping (_ result: Result) -> Void) { getImageLocation(keys, completionQueue: decodeQueue) { [weak self] location in guard let _ = self else { // Just a sanity check if the cache object is still exists