diff --git a/Playgrounds/NetworkImage.playground/Pages/Creating_custom_network_image_styles.xcplaygroundpage/Contents.swift b/Playgrounds/NetworkImage.playground/Pages/Creating_custom_network_image_styles.xcplaygroundpage/Contents.swift new file mode 100644 index 0000000..80cd9a0 --- /dev/null +++ b/Playgrounds/NetworkImage.playground/Pages/Creating_custom_network_image_styles.xcplaygroundpage/Contents.swift @@ -0,0 +1,57 @@ +//: [Previous](@previous) + +import PlaygroundSupport +import SwiftUI + +import NetworkImage + +/*: + To add a custom appearance, create a type that conforms to the `NetworkImageStyle` protocol. You can customize a network image's appearance in all of its different states: loading, displaying an image or failed. + */ + +struct RoundedImageStyle: NetworkImageStyle { + var width: CGFloat? + var height: CGFloat? + + func makeBody(state: NetworkImageState) -> some View { + ZStack { + Color(.secondarySystemBackground) + + switch state { + case .loading: + EmptyView() + case let .image(image, _): + image + .resizable() + .aspectRatio(contentMode: .fill) + case .failed: + Image(systemName: "photo") + .foregroundColor(Color(.systemFill)) + } + } + .frame(width: width, height: height) + .clipShape(RoundedRectangle(cornerRadius: 5)) + } +} + +/*: + Then set the custom style for all network images within a view, using the `networkImageStyle(_:)` modifier: + */ + +struct ContentView: View { + var body: some View { + HStack { + NetworkImage(url: URL(string: "https://picsum.photos/id/1025/300/200")) + NetworkImage(url: URL(string: "https://picsum.photos/id/237/300/200")) + } + .networkImageStyle( + RoundedImageStyle(width: 200, height: 200) + ) + } +} + +//: [Next](@next) + +PlaygroundPage.current.setLiveView( + ContentView().frame(width: 500, height: 500) +) diff --git a/Playgrounds/NetworkImage.playground/Pages/Customizing_network_images.xcplaygroundpage/Contents.swift b/Playgrounds/NetworkImage.playground/Pages/Customizing_network_images.xcplaygroundpage/Contents.swift new file mode 100644 index 0000000..7a8cc12 --- /dev/null +++ b/Playgrounds/NetworkImage.playground/Pages/Customizing_network_images.xcplaygroundpage/Contents.swift @@ -0,0 +1,33 @@ +//: [Previous](@previous) + +import PlaygroundSupport +import SwiftUI + +import NetworkImage + +/*: + You can customize a network image's appearance by using a network image style. The default image style is an instance of `ResizableNetworkImageStyle` configured to `fill` the available space. To set a specific style for all network images within a view, you can use the `networkImageStyle(_:)` modifier. + */ + +struct ContentView: View { + var body: some View { + HStack { + NetworkImage(url: URL(string: "https://picsum.photos/id/1025/300/200")) + .frame(width: 200, height: 200) + NetworkImage(url: URL(string: "https://picsum.photos/id/237/300/200")) + .frame(width: 200, height: 200) + } + .networkImageStyle( + ResizableNetworkImageStyle( + backgroundColor: .yellow, + contentMode: .fit + ) + ) + } +} + +//: [Next](@next) + +PlaygroundPage.current.setLiveView( + ContentView().frame(width: 500, height: 500) +) diff --git a/Playgrounds/NetworkImage.playground/Pages/Displaying_network_images.xcplaygroundpage/Contents.swift b/Playgrounds/NetworkImage.playground/Pages/Displaying_network_images.xcplaygroundpage/Contents.swift new file mode 100644 index 0000000..32a5cef --- /dev/null +++ b/Playgrounds/NetworkImage.playground/Pages/Displaying_network_images.xcplaygroundpage/Contents.swift @@ -0,0 +1,26 @@ +//: [Previous](@previous) + +import PlaygroundSupport +import SwiftUI + +import NetworkImage + +/*: + You can use a `NetworkImage` view to display an image from a given URL. The download happens asynchronously, and the resulting image is cached both in disk and memory. + */ + +struct ContentView: View { + var body: some View { + NetworkImage(url: URL(string: "https://picsum.photos/id/237/300/200")) + .frame(width: 300, height: 200) + } +} + +/*: + By default, remote images are resizable and fill the available space while maintaining the aspect ratio. + */ +//: [Next](@next) + +PlaygroundPage.current.setLiveView( + ContentView().frame(width: 500, height: 500) +) diff --git a/Playgrounds/NetworkImage.playground/Pages/Displaying_network_images_UIKit.xcplaygroundpage/Contents.swift b/Playgrounds/NetworkImage.playground/Pages/Displaying_network_images_UIKit.xcplaygroundpage/Contents.swift new file mode 100644 index 0000000..a81b75e --- /dev/null +++ b/Playgrounds/NetworkImage.playground/Pages/Displaying_network_images_UIKit.xcplaygroundpage/Contents.swift @@ -0,0 +1,36 @@ +//: [Previous](@previous) + +import PlaygroundSupport +import UIKit + +import NetworkImage + +/*: + The simplest way to display remote images in UIKit is by using `NetworkImageView`. You need to provide the URL where the image is located and optionally configure a placeholder image that will be displayed if the download fails or the URL is `nil`. When there is no cached image for the given URL, and the download takes more than a specific time, the view performs a cross-fade transition between the placeholder and the result. + */ + +class MyViewController: UIViewController { + override func loadView() { + let view = UIView() + view.backgroundColor = .systemBackground + + let imageView = NetworkImageView() + imageView.url = URL(string: "https://picsum.photos/id/237/300/200") + + imageView.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(imageView) + + NSLayoutConstraint.activate([ + imageView.widthAnchor.constraint(equalToConstant: 300), + imageView.heightAnchor.constraint(equalToConstant: 200), + imageView.centerXAnchor.constraint(equalTo: view.centerXAnchor), + imageView.centerYAnchor.constraint(equalTo: view.centerYAnchor), + ]) + + self.view = view + } +} + +//: [Next](@next) + +PlaygroundPage.current.liveView = MyViewController() diff --git a/Playgrounds/NetworkImage.playground/Pages/Table_of_contents.xcplaygroundpage/Contents.swift b/Playgrounds/NetworkImage.playground/Pages/Table_of_contents.xcplaygroundpage/Contents.swift new file mode 100644 index 0000000..5a759c8 --- /dev/null +++ b/Playgrounds/NetworkImage.playground/Pages/Table_of_contents.xcplaygroundpage/Contents.swift @@ -0,0 +1,10 @@ +/*: + # NetworkImage + NetworkImage is a Swift µpackage that provides image downloading, caching, and displaying for your SwiftUI apps. It leverages the foundation URLCache, providing persistent and in-memory caches. + + 1. [Displaying network images](Displaying_network_images) + 1. [Customizing network images](Customizing_network_images) + 1. [Creating custom network image styles](Creating_custom_network_image_styles) + 1. [Displaying network images in UIKit](Displaying_network_images_UIKit) + 1. [Using `ImageDownloader`](Using_Imagedownloader) + */ diff --git a/Playgrounds/NetworkImage.playground/Pages/Using_ImageDownloader.xcplaygroundpage/Contents.swift b/Playgrounds/NetworkImage.playground/Pages/Using_ImageDownloader.xcplaygroundpage/Contents.swift new file mode 100644 index 0000000..a60af1c --- /dev/null +++ b/Playgrounds/NetworkImage.playground/Pages/Using_ImageDownloader.xcplaygroundpage/Contents.swift @@ -0,0 +1,58 @@ +//: [Previous](@previous) + +import Combine +import PlaygroundSupport +import UIKit + +import NetworkImage + +/*: + If you need a more customized behavior, like applying image transformations or providing custom animations, you can use the shared `ImageDownloader` object directly. + */ + +class MyViewController: UIViewController { + private lazy var imageView = UIImageView() + private var cancellables: Set = [] + + override func loadView() { + let view = UIView() + view.backgroundColor = .systemBackground + + imageView.translatesAutoresizingMaskIntoConstraints = false + imageView.backgroundColor = .secondarySystemBackground + view.addSubview(imageView) + + NSLayoutConstraint.activate([ + imageView.widthAnchor.constraint(equalToConstant: 300), + imageView.heightAnchor.constraint(equalToConstant: 200), + imageView.centerXAnchor.constraint(equalTo: view.centerXAnchor), + imageView.centerYAnchor.constraint(equalTo: view.centerYAnchor), + ]) + + self.view = view + } + + override func viewDidLoad() { + super.viewDidLoad() + + ImageDownloader.shared.image(for: URL(string: "https://picsum.photos/id/237/300/200")!) + .map { image in + // tint the image with a yellow color + UIGraphicsImageRenderer(size: image.size).image { _ in + image.draw(at: .zero) + UIColor.systemYellow.setFill() + UIRectFillUsingBlendMode(CGRect(origin: .zero, size: image.size), .multiply) + } + } + .replaceError(with: UIImage(systemName: "film")!) + .receive(on: DispatchQueue.main) + .sink(receiveValue: { [imageView] image in + imageView.image = image + }) + .store(in: &cancellables) + } +} + +//: [Next](@next) + +PlaygroundPage.current.liveView = MyViewController() diff --git a/Playgrounds/NetworkImage.playground/contents.xcplayground b/Playgrounds/NetworkImage.playground/contents.xcplayground new file mode 100644 index 0000000..577587b --- /dev/null +++ b/Playgrounds/NetworkImage.playground/contents.xcplayground @@ -0,0 +1,11 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/README.md b/README.md index 232ba34..a5f5e11 100644 --- a/README.md +++ b/README.md @@ -1,93 +1,192 @@ # NetworkImage -![Swift 5.2](https://img.shields.io/badge/Swift-5.2-orange.svg) -![Platforms](https://img.shields.io/badge/platforms-iOS+tvOS+watchOS-brightgreen.svg?style=flat) -[![Swift Package Manager](https://img.shields.io/badge/spm-compatible-brightgreen.svg?style=flat)](https://swift.org/package-manager) +[![CI](https://github.com/gonzalezreal/NetworkImage/workflows/CI/badge.svg)](https://github.com/gonzalezreal/NetworkImage/actions?query=workflow%3ACI) +[![](https://img.shields.io/endpoint?url=https%3A%2F%2Fswiftpackageindex.com%2Fapi%2Fpackages%2Fgonzalezreal%2FNetworkImage%2Fbadge%3Ftype%3Dswift-versions)](https://swiftpackageindex.com/gonzalezreal/NetworkImage) +[![](https://img.shields.io/endpoint?url=https%3A%2F%2Fswiftpackageindex.com%2Fapi%2Fpackages%2Fgonzalezreal%2FNetworkImage%2Fbadge%3Ftype%3Dplatforms)](https://swiftpackageindex.com/gonzalezreal/NetworkImage) [![Twitter: @gonzalezreal](https://img.shields.io/badge/twitter-@gonzalezreal-blue.svg?style=flat)](https://twitter.com/gonzalezreal) -**NetworkImage** is a Swift µpackage that provides image downloading and caching for your apps. It leverages the foundation [`URLCache`](https://developer.apple.com/documentation/foundation/urlcache), providing persistent and in-memory caches. +NetworkImage is a Swift µpackage that provides image downloading, caching, and displaying for your SwiftUI apps. It leverages the foundation URLCache, providing persistent and in-memory caches. -## Usage +You can explore all the capabilities of this package in the [companion playground](/Playgrounds/NetworkImage.playground). -The simplest way to display remote images in your UIKit app is by using `NetworkImageView`. This `UIView` subclass provides a `url` property to configure the image URL, and a `placeholder` property to configure the image that will be displayed when the URL is `nil` or the image fails to load. On top of that, it performs a cross-fade transition when the image takes more than a certain time to load. +* [Displaying network images](#displaying-network-images) +* [Customizing network images](#customizing-network-images) +* [Creating custom network image styles](#creating-custom-network-image-styles) +* [Displaying network images in UIKit](#displaying-network-images-UIKit) +* [Using the shared ImageDownloader](#using-the-shared-imageDownloader) +* [Installation](#installation) +* [Help & Feedback](#help--feedback) + +## Displaying network images +You can use a `NetworkImage` view to display an image from a given URL. The download happens asynchronously, and the resulting image is cached both in disk and memory. -```Swift -class MovieItemCell: UICollectionViewCell { - // ... - private lazy var imageView = NetworkImageView() - - override func prepareForReuse() { - super.prepareForReuse() - // cancels any ongoing image download and resets the view - imageView.prepareForReuse() - } - - func configure(with movieItem: MovieItem) { - // ... - imageView.url = movieItem.posterURL - imageView.placeholder = Image(systemName: "film") +```swift +struct ContentView: View { + var body: some View { + NetworkImage(url: URL(string: "https://picsum.photos/id/237/300/200")) + .frame(width: 300, height: 200) } } ``` -If you need a more customized behavior, like applying transformations to images or providing your custom animations and loading state, you can use the `ImageDownloader` object directly. - -```Swift -class MovieItemCell: UICollectionViewCell { - // ... - private lazy var imageView = ImageView() - private var cancellable: AnyCancellable? - - override func prepareForReuse() { - super.prepareForReuse() - cancellable?.cancel() - } - - func configure(with movieItem: MovieItem) { - // ... - cancellable = ImageDownloader.shared.image(for: movieItem.posterURL) - .map { $0.applySomeFancyEffect() } - .replaceError(with: Image(systemName: "film")!) - .receive(on: DispatchQueue.main) - .assign(to: \.image, on: imageView) +By default, remote images are resizable and fill the available space while maintaining the aspect ratio. + +## Customizing network images +You can customize a network image's appearance by using a network image style. The default image style is an instance of `ResizableNetworkImageStyle` configured to `fill` the available space. To set a specific style for all network images within a view, you can use the `networkImageStyle(_:)` modifier. + +```swift +struct ContentView: View { + var body: some View { + HStack { + NetworkImage(url: URL(string: "https://picsum.photos/id/1025/300/200")) + .frame(width: 200, height: 200) + NetworkImage(url: URL(string: "https://picsum.photos/id/237/300/200")) + .frame(width: 200, height: 200) + } + .networkImageStyle( + ResizableNetworkImageStyle( + backgroundColor: .yellow, + contentMode: .fit + ) + ) } } ``` -It is also straightforward to implement a SwiftUI view that displays a remote image, by using the `NetworkImage` view. +## Creating custom network image styles +To add a custom appearance, create a type that conforms to the `NetworkImageStyle` protocol. You can customize a network image's appearance in all of its different states: loading, displaying an image or failed. -```Swift -struct ContentView: View { - var url = URL(string: "https://image.tmdb.org/t/p/w300/9gk7adHYeDvHkCSEqAvQNLV5Uge.jpg") +```swift +struct RoundedImageStyle: NetworkImageStyle { + var width: CGFloat? + var height: CGFloat? - var body: some View { + func makeBody(state: NetworkImageState) -> some View { ZStack { Color(.secondarySystemBackground) - NetworkImage(url) { image in + + switch state { + case .loading: + EmptyView() + case let .image(image, _): image .resizable() .aspectRatio(contentMode: .fill) - } - onError: { + case .failed: Image(systemName: "photo") .foregroundColor(Color(.systemFill)) } } - .frame(width: 200, height: 300) - .clipShape(RoundedRectangle(cornerRadius: 4)) + .frame(width: width, height: height) + .clipShape(RoundedRectangle(cornerRadius: 5)) + } +} +``` + +Then set the custom style for all network images within a view, using the `networkImageStyle(_:)` modifier: + +```swift +struct ContentView: View { + var body: some View { + HStack { + NetworkImage(url: URL(string: "https://picsum.photos/id/1025/300/200")) + NetworkImage(url: URL(string: "https://picsum.photos/id/237/300/200")) + } + .networkImageStyle( + RoundedImageStyle(width: 200, height: 200) + ) } } ``` +## Displaying network images in UIKit +The simplest way to display remote images in UIKit is by using `NetworkImageView`. You need to provide the URL where the image is located and optionally configure a placeholder image that will be displayed if the download fails or the URL is `nil`. When there is no cached image for the given URL, and the download takes more than a specific time, the view performs a cross-fade transition between the placeholder and the result. + +```swift +class MyViewController: UIViewController { + override func loadView() { + let view = UIView() + view.backgroundColor = .systemBackground + + let imageView = NetworkImageView() + imageView.url = URL(string: "https://picsum.photos/id/237/300/200") + + imageView.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(imageView) + + NSLayoutConstraint.activate([ + imageView.widthAnchor.constraint(equalToConstant: 300), + imageView.heightAnchor.constraint(equalToConstant: 200), + imageView.centerXAnchor.constraint(equalTo: view.centerXAnchor), + imageView.centerYAnchor.constraint(equalTo: view.centerYAnchor), + ]) + + self.view = view + } +} +``` + +## Using the shared ImageDownloader +If you need a more customized behavior, like applying image transformations or providing custom animations, you can use the shared `ImageDownloader` object directly. + +
+ Click to expand! + + ```swift + class MyViewController: UIViewController { + private lazy var imageView = UIImageView() + private var cancellables: Set = [] + + override func loadView() { + let view = UIView() + view.backgroundColor = .systemBackground + + imageView.translatesAutoresizingMaskIntoConstraints = false + imageView.backgroundColor = .secondarySystemBackground + view.addSubview(imageView) + + NSLayoutConstraint.activate([ + imageView.widthAnchor.constraint(equalToConstant: 300), + imageView.heightAnchor.constraint(equalToConstant: 200), + imageView.centerXAnchor.constraint(equalTo: view.centerXAnchor), + imageView.centerYAnchor.constraint(equalTo: view.centerYAnchor), + ]) + + self.view = view + } + + override func viewDidLoad() { + super.viewDidLoad() + + ImageDownloader.shared.image(for: URL(string: "https://picsum.photos/id/237/300/200")!) + .map { image in + // tint the image with a yellow color + UIGraphicsImageRenderer(size: image.size).image { _ in + image.draw(at: .zero) + UIColor.systemYellow.setFill() + UIRectFillUsingBlendMode(CGRect(origin: .zero, size: image.size), .multiply) + } + } + .replaceError(with: UIImage(systemName: "film")!) + .receive(on: DispatchQueue.main) + .sink(receiveValue: { [imageView] image in + imageView.image = image + }) + .store(in: &cancellables) + } + } + ``` +
+ ## Installation **Using the Swift Package Manager** Add NetworkImage as a dependency to your `Package.swift` file. For more information, see the [Swift Package Manager documentation](https://github.com/apple/swift-package-manager/tree/master/Documentation). ``` -.package(url: "https://github.com/gonzalezreal/NetworkImage", from: "1.0.0") +.package(url: "https://github.com/gonzalezreal/NetworkImage", from: "1.1.0") ``` ## Help & Feedback - [Open an issue](https://github.com/gonzalezreal/NetworkImage/issues/new) if you need help, if you found a bug, or if you want to discuss a feature request. -- [Open a PR](https://github.com/gonzalezreal/NetworkImage/pull/new/master) if you want to make some change to `Reusable`. +- [Open a PR](https://github.com/gonzalezreal/NetworkImage/pull/new/master) if you want to make some change to `NetworkImage`. - Contact [@gonzalezreal](https://twitter.com/gonzalezreal) on Twitter. diff --git a/Sources/NetworkImage/Core/ImageCache.swift b/Sources/NetworkImage/Core/ImageCache.swift index 7ae45cc..7e9782d 100644 --- a/Sources/NetworkImage/Core/ImageCache.swift +++ b/Sources/NetworkImage/Core/ImageCache.swift @@ -1,6 +1,12 @@ import Foundation +/// An object that can temporarily store images, keyed by their URL. +/// +/// Any class implementing this protocol must allow adding and querying images from different threads. public protocol ImageCache: AnyObject { + /// Returns the image associated with a given URL. func image(for url: URL) -> OSImage? + + /// Stores the image in the cache, associated with the specified URL. func setImage(_ image: OSImage, for url: URL) } diff --git a/Sources/NetworkImage/Core/ImageDownloader.swift b/Sources/NetworkImage/Core/ImageDownloader.swift index be1ed47..1d679ab 100644 --- a/Sources/NetworkImage/Core/ImageDownloader.swift +++ b/Sources/NetworkImage/Core/ImageDownloader.swift @@ -2,16 +2,25 @@ import Combine import Foundation + /// An object that downloads and caches images. @available(macOS 10.15, iOS 13.0, tvOS 13.0, watchOS 6.0, *) public final class ImageDownloader { private let data: (URL) -> AnyPublisher<(data: Data, response: URLResponse), URLError> private let imageCache: ImageCache + /// The shared singleton image downloader. + /// + /// The shared image downloader uses the shared `URLCache` and provides + /// reasonable defaults for disk and memory caches. public static let shared = ImageDownloader( session: .imageLoading, imageCache: ImmediateImageCache() ) + /// Creates an image downloader. + /// - Parameters: + /// - session: The `URLSession` that will download the images. + /// - imageCache: An immediate cache to store the images in memory. public convenience init(session: URLSession, imageCache: ImageCache) { self.init( data: { @@ -30,6 +39,7 @@ self.imageCache = imageCache } + /// Returns a publisher that wraps an image download for a given URL. public func image(for url: URL) -> AnyPublisher { if let image = imageCache.image(for: url) { return Just(image) diff --git a/Sources/NetworkImage/Core/ImagePrefetcher.swift b/Sources/NetworkImage/Core/ImagePrefetcher.swift index 3c143fc..ad5dc4d 100644 --- a/Sources/NetworkImage/Core/ImagePrefetcher.swift +++ b/Sources/NetworkImage/Core/ImagePrefetcher.swift @@ -2,6 +2,36 @@ import Combine import Foundation + /// An `ImagePrefetcher` object and be used to preload images and warm the caches up, + /// providing a smoother user experience when scrolling through collection views. + /// + /// You can use an `ImagePrefetcher` instance to implement a prefetch data source in a + /// collection view: + /// + /// class MovieListViewController: UICollectionViewController, UICollectionViewDataSourcePrefetching { + /// private lazy var imagePrefetcher = ImagePrefetcher() + /// override func viewDidLoad() { + /// super.viewDidLoad() + /// collectionView.prefetchDataSource = self + /// } + /// + /// func collectionView(_: UICollectionView, prefetchItemsAt indexPaths: [IndexPath]) { + /// imagePrefetcher.prefetchImages(with: imageURLs(for: indexPaths)) + /// } + /// + /// func collectionView(_: UICollectionView, cancelPrefetchingForItemsAt indexPaths: [IndexPath]) { + /// imagePrefetcher.cancelPrefetchingImages(with: imageURLs(for: indexPaths)) + /// } + /// + /// func imageURLs(for indexPaths: [IndexPath]) -> Set { + /// Set( + /// indexPaths.map { + /// viewModel.item(at: $0) + /// } + /// .compactMap(\.imageURL) + /// ) + /// } + /// } @available(macOS 10.15, iOS 13.0, tvOS 13.0, watchOS 6.0, *) public final class ImagePrefetcher { private let session: URLSession diff --git a/Sources/NetworkImage/Core/ImmediateImageCache.swift b/Sources/NetworkImage/Core/ImmediateImageCache.swift index 04971ee..184e306 100644 --- a/Sources/NetworkImage/Core/ImmediateImageCache.swift +++ b/Sources/NetworkImage/Core/ImmediateImageCache.swift @@ -1,5 +1,6 @@ import Foundation +/// An in-memory `ImageCache`. public final class ImmediateImageCache: ImageCache { private let cache = NSCache() diff --git a/Sources/NetworkImage/Core/URLSession+NetworkImage.swift b/Sources/NetworkImage/Core/URLSession+NetworkImage.swift index 46f5540..c64fcc3 100644 --- a/Sources/NetworkImage/Core/URLSession+NetworkImage.swift +++ b/Sources/NetworkImage/Core/URLSession+NetworkImage.swift @@ -7,6 +7,7 @@ extension URLSession { static let timeoutInterval: TimeInterval = 15 } + /// Returns a `URLSession` optimized for image downloading. @available(macOS 10.15, iOS 13.0, tvOS 13.0, watchOS 6.0, *) public static var imageLoading: URLSession { let configuration = URLSessionConfiguration.default diff --git a/Sources/NetworkImage/SwiftUI/NetworkImage.swift b/Sources/NetworkImage/SwiftUI/NetworkImage.swift index 39ae921..2d060f1 100644 --- a/Sources/NetworkImage/SwiftUI/NetworkImage.swift +++ b/Sources/NetworkImage/SwiftUI/NetworkImage.swift @@ -13,11 +13,32 @@ } } + /// A view that displays a remote image. + /// + /// A network image downloads and displays an image from a given URL. + /// The download is asynchronous, and the result is cached both in disk and memory. + /// + /// You can create a network image by providing the URL where the image is located. + /// + /// NetworkImage(url: URL(string: "https://picsum.photos/id/237/300/200")) + /// + /// You can customize a network image's appearance in all of its different states: loading, + /// displaying an image or failed. To add a custom appearance, create a style that conforms to + /// the `NetworkImageStyle` protocol. To set a specific style for all network images within + /// a view, use the `networkImageStyle(_:)` modifier: + /// + /// VStack { + /// NetworkImage(url: URL(string: "https://picsum.photos/id/1025/300/200")) + /// NetworkImage(url: URL(string: "https://picsum.photos/id/237/300/200")) + /// } + /// .networkImageStyle(BackdropNetworkImageStyle()) @available(macOS 10.15, iOS 13.0, tvOS 13.0, watchOS 6.0, *) public struct NetworkImage: View { @Environment(\.networkImageStyle) private var networkImageStyle @ObservedObject private var store: NetworkImageStore + /// Creates a network image. + /// - Parameter url: The URL where the image is located. public init(url: URL?) { self.init(store: NetworkImageStore(url: url)) } @@ -28,7 +49,7 @@ public var body: some View { networkImageStyle.makeBody( - configuration: NetworkImageConfiguration(state: store.state) + state: NetworkImageState(state: store.state) ) } } diff --git a/Sources/NetworkImage/SwiftUI/NetworkImageConfiguration.swift b/Sources/NetworkImage/SwiftUI/NetworkImageState.swift similarity index 63% rename from Sources/NetworkImage/SwiftUI/NetworkImageConfiguration.swift rename to Sources/NetworkImage/SwiftUI/NetworkImageState.swift index 39a7e60..1af267b 100644 --- a/Sources/NetworkImage/SwiftUI/NetworkImageConfiguration.swift +++ b/Sources/NetworkImage/SwiftUI/NetworkImageState.swift @@ -2,15 +2,24 @@ import SwiftUI + /// The state of a network image. @available(macOS 10.15, iOS 13.0, tvOS 13.0, watchOS 6.0, *) - public enum NetworkImageConfiguration { + public enum NetworkImageState { + /// The image is loading. case loading + + /// The image is ready. + /// + /// As associated values, this case contains the `Image` + /// and its size. case image(Image, size: CGSize) + + /// The image could not be loaded or the given URL is `nil. case failed } @available(macOS 10.15, iOS 13.0, tvOS 13.0, watchOS 6.0, *) - internal extension NetworkImageConfiguration { + internal extension NetworkImageState { init(state: NetworkImageStore.State) { switch state { case .notRequested, .loading: diff --git a/Sources/NetworkImage/SwiftUI/NetworkImageStyle.swift b/Sources/NetworkImage/SwiftUI/NetworkImageStyle.swift index a3810c7..a3a4038 100644 --- a/Sources/NetworkImage/SwiftUI/NetworkImageStyle.swift +++ b/Sources/NetworkImage/SwiftUI/NetworkImageStyle.swift @@ -2,32 +2,44 @@ import SwiftUI + /// A type that applies a custom appearance to all network images within a view hierarchy. + /// + /// To configure the current network image style for a view hierarchy, use the `networkImageStyle(_:)` + /// modifier and specify a style that conforms to `NetworkImageStyle`. @available(macOS 10.15, iOS 13.0, tvOS 13.0, watchOS 6.0, *) public protocol NetworkImageStyle { + /// A view that represents the body of a network image. associatedtype Body: View - @ViewBuilder func makeBody(configuration: NetworkImageConfiguration) -> Body + /// Creates a view that represents the body of a network image. + /// + /// The system calls this method for each `NetworkImage` instance in a view + /// hierarchy where this style is the current network image style. + /// + /// - Parameter state: The state of the network image. + @ViewBuilder func makeBody(state: NetworkImageState) -> Body } @available(macOS 10.15, iOS 13.0, tvOS 13.0, watchOS 6.0, *) public extension View { - func networkImageStyle(_ networkImageStyle: A) -> some View where A: NetworkImageStyle { + /// Sets the style for network images within this view. + func networkImageStyle(_ networkImageStyle: S) -> some View where S: NetworkImageStyle { environment(\.networkImageStyle, AnyNetworkImageStyle(networkImageStyle)) } } @available(macOS 10.15, iOS 13.0, tvOS 13.0, watchOS 6.0, *) struct AnyNetworkImageStyle: NetworkImageStyle { - private let _makeBody: (NetworkImageConfiguration) -> AnyView + private let _makeBody: (NetworkImageState) -> AnyView - init(_ networkImageStyle: A) where A: NetworkImageStyle { + init(_ networkImageStyle: S) where S: NetworkImageStyle { _makeBody = { - AnyView(networkImageStyle.makeBody(configuration: $0)) + AnyView(networkImageStyle.makeBody(state: $0)) } } - func makeBody(configuration: NetworkImageConfiguration) -> AnyView { - _makeBody(configuration) + func makeBody(state: NetworkImageState) -> AnyView { + _makeBody(state) } } diff --git a/Sources/NetworkImage/SwiftUI/ResizableNetworkImageStyle.swift b/Sources/NetworkImage/SwiftUI/ResizableNetworkImageStyle.swift index 7976105..396dc2c 100644 --- a/Sources/NetworkImage/SwiftUI/ResizableNetworkImageStyle.swift +++ b/Sources/NetworkImage/SwiftUI/ResizableNetworkImageStyle.swift @@ -2,6 +2,12 @@ import SwiftUI + /// A network image style that does not decorate the resulting image but applies the + /// `resizable()` and `aspectRatio(contentMode:)` modifiers to it, and + /// displays a placeholder image when loading fails or the given URL is `nil`. + /// + /// To apply this style to a network image, or to a view that contains network images, + /// use the `networkImageStyle(_:)` modifier. @available(macOS 10.15, iOS 13.0, tvOS 13.0, watchOS 6.0, *) public struct ResizableNetworkImageStyle: NetworkImageStyle { public enum Defaults { @@ -39,6 +45,13 @@ private let contentMode: ContentMode private let errorPlaceholder: Image? + /// Creates a resizable network image style. + /// + /// - Parameters: + /// - backgroundColor: The background color of the network image in any state. + /// - foregroundColor: The tint color for the placeholder image. + /// - contentMode: The content mode applied to the resulting image. + /// - errorPlaceholder: An image to display when there is an error or the URL is `nil`. public init( backgroundColor: Color = Defaults.backgroundColor, foregroundColor: Color = Defaults.foregroundColor, @@ -51,11 +64,17 @@ self.errorPlaceholder = errorPlaceholder } - public func makeBody(configuration: NetworkImageConfiguration) -> some View { + /// Creates a view that represents the body of a network image. + /// + /// The system calls this method for each `NetworkImage` instance in a view + /// hierarchy where this style is the current network image style. + /// + /// - Parameter state: The state of the network image. + public func makeBody(state: NetworkImageState) -> some View { ZStack { backgroundColor - switch configuration { + switch state { case .loading: EmptyView() case let .image(image, _): diff --git a/Sources/NetworkImage/UIKit/NetworkImageView.swift b/Sources/NetworkImage/UIKit/NetworkImageView.swift index ecb037a..2136499 100644 --- a/Sources/NetworkImage/UIKit/NetworkImageView.swift +++ b/Sources/NetworkImage/UIKit/NetworkImageView.swift @@ -2,9 +2,40 @@ import Combine import UIKit + /// A view that displays a remote image. + /// + /// A network image view downloads and displays an image from a given URL. + /// The download is asynchronous, and the result is cached both in disk and memory. + /// + /// You can specify a placeholder image that will be displayed if the download fails or the URL is `nil`. + /// + /// When there is no cached image for the given URL, and the download takes more than a specific time, + /// the view performs a cross-fade transition between the placeholder and the result. + /// + /// As a basic example, consider a UICollectionView subclass that displays a movie poster: + /// + /// class MoviePosterCell: UICollectionViewCell { + /// private lazy var imageView = NetworkImageView() + /// + /// override func prepareForReuse() { + /// super.prepareForReuse() + /// // cancels any ongoing image download and resets the view + /// imageView.prepareForReuse() + /// } + /// + /// func configure(with movie: Movie) { + /// imageView.url = movie.posterURL + /// imageView.placeholder = Image(systemName: "film") + /// } + /// + /// ... + /// } @available(iOS 13.0, tvOS 13.0, *) open class NetworkImageView: UIView { + /// Placeholder image that will be used when `url` is `nil` or the download fails. open var placeholder = UIImage(systemName: "photo") + + /// The URL for the image. open var url: URL? { didSet { store.send(.didSetURL(url)) } } @@ -27,6 +58,7 @@ setUp() } + /// Resets the view and cancels any ongoing download. open func prepareForReuse() { store.send(.prepareForReuse) }