fix gif caching
This commit is contained in:
1 parent
71b1d0a21d
commit
395cbe5e13
19 files changed
+724
-471
No files matched your search
@@ -10,7 +10,6 @@
|
|||||||
6F09B7BB2DEB873F00CDE89C /* ZIPFoundation in Frameworks */ = {isa = PBXBuildFile; productRef = 6F09B7BA2DEB873F00CDE89C /* ZIPFoundation */; };
|
6F09B7BB2DEB873F00CDE89C /* ZIPFoundation in Frameworks */ = {isa = PBXBuildFile; productRef = 6F09B7BA2DEB873F00CDE89C /* ZIPFoundation */; };
|
||||||
6F09BA332E00808300CDE89C /* Dynamic in Frameworks */ = {isa = PBXBuildFile; productRef = 6F09BA322E00808300CDE89C /* Dynamic */; };
|
6F09BA332E00808300CDE89C /* Dynamic in Frameworks */ = {isa = PBXBuildFile; productRef = 6F09BA322E00808300CDE89C /* Dynamic */; };
|
||||||
6FB105552E26D3EC00185A5C /* CachedAsyncImage in Frameworks */ = {isa = PBXBuildFile; productRef = 6FB105542E26D3EC00185A5C /* CachedAsyncImage */; };
|
6FB105552E26D3EC00185A5C /* CachedAsyncImage in Frameworks */ = {isa = PBXBuildFile; productRef = 6FB105542E26D3EC00185A5C /* CachedAsyncImage */; };
|
||||||
6FB105602E26DD0A00185A5C /* Giffy in Frameworks */ = {isa = PBXBuildFile; productRef = 6FB1055F2E26DD0A00185A5C /* Giffy */; };
|
|
||||||
/* End PBXBuildFile section */
|
/* End PBXBuildFile section */
|
||||||
|
|
||||||
/* Begin PBXFileReference section */
|
/* Begin PBXFileReference section */
|
||||||
@@ -35,7 +34,6 @@
|
|||||||
files = (
|
files = (
|
||||||
6FB105552E26D3EC00185A5C /* CachedAsyncImage in Frameworks */,
|
6FB105552E26D3EC00185A5C /* CachedAsyncImage in Frameworks */,
|
||||||
6F09BA332E00808300CDE89C /* Dynamic in Frameworks */,
|
6F09BA332E00808300CDE89C /* Dynamic in Frameworks */,
|
||||||
6FB105602E26DD0A00185A5C /* Giffy in Frameworks */,
|
|
||||||
6F09B7BB2DEB873F00CDE89C /* ZIPFoundation in Frameworks */,
|
6F09B7BB2DEB873F00CDE89C /* ZIPFoundation in Frameworks */,
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
@@ -82,7 +80,6 @@
|
|||||||
6F09B7BA2DEB873F00CDE89C /* ZIPFoundation */,
|
6F09B7BA2DEB873F00CDE89C /* ZIPFoundation */,
|
||||||
6F09BA322E00808300CDE89C /* Dynamic */,
|
6F09BA322E00808300CDE89C /* Dynamic */,
|
||||||
6FB105542E26D3EC00185A5C /* CachedAsyncImage */,
|
6FB105542E26D3EC00185A5C /* CachedAsyncImage */,
|
||||||
6FB1055F2E26DD0A00185A5C /* Giffy */,
|
|
||||||
);
|
);
|
||||||
productName = "Pocket Poster";
|
productName = "Pocket Poster";
|
||||||
productReference = 6F09B79D2DEB694B00CDE89C /* Pocket Poster.app */;
|
productReference = 6F09B79D2DEB694B00CDE89C /* Pocket Poster.app */;
|
||||||
@@ -116,7 +113,6 @@
|
|||||||
6F09B7B92DEB873F00CDE89C /* XCRemoteSwiftPackageReference "ZIPFoundation" */,
|
6F09B7B92DEB873F00CDE89C /* XCRemoteSwiftPackageReference "ZIPFoundation" */,
|
||||||
6F09BA312E00808300CDE89C /* XCRemoteSwiftPackageReference "Dynamic" */,
|
6F09BA312E00808300CDE89C /* XCRemoteSwiftPackageReference "Dynamic" */,
|
||||||
6FB105532E26D3EC00185A5C /* XCRemoteSwiftPackageReference "swiftui-cached-async-image" */,
|
6FB105532E26D3EC00185A5C /* XCRemoteSwiftPackageReference "swiftui-cached-async-image" */,
|
||||||
6FB1055E2E26DD0A00185A5C /* XCRemoteSwiftPackageReference "Giffy" */,
|
|
||||||
);
|
);
|
||||||
preferredProjectObjectVersion = 77;
|
preferredProjectObjectVersion = 77;
|
||||||
productRefGroup = 6F09B79E2DEB694B00CDE89C /* Products */;
|
productRefGroup = 6F09B79E2DEB694B00CDE89C /* Products */;
|
||||||
@@ -276,7 +272,7 @@
|
|||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 3;
|
CURRENT_PROJECT_VERSION = 5;
|
||||||
DEVELOPMENT_ASSET_PATHS = "\"Pocket Poster/Preview Content\"";
|
DEVELOPMENT_ASSET_PATHS = "\"Pocket Poster/Preview Content\"";
|
||||||
DEVELOPMENT_TEAM = 2P4CP47H76;
|
DEVELOPMENT_TEAM = 2P4CP47H76;
|
||||||
ENABLE_PREVIEWS = YES;
|
ENABLE_PREVIEWS = YES;
|
||||||
@@ -308,7 +304,7 @@
|
|||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 3;
|
CURRENT_PROJECT_VERSION = 5;
|
||||||
DEVELOPMENT_ASSET_PATHS = "\"Pocket Poster/Preview Content\"";
|
DEVELOPMENT_ASSET_PATHS = "\"Pocket Poster/Preview Content\"";
|
||||||
DEVELOPMENT_TEAM = 2P4CP47H76;
|
DEVELOPMENT_TEAM = 2P4CP47H76;
|
||||||
ENABLE_PREVIEWS = YES;
|
ENABLE_PREVIEWS = YES;
|
||||||
@@ -382,14 +378,6 @@
|
|||||||
minimumVersion = 2.1.1;
|
minimumVersion = 2.1.1;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
6FB1055E2E26DD0A00185A5C /* XCRemoteSwiftPackageReference "Giffy" */ = {
|
|
||||||
isa = XCRemoteSwiftPackageReference;
|
|
||||||
repositoryURL = "https://github.com/tfmart/Giffy.git";
|
|
||||||
requirement = {
|
|
||||||
branch = main;
|
|
||||||
kind = branch;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
/* End XCRemoteSwiftPackageReference section */
|
/* End XCRemoteSwiftPackageReference section */
|
||||||
|
|
||||||
/* Begin XCSwiftPackageProductDependency section */
|
/* Begin XCSwiftPackageProductDependency section */
|
||||||
@@ -408,11 +396,6 @@
|
|||||||
package = 6FB105532E26D3EC00185A5C /* XCRemoteSwiftPackageReference "swiftui-cached-async-image" */;
|
package = 6FB105532E26D3EC00185A5C /* XCRemoteSwiftPackageReference "swiftui-cached-async-image" */;
|
||||||
productName = CachedAsyncImage;
|
productName = CachedAsyncImage;
|
||||||
};
|
};
|
||||||
6FB1055F2E26DD0A00185A5C /* Giffy */ = {
|
|
||||||
isa = XCSwiftPackageProductDependency;
|
|
||||||
package = 6FB1055E2E26DD0A00185A5C /* XCRemoteSwiftPackageReference "Giffy" */;
|
|
||||||
productName = Giffy;
|
|
||||||
};
|
|
||||||
/* End XCSwiftPackageProductDependency section */
|
/* End XCSwiftPackageProductDependency section */
|
||||||
};
|
};
|
||||||
rootObject = 6F09B7952DEB694B00CDE89C /* Project object */;
|
rootObject = 6F09B7952DEB694B00CDE89C /* Project object */;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"originHash" : "9d3a39dff9bda7be6921bb553210388e3a68059324656c0ace5f2eb2378819b1",
|
"originHash" : "7f2d604f1affb7dc07ec1898cef3c0eaf130e2b55b5c441f445ab59c3285c1bb",
|
||||||
"pins" : [
|
"pins" : [
|
||||||
{
|
{
|
||||||
"identity" : "dynamic",
|
"identity" : "dynamic",
|
||||||
@@ -10,24 +10,6 @@
|
|||||||
"version" : "1.2.0"
|
"version" : "1.2.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"identity" : "flanimatedimage",
|
|
||||||
"kind" : "remoteSourceControl",
|
|
||||||
"location" : "https://github.com/Flipboard/FLAnimatedImage.git",
|
|
||||||
"state" : {
|
|
||||||
"revision" : "d4f07b6f164d53c1212c3e54d6460738b1981e9f",
|
|
||||||
"version" : "1.0.17"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"identity" : "giffy",
|
|
||||||
"kind" : "remoteSourceControl",
|
|
||||||
"location" : "https://github.com/tfmart/Giffy.git",
|
|
||||||
"state" : {
|
|
||||||
"branch" : "main",
|
|
||||||
"revision" : "595bd0a0bc292d32da4b26b8bca4347235ab2556"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"identity" : "swiftui-cached-async-image",
|
"identity" : "swiftui-cached-async-image",
|
||||||
"kind" : "remoteSourceControl",
|
"kind" : "remoteSourceControl",
|
||||||
|
|||||||
+1
-1
@@ -7,7 +7,7 @@
|
|||||||
<key>Pocket Poster.xcscheme_^#shared#^_</key>
|
<key>Pocket Poster.xcscheme_^#shared#^_</key>
|
||||||
<dict>
|
<dict>
|
||||||
<key>orderHint</key>
|
<key>orderHint</key>
|
||||||
<integer>0</integer>
|
<integer>1</integer>
|
||||||
</dict>
|
</dict>
|
||||||
</dict>
|
</dict>
|
||||||
</dict>
|
</dict>
|
||||||
|
|||||||
@@ -1,408 +0,0 @@
|
|||||||
//
|
|
||||||
// CachedAsyncGiffy.swift
|
|
||||||
// Pocket Poster
|
|
||||||
//
|
|
||||||
// Created by lemin on 7/18/25.
|
|
||||||
//
|
|
||||||
|
|
||||||
|
|
||||||
//
|
|
||||||
// Created by Lorenzo Fiamingo on 04/11/20.
|
|
||||||
//
|
|
||||||
|
|
||||||
import SwiftUI
|
|
||||||
import Giffy
|
|
||||||
|
|
||||||
/// A view that asynchronously loads, cache and displays an image.
|
|
||||||
///
|
|
||||||
/// This view uses a custom default
|
|
||||||
/// <doc://com.apple.documentation/documentation/Foundation/URLSession>
|
|
||||||
/// instance to load an image from the specified URL, and then display it.
|
|
||||||
/// For example, you can display an icon that's stored on a server:
|
|
||||||
///
|
|
||||||
/// CachedAsyncGiffy(url: URL(string: "https://example.com/icon.gif"))
|
|
||||||
/// .frame(width: 200, height: 200)
|
|
||||||
///
|
|
||||||
/// Until the image loads, the view displays a standard placeholder that
|
|
||||||
/// fills the available space. After the load completes successfully, the view
|
|
||||||
/// updates to display the image. In the example above, the icon is smaller
|
|
||||||
/// than the frame, and so appears smaller than the placeholder.
|
|
||||||
///
|
|
||||||
/// 
|
|
||||||
///
|
|
||||||
/// You can specify a custom placeholder using
|
|
||||||
/// ``init(url:urlCache:scale:content:placeholder:)``. With this initializer, you can
|
|
||||||
/// also use the `content` parameter to manipulate the loaded image.
|
|
||||||
/// For example, you can add a modifier to make the loaded image resizable:
|
|
||||||
///
|
|
||||||
/// CachedAsyncGiffy(url: URL(string: "https://example.com/icon.gif")) { image in
|
|
||||||
/// image.resizable()
|
|
||||||
/// } placeholder: {
|
|
||||||
/// ProgressView()
|
|
||||||
/// }
|
|
||||||
/// .frame(width: 50, height: 50)
|
|
||||||
///
|
|
||||||
/// For this example, SwiftUI shows a ``ProgressView`` first, and then the
|
|
||||||
/// image scaled to fit in the specified frame:
|
|
||||||
///
|
|
||||||
/// 
|
|
||||||
///
|
|
||||||
/// > Important: You can't apply image-specific modifiers, like
|
|
||||||
/// ``Image/resizable(capInsets:resizingMode:)``, directly to a `CachedAsyncImage`.
|
|
||||||
/// Instead, apply them to the ``Image`` instance that your `content`
|
|
||||||
/// closure gets when defining the view's appearance.
|
|
||||||
///
|
|
||||||
/// To gain more control over the loading process, use the
|
|
||||||
/// ``init(url:urlCache:scale:transaction:content:)`` initializer, which takes a
|
|
||||||
/// `content` closure that receives an ``AsyncImagePhase`` to indicate
|
|
||||||
/// the state of the loading operation. Return a view that's appropriate
|
|
||||||
/// for the current phase:
|
|
||||||
///
|
|
||||||
/// CachedAsyncGiffy(url: URL(string: "https://example.com/icon.gif")) { phase in
|
|
||||||
/// if let image = phase.image {
|
|
||||||
/// image // Displays the loaded image.
|
|
||||||
/// } else if phase.error != nil {
|
|
||||||
/// Color.red // Indicates an error.
|
|
||||||
/// } else {
|
|
||||||
/// Color.blue // Acts as a placeholder.
|
|
||||||
/// }
|
|
||||||
/// }
|
|
||||||
///
|
|
||||||
@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *)
|
|
||||||
public struct CachedAsyncGiffy<Content>: View where Content: View {
|
|
||||||
|
|
||||||
@State private var phase: AsyncGiffyPhase
|
|
||||||
|
|
||||||
private let urlRequest: URLRequest?
|
|
||||||
|
|
||||||
private let urlSession: URLSession
|
|
||||||
|
|
||||||
private let scale: CGFloat
|
|
||||||
|
|
||||||
private let transaction: Transaction
|
|
||||||
|
|
||||||
private let content: (AsyncGiffyPhase) -> Content
|
|
||||||
|
|
||||||
public var body: some View {
|
|
||||||
content(phase)
|
|
||||||
.task(id: urlRequest, load)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Loads and displays an image from the specified URL.
|
|
||||||
///
|
|
||||||
/// Until the image loads, SwiftUI displays a default placeholder. When
|
|
||||||
/// the load operation completes successfully, SwiftUI updates the
|
|
||||||
/// view to show the loaded image. If the operation fails, SwiftUI
|
|
||||||
/// continues to display the placeholder. The following example loads
|
|
||||||
/// and displays an icon from an example server:
|
|
||||||
///
|
|
||||||
/// CachedAsyncImage(url: URL(string: "https://example.com/icon.png"))
|
|
||||||
///
|
|
||||||
/// If you want to customize the placeholder or apply image-specific
|
|
||||||
/// modifiers --- like ``Image/resizable(capInsets:resizingMode:)`` ---
|
|
||||||
/// to the loaded image, use the ``init(url:scale:content:placeholder:)``
|
|
||||||
/// initializer instead.
|
|
||||||
///
|
|
||||||
/// - Parameters:
|
|
||||||
/// - url: The URL of the image to display.
|
|
||||||
/// - urlCache: The URL cache for providing cached responses to requests within the session.
|
|
||||||
/// - scale: The scale to use for the image. The default is `1`. Set a
|
|
||||||
/// different value when loading images designed for higher resolution
|
|
||||||
/// displays. For example, set a value of `2` for an image that you
|
|
||||||
/// would name with the `@2x` suffix if stored in a file on disk.
|
|
||||||
public init(url: URL?, urlCache: URLCache = .shared, scale: CGFloat = 1) where Content == Giffy {
|
|
||||||
let urlRequest = url == nil ? nil : URLRequest(url: url!)
|
|
||||||
self.init(urlRequest: urlRequest, urlCache: urlCache, scale: scale)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Loads and displays an image from the specified URL.
|
|
||||||
///
|
|
||||||
/// Until the image loads, SwiftUI displays a default placeholder. When
|
|
||||||
/// the load operation completes successfully, SwiftUI updates the
|
|
||||||
/// view to show the loaded image. If the operation fails, SwiftUI
|
|
||||||
/// continues to display the placeholder. The following example loads
|
|
||||||
/// and displays an icon from an example server:
|
|
||||||
///
|
|
||||||
/// CachedAsyncImage(url: URL(string: "https://example.com/icon.png"))
|
|
||||||
///
|
|
||||||
/// If you want to customize the placeholder or apply image-specific
|
|
||||||
/// modifiers --- like ``Image/resizable(capInsets:resizingMode:)`` ---
|
|
||||||
/// to the loaded image, use the ``init(url:scale:content:placeholder:)``
|
|
||||||
/// initializer instead.
|
|
||||||
///
|
|
||||||
/// - Parameters:
|
|
||||||
/// - urlRequest: The URL request of the image to display.
|
|
||||||
/// - urlCache: The URL cache for providing cached responses to requests within the session.
|
|
||||||
/// - scale: The scale to use for the image. The default is `1`. Set a
|
|
||||||
/// different value when loading images designed for higher resolution
|
|
||||||
/// displays. For example, set a value of `2` for an image that you
|
|
||||||
/// would name with the `@2x` suffix if stored in a file on disk.
|
|
||||||
public init(urlRequest: URLRequest?, urlCache: URLCache = .shared, scale: CGFloat = 1) where Content == Giffy {
|
|
||||||
self.init(urlRequest: urlRequest, urlCache: urlCache, scale: scale) { phase in
|
|
||||||
#if os(macOS)
|
|
||||||
phase.image ?? Image(nsImage: .init())
|
|
||||||
#else
|
|
||||||
switch phase {
|
|
||||||
case .success(let giffy):
|
|
||||||
return giffy
|
|
||||||
default:
|
|
||||||
return Giffy("")
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Loads and displays a modifiable image from the specified URL using
|
|
||||||
/// a custom placeholder until the image loads.
|
|
||||||
///
|
|
||||||
/// Until the image loads, SwiftUI displays the placeholder view that
|
|
||||||
/// you specify. When the load operation completes successfully, SwiftUI
|
|
||||||
/// updates the view to show content that you specify, which you
|
|
||||||
/// create using the loaded image. For example, you can show a green
|
|
||||||
/// placeholder, followed by a tiled version of the loaded image:
|
|
||||||
///
|
|
||||||
/// CachedAsyncImage(url: URL(string: "https://example.com/icon.png")) { image in
|
|
||||||
/// image.resizable(resizingMode: .tile)
|
|
||||||
/// } placeholder: {
|
|
||||||
/// Color.green
|
|
||||||
/// }
|
|
||||||
///
|
|
||||||
/// If the load operation fails, SwiftUI continues to display the
|
|
||||||
/// placeholder. To be able to display a different view on a load error,
|
|
||||||
/// use the ``init(url:scale:transaction:content:)`` initializer instead.
|
|
||||||
///
|
|
||||||
/// - Parameters:
|
|
||||||
/// - url: The URL of the image to display.
|
|
||||||
/// - urlCache: The URL cache for providing cached responses to requests within the session.
|
|
||||||
/// - scale: The scale to use for the image. The default is `1`. Set a
|
|
||||||
/// different value when loading images designed for higher resolution
|
|
||||||
/// displays. For example, set a value of `2` for an image that you
|
|
||||||
/// would name with the `@2x` suffix if stored in a file on disk.
|
|
||||||
/// - content: A closure that takes the loaded image as an input, and
|
|
||||||
/// returns the view to show. You can return the image directly, or
|
|
||||||
/// modify it as needed before returning it.
|
|
||||||
/// - placeholder: A closure that returns the view to show until the
|
|
||||||
/// load operation completes successfully.
|
|
||||||
public init<I, P>(url: URL?, urlCache: URLCache = .shared, scale: CGFloat = 1, @ViewBuilder content: @escaping (Giffy) -> I, @ViewBuilder placeholder: @escaping () -> P) where Content == _ConditionalContent<I, P>, I : View, P : View {
|
|
||||||
let urlRequest = url == nil ? nil : URLRequest(url: url!)
|
|
||||||
self.init(urlRequest: urlRequest, urlCache: urlCache, scale: scale, content: content, placeholder: placeholder)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Loads and displays a modifiable image from the specified URL using
|
|
||||||
/// a custom placeholder until the image loads.
|
|
||||||
///
|
|
||||||
/// Until the image loads, SwiftUI displays the placeholder view that
|
|
||||||
/// you specify. When the load operation completes successfully, SwiftUI
|
|
||||||
/// updates the view to show content that you specify, which you
|
|
||||||
/// create using the loaded image. For example, you can show a green
|
|
||||||
/// placeholder, followed by a tiled version of the loaded image:
|
|
||||||
///
|
|
||||||
/// CachedAsyncImage(url: URL(string: "https://example.com/icon.png")) { image in
|
|
||||||
/// image.resizable(resizingMode: .tile)
|
|
||||||
/// } placeholder: {
|
|
||||||
/// Color.green
|
|
||||||
/// }
|
|
||||||
///
|
|
||||||
/// If the load operation fails, SwiftUI continues to display the
|
|
||||||
/// placeholder. To be able to display a different view on a load error,
|
|
||||||
/// use the ``init(url:scale:transaction:content:)`` initializer instead.
|
|
||||||
///
|
|
||||||
/// - Parameters:
|
|
||||||
/// - urlRequest: The URL request of the image to display.
|
|
||||||
/// - urlCache: The URL cache for providing cached responses to requests within the session.
|
|
||||||
/// - scale: The scale to use for the image. The default is `1`. Set a
|
|
||||||
/// different value when loading images designed for higher resolution
|
|
||||||
/// displays. For example, set a value of `2` for an image that you
|
|
||||||
/// would name with the `@2x` suffix if stored in a file on disk.
|
|
||||||
/// - content: A closure that takes the loaded image as an input, and
|
|
||||||
/// returns the view to show. You can return the image directly, or
|
|
||||||
/// modify it as needed before returning it.
|
|
||||||
/// - placeholder: A closure that returns the view to show until the
|
|
||||||
/// load operation completes successfully.
|
|
||||||
public init<I, P>(urlRequest: URLRequest?, urlCache: URLCache = .shared, scale: CGFloat = 1, @ViewBuilder content: @escaping (Giffy) -> I, @ViewBuilder placeholder: @escaping () -> P) where Content == _ConditionalContent<I, P>, I : View, P : View {
|
|
||||||
self.init(urlRequest: urlRequest, urlCache: urlCache, scale: scale) { phase in
|
|
||||||
switch phase {
|
|
||||||
case .success(let image):
|
|
||||||
content(image)
|
|
||||||
default:
|
|
||||||
placeholder()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Loads and displays a modifiable image from the specified URL in phases.
|
|
||||||
///
|
|
||||||
/// If you set the asynchronous image's URL to `nil`, or after you set the
|
|
||||||
/// URL to a value but before the load operation completes, the phase is
|
|
||||||
/// ``AsyncImagePhase/empty``. After the operation completes, the phase
|
|
||||||
/// becomes either ``AsyncImagePhase/failure(_:)`` or
|
|
||||||
/// ``AsyncImagePhase/success(_:)``. In the first case, the phase's
|
|
||||||
/// ``AsyncImagePhase/error`` value indicates the reason for failure.
|
|
||||||
/// In the second case, the phase's ``AsyncImagePhase/image`` property
|
|
||||||
/// contains the loaded image. Use the phase to drive the output of the
|
|
||||||
/// `content` closure, which defines the view's appearance:
|
|
||||||
///
|
|
||||||
/// CachedAsyncImage(url: URL(string: "https://example.com/icon.png")) { phase in
|
|
||||||
/// if let image = phase.image {
|
|
||||||
/// image // Displays the loaded image.
|
|
||||||
/// } else if phase.error != nil {
|
|
||||||
/// Color.red // Indicates an error.
|
|
||||||
/// } else {
|
|
||||||
/// Color.blue // Acts as a placeholder.
|
|
||||||
/// }
|
|
||||||
/// }
|
|
||||||
///
|
|
||||||
/// To add transitions when you change the URL, apply an identifier to the
|
|
||||||
/// ``CachedAsyncImage``.
|
|
||||||
///
|
|
||||||
/// - Parameters:
|
|
||||||
/// - url: The URL of the image to display.
|
|
||||||
/// - urlCache: The URL cache for providing cached responses to requests within the session.
|
|
||||||
/// - scale: The scale to use for the image. The default is `1`. Set a
|
|
||||||
/// different value when loading images designed for higher resolution
|
|
||||||
/// displays. For example, set a value of `2` for an image that you
|
|
||||||
/// would name with the `@2x` suffix if stored in a file on disk.
|
|
||||||
/// - transaction: The transaction to use when the phase changes.
|
|
||||||
/// - content: A closure that takes the load phase as an input, and
|
|
||||||
/// returns the view to display for the specified phase.
|
|
||||||
public init(url: URL?, urlCache: URLCache = .shared, scale: CGFloat = 1, transaction: Transaction = Transaction(), @ViewBuilder content: @escaping (AsyncGiffyPhase) -> Content) {
|
|
||||||
let urlRequest = url == nil ? nil : URLRequest(url: url!)
|
|
||||||
self.init(urlRequest: urlRequest, urlCache: urlCache, scale: scale, transaction: transaction, content: content)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Loads and displays a modifiable image from the specified URL in phases.
|
|
||||||
///
|
|
||||||
/// If you set the asynchronous image's URL to `nil`, or after you set the
|
|
||||||
/// URL to a value but before the load operation completes, the phase is
|
|
||||||
/// ``AsyncImagePhase/empty``. After the operation completes, the phase
|
|
||||||
/// becomes either ``AsyncImagePhase/failure(_:)`` or
|
|
||||||
/// ``AsyncImagePhase/success(_:)``. In the first case, the phase's
|
|
||||||
/// ``AsyncImagePhase/error`` value indicates the reason for failure.
|
|
||||||
/// In the second case, the phase's ``AsyncImagePhase/image`` property
|
|
||||||
/// contains the loaded image. Use the phase to drive the output of the
|
|
||||||
/// `content` closure, which defines the view's appearance:
|
|
||||||
///
|
|
||||||
/// CachedAsyncImage(url: URL(string: "https://example.com/icon.png")) { phase in
|
|
||||||
/// if let image = phase.image {
|
|
||||||
/// image // Displays the loaded image.
|
|
||||||
/// } else if phase.error != nil {
|
|
||||||
/// Color.red // Indicates an error.
|
|
||||||
/// } else {
|
|
||||||
/// Color.blue // Acts as a placeholder.
|
|
||||||
/// }
|
|
||||||
/// }
|
|
||||||
///
|
|
||||||
/// To add transitions when you change the URL, apply an identifier to the
|
|
||||||
/// ``CachedAsyncImage``.
|
|
||||||
///
|
|
||||||
/// - Parameters:
|
|
||||||
/// - urlRequest: The URL request of the image to display.
|
|
||||||
/// - urlCache: The URL cache for providing cached responses to requests within the session.
|
|
||||||
/// - scale: The scale to use for the image. The default is `1`. Set a
|
|
||||||
/// different value when loading images designed for higher resolution
|
|
||||||
/// displays. For example, set a value of `2` for an image that you
|
|
||||||
/// would name with the `@2x` suffix if stored in a file on disk.
|
|
||||||
/// - transaction: The transaction to use when the phase changes.
|
|
||||||
/// - content: A closure that takes the load phase as an input, and
|
|
||||||
/// returns the view to display for the specified phase.
|
|
||||||
public init(urlRequest: URLRequest?, urlCache: URLCache = .shared, scale: CGFloat = 1, transaction: Transaction = Transaction(), @ViewBuilder content: @escaping (AsyncGiffyPhase) -> Content) {
|
|
||||||
let configuration = URLSessionConfiguration.default
|
|
||||||
configuration.urlCache = urlCache
|
|
||||||
self.urlRequest = urlRequest
|
|
||||||
self.urlSession = URLSession(configuration: configuration)
|
|
||||||
self.scale = scale
|
|
||||||
self.transaction = transaction
|
|
||||||
self.content = content
|
|
||||||
|
|
||||||
self._phase = State(wrappedValue: .loading)
|
|
||||||
do {
|
|
||||||
if let urlRequest = urlRequest, let image = try cachedGif(from: urlRequest, cache: urlCache) {
|
|
||||||
self._phase = State(wrappedValue: .success(image))
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
self._phase = State(wrappedValue: .error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Sendable
|
|
||||||
private func load() async {
|
|
||||||
do {
|
|
||||||
if let urlRequest = urlRequest {
|
|
||||||
let (image, metrics) = try await remoteGif(from: urlRequest, session: urlSession)
|
|
||||||
if metrics.transactionMetrics.last?.resourceFetchType == .localCache {
|
|
||||||
// WARNING: This does not behave well when the url is changed with another
|
|
||||||
phase = .success(image)
|
|
||||||
} else {
|
|
||||||
withAnimation(transaction.animation) {
|
|
||||||
phase = .success(image)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
withAnimation(transaction.animation) {
|
|
||||||
phase = .loading
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
withAnimation(transaction.animation) {
|
|
||||||
phase = .error
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Helpers
|
|
||||||
|
|
||||||
@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *)
|
|
||||||
private extension CachedAsyncGiffy {
|
|
||||||
private func remoteGif(from request: URLRequest, session: URLSession) async throws -> (Giffy, URLSessionTaskMetrics) {
|
|
||||||
let (data, _, metrics) = try await session.data(for: request)
|
|
||||||
if metrics.redirectCount > 0, let lastResponse = metrics.transactionMetrics.last?.response {
|
|
||||||
let requests = metrics.transactionMetrics.map { $0.request }
|
|
||||||
requests.forEach(session.configuration.urlCache!.removeCachedResponse)
|
|
||||||
let lastCachedResponse = CachedURLResponse(response: lastResponse, data: data)
|
|
||||||
session.configuration.urlCache!.storeCachedResponse(lastCachedResponse, for: request)
|
|
||||||
}
|
|
||||||
return (try gif(from: data), metrics)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func cachedGif(from request: URLRequest, cache: URLCache) throws -> Giffy? {
|
|
||||||
guard let cachedResponse = cache.cachedResponse(for: request) else { return nil }
|
|
||||||
return try gif(from: cachedResponse.data)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func gif(from data: Data) throws -> Giffy {
|
|
||||||
#if os(macOS)
|
|
||||||
if let nsImage = NSImage(data: data) {
|
|
||||||
return Giffy(nsImage: nsImage)
|
|
||||||
} else {
|
|
||||||
throw AsyncGiffy<Content>.LoadingError()
|
|
||||||
}
|
|
||||||
#else
|
|
||||||
return Giffy(imageData: data)
|
|
||||||
#endif
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - AsyncImageURLSession
|
|
||||||
|
|
||||||
private class URLSessionTaskController: NSObject, URLSessionTaskDelegate {
|
|
||||||
|
|
||||||
var metrics: URLSessionTaskMetrics?
|
|
||||||
|
|
||||||
func urlSession(_ session: URLSession, task: URLSessionTask, didFinishCollecting metrics: URLSessionTaskMetrics) {
|
|
||||||
self.metrics = metrics
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *)
|
|
||||||
private extension URLSession {
|
|
||||||
|
|
||||||
func data(for request: URLRequest) async throws -> (Data, URLResponse, URLSessionTaskMetrics) {
|
|
||||||
let controller = URLSessionTaskController()
|
|
||||||
let (data, response) = try await data(for: request, delegate: controller)
|
|
||||||
return (data, response, controller.metrics!)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,169 @@
|
|||||||
|
//
|
||||||
|
// File.swift
|
||||||
|
//
|
||||||
|
//
|
||||||
|
// Created by Igor Ferreira on 06/04/2022.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
public extension GIFImage {
|
||||||
|
|
||||||
|
/// `GIFImage` is a `View` that loads a `Data` object from a source into `CoreImage.CGImageSource`, parse the image source
|
||||||
|
/// into frames and stream them based in the "Delay" key packaged on which frame item.
|
||||||
|
///
|
||||||
|
/// - Parameters:
|
||||||
|
/// - url: URL string of the image. If the string cannot be parsed into URL, this constructor early aborts and returns `nil`
|
||||||
|
/// - animate: A flag to indicate that GIF should animate or not. If non-animated, the first frame will be displayed
|
||||||
|
/// - loop: Flag to indicate if the GIF should be played only once or continue to loop
|
||||||
|
/// - placeholder: Image to be used before the source is loaded
|
||||||
|
/// - errorImage: If the stream fails, this image is used
|
||||||
|
/// - frameRate: Option to control the frame rate of the animation or to use the GIF information about frame rate
|
||||||
|
/// - loopAction: Closure called whenever the GIF finishes rendering one cycle of the action
|
||||||
|
init?(
|
||||||
|
url: String,
|
||||||
|
animate: Bool,
|
||||||
|
loop: Bool,
|
||||||
|
placeholder: RawImage = RawImage(),
|
||||||
|
errorImage: RawImage? = nil,
|
||||||
|
frameRate: FrameRate = .dynamic,
|
||||||
|
loopAction: @Sendable @escaping (GIFSource) async throws -> Void = { _ in }
|
||||||
|
) {
|
||||||
|
self.init(
|
||||||
|
url: url,
|
||||||
|
animate: .constant(animate),
|
||||||
|
loop: .constant(loop),
|
||||||
|
placeholder: placeholder,
|
||||||
|
errorImage: errorImage,
|
||||||
|
frameRate: frameRate,
|
||||||
|
loopAction: loopAction
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `GIFImage` is a `View` that loads a `Data` object from a source into `CoreImage.CGImageSource`, parse the image source
|
||||||
|
/// into frames and stream them based in the "Delay" key packaged on which frame item.
|
||||||
|
///
|
||||||
|
/// - Parameters:
|
||||||
|
/// - url: URL string of the image. If the string cannot be parsed into URL, this constructor early aborts and returns `nil`
|
||||||
|
/// - animate: A flag to indicate that GIF should animate or not. If non-animated, the first frame will be displayed
|
||||||
|
/// - loop: Flag to indicate if the GIF should be played only once or continue to loop
|
||||||
|
/// - placeholder: Image to be used before the source is loaded
|
||||||
|
/// - errorImage: If the stream fails, this image is used
|
||||||
|
/// - frameRate: Option to control the frame rate of the animation or to use the GIF information about frame rate
|
||||||
|
/// - loopAction: Closure called whenever the GIF finishes rendering one cycle of the action
|
||||||
|
init?(
|
||||||
|
url: String,
|
||||||
|
animate: Binding<Bool> = Binding.constant(true),
|
||||||
|
loop: Binding<Bool> = Binding.constant(true),
|
||||||
|
placeholder: RawImage = RawImage(),
|
||||||
|
errorImage: RawImage? = nil,
|
||||||
|
frameRate: FrameRate = .dynamic,
|
||||||
|
loopAction: @Sendable @escaping (GIFSource) async throws -> Void = { _ in }
|
||||||
|
) {
|
||||||
|
guard let resolvedURL = URL(string: url) else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
self.init(
|
||||||
|
source: GIFSource.remoteURL(resolvedURL),
|
||||||
|
animate: animate,
|
||||||
|
loop: loop,
|
||||||
|
placeholder: placeholder,
|
||||||
|
errorImage: errorImage,
|
||||||
|
frameRate: frameRate,
|
||||||
|
loopAction: loopAction
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `GIFImage` is a `View` that loads a `Data` object from a source into `CoreImage.CGImageSource`, parse the image source
|
||||||
|
/// into frames and stream them based in the "Delay" key packaged on which frame item.
|
||||||
|
///
|
||||||
|
/// - Parameters:
|
||||||
|
/// - url: URL of the image. The response is cached using `URLCache`
|
||||||
|
/// - animate: A flag to indicate that GIF should animate or not. If non-animated, the first frame will be displayed
|
||||||
|
/// - loop: Flag to indicate if the GIF should be played only once or continue to loop
|
||||||
|
/// - placeholder: Image to be used before the source is loaded
|
||||||
|
/// - errorImage: If the stream fails, this image is used
|
||||||
|
/// - frameRate: Option to control the frame rate of the animation or to use the GIF information about frame rate
|
||||||
|
/// - loopAction: Closure called whenever the GIF finishes rendering one cycle of the action
|
||||||
|
init(
|
||||||
|
url: URL,
|
||||||
|
animate: Bool = true,
|
||||||
|
loop: Bool = true,
|
||||||
|
placeholder: RawImage = RawImage(),
|
||||||
|
errorImage: RawImage? = nil,
|
||||||
|
frameRate: FrameRate = .dynamic,
|
||||||
|
loopAction: @Sendable @escaping (GIFSource) async throws -> Void = { _ in }
|
||||||
|
) {
|
||||||
|
self.init(
|
||||||
|
url: url,
|
||||||
|
animate: .constant(animate),
|
||||||
|
loop: .constant(loop),
|
||||||
|
placeholder: placeholder,
|
||||||
|
errorImage: errorImage,
|
||||||
|
frameRate: frameRate,
|
||||||
|
loopAction: loopAction
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `GIFImage` is a `View` that loads a `Data` object from a source into `CoreImage.CGImageSource`, parse the image source
|
||||||
|
/// into frames and stream them based in the "Delay" key packaged on which frame item.
|
||||||
|
///
|
||||||
|
/// - Parameters:
|
||||||
|
/// - url: URL of the image. The response is cached using `URLCache`
|
||||||
|
/// - animate: A flag to indicate that GIF should animate or not. If non-animated, the first frame will be displayed
|
||||||
|
/// - loop: Flag to indicate if the GIF should be played only once or continue to loop
|
||||||
|
/// - placeholder: Image to be used before the source is loaded
|
||||||
|
/// - errorImage: If the stream fails, this image is used
|
||||||
|
/// - frameRate: Option to control the frame rate of the animation or to use the GIF information about frame rate
|
||||||
|
/// - loopAction: Closure called whenever the GIF finishes rendering one cycle of the action
|
||||||
|
init(
|
||||||
|
url: URL,
|
||||||
|
animate: Binding<Bool> = Binding.constant(true),
|
||||||
|
loop: Binding<Bool> = Binding.constant(true),
|
||||||
|
placeholder: RawImage = RawImage(),
|
||||||
|
errorImage: RawImage? = nil,
|
||||||
|
frameRate: FrameRate = .dynamic,
|
||||||
|
loopAction: @Sendable @escaping (GIFSource) async throws -> Void = { _ in }
|
||||||
|
) {
|
||||||
|
self.init(
|
||||||
|
source: GIFSource.remoteURL(url),
|
||||||
|
animate: animate,
|
||||||
|
loop: loop,
|
||||||
|
placeholder: placeholder,
|
||||||
|
errorImage: errorImage,
|
||||||
|
frameRate: frameRate,
|
||||||
|
loopAction: loopAction
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `GIFImage` is a `View` that loads a `Data` object from a source into `CoreImage.CGImageSource`, parse the image source
|
||||||
|
/// into frames and stream them based in the "Delay" key packaged on which frame item.
|
||||||
|
///
|
||||||
|
/// - Parameters:
|
||||||
|
/// - request: `URLRequest` of the image. The response is cached using `URLCache`
|
||||||
|
/// - animate: A flag to indicate that GIF should animate or not. If non-animated, the first frame will be displayed
|
||||||
|
/// - loop: Flag to indicate if the GIF should be played only once or continue to loop
|
||||||
|
/// - placeholder: Image to be used before the source is loaded
|
||||||
|
/// - errorImage: If the stream fails, this image is used
|
||||||
|
/// - frameRate: Option to control the frame rate of the animation or to use the GIF information about frame rate
|
||||||
|
/// - loopAction: Closure called whenever the GIF finishes rendering one cycle of the action
|
||||||
|
init(
|
||||||
|
request: URLRequest,
|
||||||
|
animate: Binding<Bool> = Binding.constant(true),
|
||||||
|
loop: Binding<Bool> = Binding.constant(true),
|
||||||
|
placeholder: RawImage = RawImage(),
|
||||||
|
errorImage: RawImage? = nil,
|
||||||
|
frameRate: FrameRate = .dynamic,
|
||||||
|
loopAction: @Sendable @escaping (GIFSource) async throws -> Void = { _ in }
|
||||||
|
) {
|
||||||
|
self.init(
|
||||||
|
source: GIFSource.remoteRequest(request),
|
||||||
|
animate: animate,
|
||||||
|
loop: loop,
|
||||||
|
placeholder: placeholder,
|
||||||
|
errorImage: errorImage,
|
||||||
|
frameRate: frameRate,
|
||||||
|
loopAction: loopAction
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
/// `GIFImage` is a `View` that loads a `Data` object from a source into `CoreImage.CGImageSource`, parse the image source
|
||||||
|
/// into frames and stream them based in the "Delay" key packaged on which frame item. The view will use the `ImageLoader` from the environment
|
||||||
|
/// to convert the fetch the `Data`
|
||||||
|
public struct GIFImage: View {
|
||||||
|
public let source: GIFSource
|
||||||
|
public let placeholder: RawImage
|
||||||
|
public let errorImage: RawImage?
|
||||||
|
private let presentationController: PresentationController
|
||||||
|
|
||||||
|
@Environment(\.imageLoader) var imageLoader
|
||||||
|
@State @MainActor private var frame: RawImage?
|
||||||
|
@Binding public var loop: Bool
|
||||||
|
@Binding public var animate: Bool
|
||||||
|
@State private var presentationTask: Task<(), Never>?
|
||||||
|
|
||||||
|
/// `GIFImage` is a `View` that loads a `Data` object from a source into `CoreImage.CGImageSource`, parse the image source
|
||||||
|
/// into frames and stream them based in the "Delay" key packaged on which frame item.
|
||||||
|
///
|
||||||
|
/// - Parameters:
|
||||||
|
/// - source: Source of the image. If the source is remote, the response is cached using `URLCache`
|
||||||
|
/// - animate: A flag to indicate that GIF should animate or not. If non-animated, the first frame will be displayed
|
||||||
|
/// - loop: Flag to indicate if the GIF should be played only once or continue to loop
|
||||||
|
/// - placeholder: Image to be used before the source is loaded
|
||||||
|
/// - errorImage: If the stream fails, this image is used
|
||||||
|
/// - frameRate: Option to control the frame rate of the animation or to use the GIF information about frame rate
|
||||||
|
/// - loopAction: Closure called whenever the GIF finishes rendering one cycle of the action
|
||||||
|
public init(
|
||||||
|
source: GIFSource,
|
||||||
|
animate: Bool,
|
||||||
|
loop: Bool,
|
||||||
|
placeholder: RawImage = RawImage(),
|
||||||
|
errorImage: RawImage? = nil,
|
||||||
|
frameRate: FrameRate = .dynamic,
|
||||||
|
loopAction: @Sendable @escaping (GIFSource) async throws -> Void = { _ in }
|
||||||
|
) {
|
||||||
|
self.init(
|
||||||
|
source: source,
|
||||||
|
animate: .constant(animate),
|
||||||
|
loop: .constant(loop),
|
||||||
|
placeholder: placeholder,
|
||||||
|
errorImage: errorImage,
|
||||||
|
frameRate: frameRate,
|
||||||
|
loopAction: loopAction
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `GIFImage` is a `View` that loads a `Data` object from a source into `CoreImage.CGImageSource`, parse the image source
|
||||||
|
/// into frames and stream them based in the "Delay" key packaged on which frame item.
|
||||||
|
///
|
||||||
|
/// - Parameters:
|
||||||
|
/// - source: Source of the image. If the source is remote, the response is cached using `URLCache`
|
||||||
|
/// - loop: Flag to indicate if the GIF should be played only once or continue to loop
|
||||||
|
/// - placeholder: Image to be used before the source is loaded
|
||||||
|
/// - errorImage: If the stream fails, this image is used
|
||||||
|
/// - frameRate: Option to control the frame rate of the animation or to use the GIF information about frame rate
|
||||||
|
/// - loopAction: Closure called whenever the GIF finishes rendering one cycle of the action
|
||||||
|
public init(
|
||||||
|
source: GIFSource,
|
||||||
|
animate: Binding<Bool> = Binding.constant(true),
|
||||||
|
loop: Binding<Bool> = Binding.constant(true),
|
||||||
|
placeholder: RawImage = RawImage(),
|
||||||
|
errorImage: RawImage? = nil,
|
||||||
|
frameRate: FrameRate = .dynamic,
|
||||||
|
loopAction: @Sendable @escaping (GIFSource) async throws -> Void = { _ in }
|
||||||
|
) {
|
||||||
|
self.source = source
|
||||||
|
self._animate = animate
|
||||||
|
self._loop = loop
|
||||||
|
self.placeholder = placeholder
|
||||||
|
self.errorImage = errorImage
|
||||||
|
|
||||||
|
self.presentationController = PresentationController(
|
||||||
|
source: source,
|
||||||
|
frameRate: frameRate,
|
||||||
|
animate: animate,
|
||||||
|
loop: loop,
|
||||||
|
action: loopAction
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
public var body: some View {
|
||||||
|
Image.loadImage(with: frame ?? placeholder)
|
||||||
|
.resizable()
|
||||||
|
.scaledToFit()
|
||||||
|
.onChange(of: loop) { newValue in
|
||||||
|
handle(loop: newValue)
|
||||||
|
}
|
||||||
|
.onChange(of: animate) { newValue in
|
||||||
|
handle(animate: newValue)
|
||||||
|
}
|
||||||
|
.task(id: source, load)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func handle(animate: Bool) {
|
||||||
|
if animate {
|
||||||
|
load()
|
||||||
|
} else {
|
||||||
|
presentationTask?.cancel()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func handle(loop: Bool) {
|
||||||
|
if loop { load() }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Sendable private func load() {
|
||||||
|
presentationTask?.cancel()
|
||||||
|
presentationTask = Task { await presentationController.start(
|
||||||
|
imageLoader: imageLoader,
|
||||||
|
fallbackImage: errorImage ?? placeholder,
|
||||||
|
frameUpdate: setFrame(_:)
|
||||||
|
)}
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
@Sendable private func setFrame(_ frame: RawImage) async {
|
||||||
|
self.frame = frame
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#if DEBUG
|
||||||
|
let placeholder = RawImage.create(symbol: "photo.circle.fill")!
|
||||||
|
let error = RawImage.create(symbol: "xmark.octagon")
|
||||||
|
let gifURL = "https://raw.githubusercontent.com/igorcferreira/GIFImage/main/Tests/test.gif"
|
||||||
|
#Preview("Raw URL") {
|
||||||
|
GIFImage(url: gifURL, placeholder: placeholder, errorImage: error)
|
||||||
|
}
|
||||||
|
#Preview("Limited 5 FPS") {
|
||||||
|
GIFImage(url: gifURL, placeholder: placeholder, errorImage: error, frameRate: .limited(fps: 5))
|
||||||
|
}
|
||||||
|
#Preview("Limited to 30 FPS") {
|
||||||
|
GIFImage(url: gifURL, placeholder: placeholder, errorImage: error, frameRate: .static(fps: 30))
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
//
|
||||||
|
// File.swift
|
||||||
|
//
|
||||||
|
//
|
||||||
|
// Created by Igor Ferreira on 05/04/2022.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import ImageIO
|
||||||
|
|
||||||
|
public actor CGImageSourceFrameSequence: AsyncSequence {
|
||||||
|
public typealias Element = ImageFrame
|
||||||
|
|
||||||
|
public enum LoadError: Error {
|
||||||
|
case invalidData
|
||||||
|
case emptyData
|
||||||
|
}
|
||||||
|
|
||||||
|
public let source: CGImageSource
|
||||||
|
|
||||||
|
public init(data: Data) throws {
|
||||||
|
guard let source = CGImageSourceCreateWithData(data as CFData, nil) else {
|
||||||
|
throw LoadError.invalidData
|
||||||
|
}
|
||||||
|
|
||||||
|
switch CGImageSourceGetStatus(source) {
|
||||||
|
case .statusComplete: break
|
||||||
|
case .statusReadingHeader: break
|
||||||
|
case .statusIncomplete: throw LoadError.emptyData
|
||||||
|
case .statusInvalidData: throw LoadError.invalidData
|
||||||
|
case .statusUnexpectedEOF: throw LoadError.invalidData
|
||||||
|
case .statusUnknownType: throw LoadError.invalidData
|
||||||
|
@unknown default: throw LoadError.invalidData
|
||||||
|
}
|
||||||
|
|
||||||
|
self.init(source: source)
|
||||||
|
}
|
||||||
|
|
||||||
|
public init(source: CGImageSource) {
|
||||||
|
self.source = source
|
||||||
|
}
|
||||||
|
|
||||||
|
nonisolated public func makeAsyncIterator() -> CGImageSourceIterator {
|
||||||
|
CGImageSourceIterator(source: source)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
//
|
||||||
|
// File.swift
|
||||||
|
//
|
||||||
|
//
|
||||||
|
// Created by Igor Ferreira on 05/04/2022.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import ImageIO
|
||||||
|
|
||||||
|
public actor CGImageSourceIterator: AsyncIteratorProtocol {
|
||||||
|
|
||||||
|
public let frameCount: Int
|
||||||
|
public let source: CGImageSource
|
||||||
|
public private(set) var currentFrame: Int
|
||||||
|
|
||||||
|
public init(source: CGImageSource) {
|
||||||
|
self.source = source
|
||||||
|
self.frameCount = CGImageSourceGetCount(source)
|
||||||
|
self.currentFrame = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
public func next() async throws -> ImageFrame? {
|
||||||
|
guard currentFrame < frameCount else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
let frame: ImageFrame? = if let image = CGImageSourceCreateImageAtIndex(source, currentFrame, nil) {
|
||||||
|
ImageFrame(image: image, interval: source.intervalAtIndex(currentFrame))
|
||||||
|
} else {
|
||||||
|
nil
|
||||||
|
}
|
||||||
|
currentFrame += 1
|
||||||
|
return frame
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
//
|
||||||
|
// File.swift
|
||||||
|
//
|
||||||
|
//
|
||||||
|
// Created by Igor Ferreira on 05/04/2022.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Object responsible to load a `GIFSource` into a `ImageFrame` stream.
|
||||||
|
public struct ImageLoader {
|
||||||
|
public let session: URLSession
|
||||||
|
public let cache: URLCache
|
||||||
|
public let fileManager: FileManager
|
||||||
|
|
||||||
|
public init(session: URLSession = .shared, cache: URLCache = .shared, fileManager: FileManager = .default) {
|
||||||
|
self.session = session
|
||||||
|
self.cache = cache
|
||||||
|
self.fileManager = fileManager
|
||||||
|
}
|
||||||
|
|
||||||
|
public func load(source: GIFSource) async throws -> CGImageSourceFrameSequence {
|
||||||
|
let data = try await source.loadData(session: session, cache: cache, fileManager: fileManager)
|
||||||
|
return try data.imageAsyncSequence()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private extension GIFSource {
|
||||||
|
func loadData(session: URLSession, cache: URLCache, fileManager: FileManager) async throws -> Data {
|
||||||
|
switch self {
|
||||||
|
case let .static(data): data
|
||||||
|
case .remote(let url), .remoteURL(let url): try await url.loadData(session: session, cache: cache)
|
||||||
|
case let .remoteRequest(request): try await request.loadData(session: session, cache: cache)
|
||||||
|
case let .local(filePath): try await filePath.loadData(fileManager: fileManager)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private extension String {
|
||||||
|
func loadData(fileManager: FileManager) async throws -> Data {
|
||||||
|
guard fileManager.fileExists(atPath: self) else {
|
||||||
|
throw URLError(.fileDoesNotExist)
|
||||||
|
}
|
||||||
|
guard let data = fileManager.contents(atPath: self) else {
|
||||||
|
throw URLError(.cannotOpenFile)
|
||||||
|
}
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private extension URL {
|
||||||
|
func loadData(session: URLSession, cache: URLCache) async throws -> Data {
|
||||||
|
let request = URLRequest(url: self)
|
||||||
|
return try await request.loadData(session: session, cache: cache)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private extension URLRequest {
|
||||||
|
func loadData(session: URLSession, cache: URLCache) async throws -> Data {
|
||||||
|
if let cache = cache.cachedResponse(for: self) {
|
||||||
|
return cache.data
|
||||||
|
}
|
||||||
|
|
||||||
|
let (data, response) = try await session.data(for: self)
|
||||||
|
guard let httpResponse = response as? HTTPURLResponse else {
|
||||||
|
throw URLError(.badServerResponse)
|
||||||
|
}
|
||||||
|
guard httpResponse.isSuccess else {
|
||||||
|
throw URLError(.init(rawValue: httpResponse.statusCode))
|
||||||
|
}
|
||||||
|
cache.storeCachedResponse(CachedURLResponse(response: response, data: data), for: self)
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private extension HTTPURLResponse {
|
||||||
|
var isSuccess: Bool {
|
||||||
|
200..<300 ~= statusCode
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
//
|
||||||
|
// File.swift
|
||||||
|
//
|
||||||
|
//
|
||||||
|
// Created by Igor Ferreira on 9/9/22.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
private let kDefaultGIFFrameInterval: TimeInterval = 1.0 / 24.0
|
||||||
|
|
||||||
|
struct PresentationController {
|
||||||
|
let source: GIFSource
|
||||||
|
let frameRate: FrameRate
|
||||||
|
let action: (GIFSource) async throws -> Void
|
||||||
|
@Binding var animate: Bool
|
||||||
|
@Binding var loop: Bool
|
||||||
|
|
||||||
|
init(
|
||||||
|
source: GIFSource,
|
||||||
|
frameRate: FrameRate,
|
||||||
|
animate: Binding<Bool>,
|
||||||
|
loop: Binding<Bool>,
|
||||||
|
action: @Sendable @escaping (GIFSource) async throws -> Void = { _ in }
|
||||||
|
) {
|
||||||
|
self.source = source
|
||||||
|
self.action = action
|
||||||
|
self.frameRate = frameRate
|
||||||
|
self._animate = animate
|
||||||
|
self._loop = loop
|
||||||
|
}
|
||||||
|
|
||||||
|
func start(imageLoader: ImageLoader, fallbackImage: RawImage, frameUpdate: (RawImage) async -> Void) async {
|
||||||
|
do {
|
||||||
|
repeat {
|
||||||
|
for try await imageFrame in try await imageLoader.load(source: source) {
|
||||||
|
try await update(imageFrame, frameUpdate: frameUpdate)
|
||||||
|
if !animate { break }
|
||||||
|
}
|
||||||
|
try await action(source)
|
||||||
|
} while(self.loop && self.animate)
|
||||||
|
} catch {
|
||||||
|
if !(error is CancellationError) {
|
||||||
|
await frameUpdate(fallbackImage)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func update(_ imageFrame: ImageFrame, frameUpdate: (RawImage) async -> Void) async throws {
|
||||||
|
await frameUpdate(RawImage.create(with: imageFrame.image))
|
||||||
|
let calculatedInterval = imageFrame.interval ?? kDefaultGIFFrameInterval
|
||||||
|
let interval: Double
|
||||||
|
switch frameRate {
|
||||||
|
case .static(let fps):
|
||||||
|
interval = (1.0 / Double(fps))
|
||||||
|
case .limited(let fps):
|
||||||
|
let intervalLimit = (1.0 / Double(fps))
|
||||||
|
interval = max(calculatedInterval, intervalLimit)
|
||||||
|
case .dynamic:
|
||||||
|
interval = imageFrame.interval ?? kDefaultGIFFrameInterval
|
||||||
|
}
|
||||||
|
try await Task.sleep(nanoseconds: UInt64(interval * 1_000_000_000.0))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
//
|
||||||
|
// File.swift
|
||||||
|
//
|
||||||
|
//
|
||||||
|
// Created by Igor Ferreira on 05/04/2022.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import ImageIO
|
||||||
|
|
||||||
|
extension CFString {
|
||||||
|
var asKey: UnsafeMutableRawPointer {
|
||||||
|
return Unmanaged.passUnretained(self).toOpaque()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
//
|
||||||
|
// File.swift
|
||||||
|
//
|
||||||
|
//
|
||||||
|
// Created by Igor Ferreira on 05/04/2022.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import ImageIO
|
||||||
|
|
||||||
|
extension CGImageSource {
|
||||||
|
func intervalAtIndex(_ index: Int) -> TimeInterval? {
|
||||||
|
guard let properties = CGImageSourceCopyPropertiesAtIndex(self, index, nil) else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let gifProperties = CFDictionaryGetValue(properties, kCGImagePropertyGIFDictionary.asKey) else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
let dictionary = unsafeBitCast(gifProperties, to: CFDictionary.self)
|
||||||
|
|
||||||
|
let pointer: UnsafeRawPointer
|
||||||
|
if let delay = CFDictionaryGetValue(dictionary, kCGImagePropertyGIFDelayTime.asKey) {
|
||||||
|
pointer = delay
|
||||||
|
} else if let unclampedDelay = CFDictionaryGetValue(dictionary, kCGImagePropertyGIFUnclampedDelayTime.asKey) {
|
||||||
|
pointer = unclampedDelay
|
||||||
|
} else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
let interval = unsafeBitCast(pointer, to: AnyObject.self).doubleValue ?? 0.0
|
||||||
|
if interval > 0.0 {
|
||||||
|
return interval
|
||||||
|
} else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
//
|
||||||
|
// File.swift
|
||||||
|
//
|
||||||
|
//
|
||||||
|
// Created by Igor Ferreira on 05/04/2022.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import ImageIO
|
||||||
|
|
||||||
|
extension Data {
|
||||||
|
func imageAsyncSequence() throws -> CGImageSourceFrameSequence {
|
||||||
|
return try CGImageSourceFrameSequence(data: self)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
//
|
||||||
|
// File.swift
|
||||||
|
//
|
||||||
|
//
|
||||||
|
// Created by Igor Ferreira on 05/04/2022.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct GIFImageEnvironment: EnvironmentKey {
|
||||||
|
static var defaultValue: ImageLoader = {
|
||||||
|
return ImageLoader()
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
extension EnvironmentValues {
|
||||||
|
var imageLoader: ImageLoader {
|
||||||
|
get { self[GIFImageEnvironment.self] }
|
||||||
|
set { self[GIFImageEnvironment.self] = newValue }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension View {
|
||||||
|
@ViewBuilder
|
||||||
|
func with(imageLoader: ImageLoader) -> some View {
|
||||||
|
environment(\.imageLoader, imageLoader)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
//
|
||||||
|
// File.swift
|
||||||
|
//
|
||||||
|
//
|
||||||
|
// Created by Igor Ferreira on 06/04/2022.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
public enum FrameRate {
|
||||||
|
case dynamic
|
||||||
|
case limited(fps: Int)
|
||||||
|
case `static`(fps: Int)
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
//
|
||||||
|
// File.swift
|
||||||
|
//
|
||||||
|
//
|
||||||
|
// Created by Igor Ferreira on 29/10/2020.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
public enum GIFSource: Equatable {
|
||||||
|
@available(*, deprecated, renamed: "remoteURL(_:)")
|
||||||
|
case remote(url: URL)
|
||||||
|
case remoteURL(_ url: URL)
|
||||||
|
case remoteRequest(_ request: URLRequest)
|
||||||
|
case local(filePath: String)
|
||||||
|
case `static`(data: Data)
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
//
|
||||||
|
// File.swift
|
||||||
|
//
|
||||||
|
//
|
||||||
|
// Created by Igor Ferreira on 29/10/2020.
|
||||||
|
//
|
||||||
|
|
||||||
|
import ImageIO
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
public struct ImageFrame {
|
||||||
|
public let image: CGImage
|
||||||
|
public let interval: TimeInterval?
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
//
|
||||||
|
// File.swift
|
||||||
|
//
|
||||||
|
//
|
||||||
|
// Created by Igor Ferreira on 05/04/2022.
|
||||||
|
//
|
||||||
|
|
||||||
|
import ImageIO
|
||||||
|
import SwiftUI
|
||||||
|
#if os(macOS)
|
||||||
|
import AppKit
|
||||||
|
public typealias RawImage = NSImage
|
||||||
|
#else
|
||||||
|
import UIKit
|
||||||
|
public typealias RawImage = UIImage
|
||||||
|
#endif
|
||||||
|
|
||||||
|
public extension RawImage {
|
||||||
|
static func create(with cgImage: CGImage) -> RawImage {
|
||||||
|
#if os(macOS)
|
||||||
|
return RawImage(cgImage: cgImage, size: NSSize(width: cgImage.width, height: cgImage.height))
|
||||||
|
#else
|
||||||
|
return RawImage(cgImage: cgImage)
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
static func create(symbol: String) -> RawImage? {
|
||||||
|
#if os(macOS)
|
||||||
|
return RawImage(systemSymbolName: symbol, accessibilityDescription: nil)
|
||||||
|
#else
|
||||||
|
return RawImage(systemName: symbol)
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public extension Image {
|
||||||
|
static func loadImage(with image: RawImage) -> Image {
|
||||||
|
#if os(macOS)
|
||||||
|
return Image(nsImage: image)
|
||||||
|
#else
|
||||||
|
return Image(uiImage: image)
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,7 +7,6 @@
|
|||||||
|
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
import CachedAsyncImage
|
import CachedAsyncImage
|
||||||
import Giffy
|
|
||||||
|
|
||||||
let MIN_SIZE: CGFloat = 165
|
let MIN_SIZE: CGFloat = 165
|
||||||
|
|
||||||
@@ -57,33 +56,11 @@ struct ExploreView: View {
|
|||||||
}) {
|
}) {
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
if wallpaper.previewIsGif() {
|
if wallpaper.previewIsGif() {
|
||||||
CachedAsyncGiffy(url: cowabungaAPI.getPreviewURLForWallpaper(wallpaper: wallpaper), urlCache: .imageCache) { giffy in
|
GIFImage(url: cowabungaAPI.getPreviewURLForWallpaper(wallpaper: wallpaper), animate: true, loop: true)
|
||||||
giffy
|
|
||||||
.aspectRatio(contentMode: .fill)
|
.aspectRatio(contentMode: .fill)
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
.cornerRadius(10, corners: .topLeft)
|
.cornerRadius(10, corners: .topLeft)
|
||||||
.cornerRadius(10, corners: .topRight)
|
.cornerRadius(10, corners: .topRight)
|
||||||
} placeholder: {
|
|
||||||
Color.gray
|
|
||||||
.frame(height: MIN_SIZE)
|
|
||||||
}
|
|
||||||
// Non-cached code:
|
|
||||||
// AsyncGiffy(url: cowabungaAPI.getPreviewURLForWallpaper(wallpaper: wallpaper)) { phase in
|
|
||||||
// switch phase {
|
|
||||||
// case .loading:
|
|
||||||
// Color.gray
|
|
||||||
// .frame(height: MIN_SIZE)
|
|
||||||
// case .error:
|
|
||||||
// Color.red
|
|
||||||
// .frame(height: MIN_SIZE)
|
|
||||||
// case .success(let giffy):
|
|
||||||
// giffy
|
|
||||||
// .aspectRatio(contentMode: .fill)
|
|
||||||
// .frame(maxWidth: .infinity)
|
|
||||||
// .cornerRadius(10, corners: .topLeft)
|
|
||||||
// .cornerRadius(10, corners: .topRight)
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
} else {
|
} else {
|
||||||
CachedAsyncImage(url: cowabungaAPI.getPreviewURLForWallpaper(wallpaper: wallpaper), urlCache: .imageCache) { image in
|
CachedAsyncImage(url: cowabungaAPI.getPreviewURLForWallpaper(wallpaper: wallpaper), urlCache: .imageCache) { image in
|
||||||
image
|
image
|
||||||
|
|||||||
Reference in new issue
Block a user