diff --git a/Development/AsyncMultiplexImage-Demo.xcodeproj/project.pbxproj b/Development/AsyncMultiplexImage-Demo.xcodeproj/project.pbxproj index 8de4275..44e3021 100644 --- a/Development/AsyncMultiplexImage-Demo.xcodeproj/project.pbxproj +++ b/Development/AsyncMultiplexImage-Demo.xcodeproj/project.pbxproj @@ -8,6 +8,7 @@ /* Begin PBXBuildFile section */ 4B094D622D65B82500E4C4A9 /* ShrinkDemo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B094D612D65B82000E4C4A9 /* ShrinkDemo.swift */; }; + 4B094D642D65B82700E4C4A9 /* OptionalImageDemo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B094D632D65B82600E4C4A9 /* OptionalImageDemo.swift */; }; 4B57105B28D0AC7F00AA053C /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B57105A28D0AC7F00AA053C /* ContentView.swift */; }; 4B57105D28D0AC8000AA053C /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 4B57105C28D0AC8000AA053C /* Assets.xcassets */; }; 4B57106128D0AC8000AA053C /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 4B57106028D0AC8000AA053C /* Preview Assets.xcassets */; }; @@ -21,6 +22,7 @@ /* Begin PBXFileReference section */ 4B094D612D65B82000E4C4A9 /* ShrinkDemo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShrinkDemo.swift; sourceTree = ""; }; + 4B094D632D65B82600E4C4A9 /* OptionalImageDemo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OptionalImageDemo.swift; sourceTree = ""; }; 4B57105528D0AC7F00AA053C /* AsyncMultiplexImage-Demo.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "AsyncMultiplexImage-Demo.app"; sourceTree = BUILT_PRODUCTS_DIR; }; 4B57105A28D0AC7F00AA053C /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; 4B57105C28D0AC8000AA053C /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; @@ -68,6 +70,7 @@ isa = PBXGroup; children = ( 4B094D612D65B82000E4C4A9 /* ShrinkDemo.swift */, + 4B094D632D65B82600E4C4A9 /* OptionalImageDemo.swift */, 4B57105A28D0AC7F00AA053C /* ContentView.swift */, 4B57105C28D0AC8000AA053C /* Assets.xcassets */, 4B57105E28D0AC8000AA053C /* AsyncMultiplexImage_Demo.entitlements */, @@ -177,6 +180,7 @@ 4B57105B28D0AC7F00AA053C /* ContentView.swift in Sources */, 4BB403CD2C19F9A80033B5E7 /* List.swift in Sources */, 4B094D622D65B82500E4C4A9 /* ShrinkDemo.swift in Sources */, + 4B094D642D65B82700E4C4A9 /* OptionalImageDemo.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/Development/AsyncMultiplexImage-Demo/ContentView.swift b/Development/AsyncMultiplexImage-Demo/ContentView.swift index a122b26..433468a 100644 --- a/Development/AsyncMultiplexImage-Demo/ContentView.swift +++ b/Development/AsyncMultiplexImage-Demo/ContentView.swift @@ -96,6 +96,10 @@ struct ContentView: View { NavigationLink("Shrink", destination: { BookShrink() }) + + NavigationLink("Optional Image", destination: { + OptionalImageDemo() + }) } .navigationTitle("Multiplex Image") } diff --git a/Development/AsyncMultiplexImage-Demo/OptionalImageDemo.swift b/Development/AsyncMultiplexImage-Demo/OptionalImageDemo.swift new file mode 100644 index 0000000..f2aa79b --- /dev/null +++ b/Development/AsyncMultiplexImage-Demo/OptionalImageDemo.swift @@ -0,0 +1,149 @@ +import AsyncMultiplexImage +import AsyncMultiplexImage_Nuke +import Nuke +import SwiftUI + +struct OptionalImageDemo: View, PreviewProvider { + var body: some View { + ContentView() + } + + static var previews: some View { + Self() + .previewDisplayName("Optional Image Demo") + } + + private struct ContentView: View { + + @State private var imageRepresentation: ImageRepresentation? = nil + + private let imageURLs = [ + "https://images.unsplash.com/photo-1660668377331-da480e5339a0", + "https://images.unsplash.com/photo-1658214764191-b002b517e9e5", + "https://images.unsplash.com/photo-1587126396803-be14d33e49cf", + ] + + var body: some View { + VStack(spacing: 20) { + + // Current state indicator + Text("Current state: \(stateDescription)") + .font(.caption) + .foregroundColor(.secondary) + .padding(.top) + + // Image view + AsyncMultiplexImage( + imageRepresentation: imageRepresentation, + downloader: _SlowDownloader(pipeline: .shared), + content: PlaceholderContent() + ) + .frame(height: 400) + .clipped() + + // Control buttons + VStack(spacing: 12) { + HStack(spacing: 12) { + ForEach(0.. some View { + switch phase { + case .empty: + ZStack { + Color.gray.opacity(0.2) + + VStack(spacing: 12) { + Image(systemName: "photo") + .font(.system(size: 60)) + .foregroundColor(.gray) + + Text("No Image (Placeholder)") + .font(.headline) + .foregroundColor(.secondary) + + Text("Tap 'Load Image' to display an image") + .font(.caption) + .foregroundColor(.secondary) + } + } + + case .progress(let image, _): + image + .resizable() + .scaledToFill() + .transition(.opacity.animation(.easeInOut)) + + case .success(let image, _): + image + .resizable() + .scaledToFill() + .transition(.opacity.animation(.easeInOut)) + + case .failure: + ZStack { + Color.red.opacity(0.2) + + VStack(spacing: 12) { + Image(systemName: "exclamationmark.triangle") + .font(.system(size: 60)) + .foregroundColor(.red) + + Text("Failed to load image") + .font(.headline) + .foregroundColor(.secondary) + } + } + } + } + } +} diff --git a/Sources/AsyncMultiplexImage/AsyncMultiplexImage.swift b/Sources/AsyncMultiplexImage/AsyncMultiplexImage.swift index 3ab8e71..51114bc 100644 --- a/Sources/AsyncMultiplexImage/AsyncMultiplexImage.swift +++ b/Sources/AsyncMultiplexImage/AsyncMultiplexImage.swift @@ -130,8 +130,8 @@ public enum ImageRepresentation: Equatable { public struct AsyncMultiplexImage< Content: AsyncMultiplexImageContent, Downloader: AsyncMultiplexImageDownloader >: View { - - private let imageRepresentation: ImageRepresentation + + private let imageRepresentation: ImageRepresentation? private let downloader: Downloader private let content: Content @@ -165,13 +165,13 @@ public struct AsyncMultiplexImage< /// Creates an async multiplex image view with an image representation. /// /// - Parameters: - /// - imageRepresentation: The image representation to display (either remote or pre-loaded). + /// - imageRepresentation: The image representation to display (either remote or pre-loaded). If nil, displays empty/placeholder state. /// - downloader: The downloader actor to use for fetching images. /// - clearsContentBeforeDownload: Whether to clear the content before starting a new download. Defaults to `true`. /// - skipsFinalImageReload: Whether to skip reloading when a final image is already loaded for the same representation. When `false` (default), images reload when display size or representation changes. Defaults to `false`. /// - content: A closure that builds the view content based on the current loading phase. public init( - imageRepresentation: ImageRepresentation, + imageRepresentation: ImageRepresentation?, downloader: Downloader, clearsContentBeforeDownload: Bool = true, skipsFinalImageReload: Bool = false, @@ -201,18 +201,18 @@ public struct AsyncMultiplexImage< private struct _AsyncMultiplexImage< Content: AsyncMultiplexImageContent, Downloader: AsyncMultiplexImageDownloader >: View { - + private struct UpdateTrigger: Equatable { let size: CGSize - let image: ImageRepresentation + let image: ImageRepresentation? } - + @State private var item: ResultContainer.ItemSwiftUI? - + @State private var displaySize: CGSize = .zero @Environment(\.displayScale) var displayScale - private let imageRepresentation: ImageRepresentation + private let imageRepresentation: ImageRepresentation? private let downloader: Downloader private let content: Content private let clearsContentBeforeDownload: Bool @@ -221,7 +221,7 @@ private struct _AsyncMultiplexImage< public init( clearsContentBeforeDownload: Bool, skipsFinalImageReload: Bool, - imageRepresentation: ImageRepresentation, + imageRepresentation: ImageRepresentation?, downloader: Downloader, content: Content ) { @@ -270,6 +270,16 @@ private struct _AsyncMultiplexImage< ), { + // Handle nil imageRepresentation (placeholder state) + guard let imageRepresentation else { + var transaction = Transaction() + transaction.disablesAnimations = true + withTransaction(transaction) { + self.item = nil + } + return + } + if skipsFinalImageReload { if let item, case .final = item.phase, @@ -279,14 +289,14 @@ private struct _AsyncMultiplexImage< } } - await withTaskCancellationHandler { - + await withTaskCancellationHandler { + let newSize = displaySize - + guard newSize.height > 0 && newSize.width > 0 else { return } - + if clearsContentBeforeDownload { var transaction = Transaction() transaction.disablesAnimations = true @@ -294,7 +304,7 @@ private struct _AsyncMultiplexImage< self.item = nil } } - + switch imageRepresentation { case .remote(let multiplexImage):