Skip to content

Commit

Permalink
Move AsyncImageView to AsyncImageKit (#23938)
Browse files Browse the repository at this point in the history
  • Loading branch information
kean authored Jan 2, 2025
2 parents cdfbd20 + 90b1166 commit 2c17896
Show file tree
Hide file tree
Showing 27 changed files with 205 additions and 157 deletions.
13 changes: 7 additions & 6 deletions Modules/Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,10 @@ let package = Package(
.iOS(.v16),
],
products: XcodeSupport.products + [
.library(name: "JetpackStatsWidgetsCore", targets: ["JetpackStatsWidgetsCore"]),
.library(name: "AsyncImageKit", targets: ["AsyncImageKit"]),
.library(name: "DesignSystem", targets: ["DesignSystem"]),
.library(name: "JetpackStatsWidgetsCore", targets: ["JetpackStatsWidgetsCore"]),
.library(name: "WordPressFlux", targets: ["WordPressFlux"]),
.library(name: "AsyncImageKit", targets: ["AsyncImageKit"]),
.library(name: "WordPressShared", targets: ["WordPressShared"]),
.library(name: "WordPressUI", targets: ["WordPressUI"]),
],
Expand Down Expand Up @@ -52,16 +52,17 @@ let package = Package(
.package(url: "https://github.com/Automattic/color-studio", branch: "trunk"),
],
targets: XcodeSupport.targets + [
.target(name: "JetpackStatsWidgetsCore", swiftSettings: [.swiftLanguageMode(.v5)]),
.target(name: "AsyncImageKit", dependencies: [
.product(name: "Collections", package: "swift-collections"),
.product(name: "Gifu", package: "Gifu"),
]),
.target(name: "DesignSystem", swiftSettings: [.swiftLanguageMode(.v5)]),
.target(name: "JetpackStatsWidgetsCore", swiftSettings: [.swiftLanguageMode(.v5)]),
.target(name: "UITestsFoundation", dependencies: [
.product(name: "ScreenObject", package: "ScreenObject"),
.product(name: "XCUITestHelpers", package: "XCUITestHelpers"),
], swiftSettings: [.swiftLanguageMode(.v5)]),
.target(name: "WordPressFlux", swiftSettings: [.swiftLanguageMode(.v5)]),
.target(name: "AsyncImageKit", dependencies: [
.product(name: "Collections", package: "swift-collections"),
]),
.target(name: "WordPressSharedObjC", resources: [.process("Resources")], swiftSettings: [.swiftLanguageMode(.v5)]),
.target(name: "WordPressShared", dependencies: [.target(name: "WordPressSharedObjC")], resources: [.process("Resources")], swiftSettings: [.swiftLanguageMode(.v5)]),
.target(name: "WordPressTesting", resources: [.process("Resources")]),
Expand Down
17 changes: 8 additions & 9 deletions Modules/Sources/AsyncImageKit/ImageDownloader.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,9 @@ import UIKit
/// The system that downloads and caches images, and prepares them for display.
@ImageDownloaderActor
public final class ImageDownloader {
public nonisolated static let shared = ImageDownloader()

private nonisolated let cache: MemoryCacheProtocol
private let authenticator: MediaRequestAuthenticatorProtocol?

private let urlSession = URLSession {
$0.urlCache = nil
Expand All @@ -21,14 +22,12 @@ public final class ImageDownloader {
private var tasks: [String: ImageDataTask] = [:]

public nonisolated init(
cache: MemoryCacheProtocol = MemoryCache.shared,
authenticator: MediaRequestAuthenticatorProtocol?
cache: MemoryCacheProtocol = MemoryCache.shared
) {
self.cache = cache
self.authenticator = authenticator
}

public func image(from url: URL, host: MediaHost? = nil, options: ImageRequestOptions = .init()) async throws -> UIImage {
public func image(from url: URL, host: MediaHostProtocol? = nil, options: ImageRequestOptions = .init()) async throws -> UIImage {
try await image(for: ImageRequest(url: url, host: host, options: options))
}

Expand All @@ -55,8 +54,8 @@ public final class ImageDownloader {
switch request.source {
case .url(let url, let host):
var request: URLRequest
if let host, let authenticator {
request = try await authenticator.authenticatedRequest(for: url, host: host)
if let host {
request = try await host.authenticatedRequest(for: url)
} else {
request = URLRequest(url: url)
}
Expand Down Expand Up @@ -195,6 +194,6 @@ private extension URLSession {
}
}

public protocol MediaRequestAuthenticatorProtocol: Sendable {
@MainActor func authenticatedRequest(for url: URL, host: MediaHost) async throws -> URLRequest
public protocol MediaHostProtocol: Sendable {
@MainActor func authenticatedRequest(for url: URL) async throws -> URLRequest
}
5 changes: 4 additions & 1 deletion Modules/Sources/AsyncImageKit/ImagePrefetcher.swift
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,10 @@ public final class ImagePrefetcher {
}
}

public nonisolated init(downloader: ImageDownloader, maxConcurrentTasks: Int = 2) {
public nonisolated init(
downloader: ImageDownloader = .shared,
maxConcurrentTasks: Int = 2
) {
self.downloader = downloader
self.maxConcurrentTasks = maxConcurrentTasks
}
Expand Down
4 changes: 2 additions & 2 deletions Modules/Sources/AsyncImageKit/ImageRequest.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import UIKit

public final class ImageRequest: Sendable {
public enum Source: Sendable {
case url(URL, MediaHost?)
case url(URL, MediaHostProtocol?)
case urlRequest(URLRequest)

var url: URL? {
Expand All @@ -16,7 +16,7 @@ public final class ImageRequest: Sendable {
let source: Source
let options: ImageRequestOptions

public init(url: URL, host: MediaHost? = nil, options: ImageRequestOptions = .init()) {
public init(url: URL, host: MediaHostProtocol? = nil, options: ImageRequestOptions = .init()) {
self.source = .url(url, host)
self.options = options
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,46 +1,47 @@
import UIKit
import Gifu
import AsyncImageKit

/// A simple image view that supports rendering both static and animated images
/// (see ``AnimatedImage``).
@MainActor
final class AsyncImageView: UIView {
public final class AsyncImageView: UIView {
private let imageView = GIFImageView()
private var errorView: UIImageView?
private var spinner: UIActivityIndicatorView?
private let controller = ImageLoadingController()

enum LoadingStyle {
public enum LoadingStyle {
/// Shows a secondary background color during the download.
case background
/// Shows a spinner during the download.
case spinner
}

struct Configuration {
public struct Configuration {
/// Image tint color.
var tintColor: UIColor?
public var tintColor: UIColor?

/// Image view content mode.
var contentMode: UIView.ContentMode?
public var contentMode: UIView.ContentMode?

/// Enabled by default and shows an error icon on failures.
var isErrorViewEnabled = true
public var isErrorViewEnabled = true

/// By default, `background`.
var loadingStyle = LoadingStyle.background
public var loadingStyle = LoadingStyle.background

var passTouchesToSuperview = false
public var passTouchesToSuperview = false

public init() {}
}

var configuration = Configuration() {
public var configuration = Configuration() {
didSet { didUpdateConfiguration(configuration) }
}

/// The currently displayed image. If the image is animated, returns an
/// instance of ``AnimatedImage``.
var image: UIImage? {
public var image: UIImage? {
didSet {
if let image {
imageView.configure(image: image)
Expand All @@ -50,12 +51,12 @@ final class AsyncImageView: UIView {
}
}

override init(frame: CGRect) {
public override init(frame: CGRect) {
super.init(frame: frame)
setupView()
}

required init?(coder: NSCoder) {
public required init?(coder: NSCoder) {
super.init(coder: coder)
setupView()
}
Expand All @@ -65,7 +66,12 @@ final class AsyncImageView: UIView {

addSubview(imageView)
imageView.translatesAutoresizingMaskIntoConstraints = false
pinSubviewToAllEdges(imageView)
NSLayoutConstraint.activate([
imageView.topAnchor.constraint(equalTo: topAnchor),
imageView.trailingAnchor.constraint(equalTo: trailingAnchor),
imageView.bottomAnchor.constraint(equalTo: bottomAnchor),
imageView.leadingAnchor.constraint(equalTo: leadingAnchor),
])

imageView.clipsToBounds = true
imageView.contentMode = .scaleAspectFill
Expand All @@ -75,22 +81,22 @@ final class AsyncImageView: UIView {
}

/// Removes the current image and stops the outstanding downloads.
func prepareForReuse() {
public func prepareForReuse() {
controller.prepareForReuse()
image = nil
}

/// - parameter size: Target image size in pixels.
func setImage(
public func setImage(
with imageURL: URL,
host: MediaHost? = nil,
host: MediaHostProtocol? = nil,
size: ImageSize? = nil
) {
let request = ImageRequest(url: imageURL, host: host, options: ImageRequestOptions(size: size))
controller.setImage(with: request)
}

func setImage(with request: ImageRequest, completion: (@MainActor (Result<UIImage, Error>) -> Void)? = nil) {
public func setImage(with request: ImageRequest, completion: (@MainActor (Result<UIImage, Error>) -> Void)? = nil) {
controller.setImage(with: request, completion: completion)
}

Expand Down Expand Up @@ -134,7 +140,10 @@ final class AsyncImageView: UIView {
let spinner = UIActivityIndicatorView()
addSubview(spinner)
spinner.translatesAutoresizingMaskIntoConstraints = false
pinSubviewAtCenter(spinner)
NSLayoutConstraint.activate([
spinner.centerXAnchor.constraint(equalTo: centerXAnchor),
spinner.centerYAnchor.constraint(equalTo: centerYAnchor)
])
self.spinner = spinner
return spinner
}
Expand All @@ -147,12 +156,15 @@ final class AsyncImageView: UIView {
errorView.tintColor = .separator
addSubview(errorView)
errorView.translatesAutoresizingMaskIntoConstraints = false
pinSubviewAtCenter(errorView)
NSLayoutConstraint.activate([
errorView.centerXAnchor.constraint(equalTo: centerXAnchor),
errorView.centerYAnchor.constraint(equalTo: centerYAnchor)
])
self.errorView = errorView
return errorView
}

override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
public override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
if configuration.passTouchesToSuperview && self.bounds.contains(point) {
// Pass the touch to the superview
return nil
Expand All @@ -164,7 +176,7 @@ final class AsyncImageView: UIView {
extension GIFImageView {
/// If the image is an instance of `AnimatedImage` type, plays it as an
/// animated image.
func configure(image: UIImage) {
public func configure(image: UIImage) {
if let gif = image as? AnimatedImage, let data = gif.gifData {
self.animate(withGIFData: data)
} else {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,15 +1,13 @@
import SwiftUI
import DesignSystem
import AsyncImageKit

/// Asynchronous Image View that replicates the public API of `SwiftUI.AsyncImage`.
/// It uses `ImageDownloader` to fetch and cache the images.
struct CachedAsyncImage<Content>: View where Content: View {
public struct CachedAsyncImage<Content>: View where Content: View {
@State private var phase: AsyncImagePhase = .empty
private let url: URL?
private let content: (AsyncImagePhase) -> Content
private let imageDownloader: ImageDownloader
private let host: MediaHost?
private let host: MediaHostProtocol?

public var body: some View {
content(phase)
Expand All @@ -20,19 +18,24 @@ struct CachedAsyncImage<Content>: View where Content: View {

/// Initializes an image without any customization.
/// Provides a plain color as placeholder
init(url: URL?) where Content == _ConditionalContent<Image, Color> {
public init(url: URL?) where Content == _ConditionalContent<Image, Color> {
self.init(url: url) { phase in
if let image = phase.image {
image
} else {
Color(uiColor: UIAppColor.gray(.shade40))
Color(uiColor: .secondarySystemBackground)
}
}
}

/// Allows content customization and providing a placeholder that will be shown
/// until the image download is finalized.
init<I, P>(url: URL?, host: MediaHost? = nil, @ViewBuilder content: @escaping (Image) -> I, @ViewBuilder placeholder: @escaping () -> P) where Content == _ConditionalContent<I, P>, I: View, P: View {
public init<I, P>(
url: URL?,
host: MediaHostProtocol? = nil,
@ViewBuilder content: @escaping (Image) -> I,
@ViewBuilder placeholder: @escaping () -> P
) where Content == _ConditionalContent<I, P>, I: View, P: View {
self.init(url: url, host: host) { phase in
if let image = phase.image {
content(image)
Expand All @@ -42,9 +45,9 @@ struct CachedAsyncImage<Content>: View where Content: View {
}
}

init(
public init(
url: URL?,
host: MediaHost? = nil,
host: MediaHostProtocol? = nil,
imageDownloader: ImageDownloader = .shared,
@ViewBuilder content: @escaping (AsyncImagePhase) -> Content
) {
Expand Down
53 changes: 53 additions & 0 deletions Modules/Sources/AsyncImageKit/Views/ImageLoadingController.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import UIKit

/// A convenience class for managing image downloads for individual views.
@MainActor
public final class ImageLoadingController {
public var downloader: ImageDownloader = .shared
public var onStateChanged: (State) -> Void = { _ in }

public private(set) var task: Task<Void, Never>?

public enum State {
case loading
case success(UIImage)
case failure(Error)
}

deinit {
task?.cancel()
}

public init() {}

public func prepareForReuse() {
task?.cancel()
task = nil
}

/// - parameter completion: Gets called on completion _after_ `onStateChanged`.
public func setImage(with request: ImageRequest, completion: (@MainActor (Result<UIImage, Error>) -> Void)? = nil) {
task?.cancel()

if let image = downloader.cachedImage(for: request) {
onStateChanged(.success(image))
completion?(.success(image))
} else {
onStateChanged(.loading)
task = Task { @MainActor [downloader, weak self] in
do {
let image = try await downloader.image(for: request)
// This line guarantees that if you cancel on the main thread,
// none of the `onStateChanged` callbacks get called.
guard !Task.isCancelled else { return }
self?.onStateChanged(.success(image))
completion?(.success(image))
} catch {
guard !Task.isCancelled else { return }
self?.onStateChanged(.failure(error))
completion?(.failure(error))
}
}
}
}
}
Loading

0 comments on commit 2c17896

Please sign in to comment.