Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Move AsyncImageView to AsyncImageKit #23938

Merged
merged 4 commits into from
Jan 2, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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