Skip to content

Commit

Permalink
Add loading progress and error to be displayed.
Browse files Browse the repository at this point in the history
  • Loading branch information
bullinnyc committed Oct 2, 2023
1 parent 66202c0 commit 0c08f4c
Show file tree
Hide file tree
Showing 6 changed files with 240 additions and 37 deletions.
46 changes: 36 additions & 10 deletions Examples/ContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -37,18 +37,41 @@ struct ContentView: View {
ForEach(posters, id: \.self) { url in
CachedAsyncImage(
url: url,
placeholder: {
// Create any view for placeholder.
placeholder: { progress in
// Create any view for placeholder (optional).
ZStack {
Color.yellow
ProgressView()

ProgressView() {
VStack {
Text("Downloading...")
Text("\(progress) %")
}
}
}
},
image: {
// Customize image.
Image(uiImage: $0)
.resizable()
.scaledToFill()
},
error: { error in
// Create any view for error (optional).
ZStack {
Color.yellow

VStack {
Text("Error:")
.bold()

Text(error)
}
.font(.footnote)
.multilineTextAlignment(.center)
.foregroundColor(.red)
.frame(width: 120)
}
}
)
.frame(
Expand All @@ -68,13 +91,16 @@ struct ContentView: View {
}
}
}
.onAppear {
// Set image cache limit.
TemporaryImageCache.shared.setCacheLimit(
countLimit: 1000, // 1000 items
totalCostLimit: 1024 * 1024 * 200 // 200 MB
)
}
}

// MARK: - Initializers

init() {
// Set image cache limit.
TemporaryImageCache.shared.setCacheLimit(
countLimit: 1000, // 1000 items
totalCostLimit: 1024 * 1024 * 200 // 200 MB
)
}

// MARK: - Private Methods
Expand Down
29 changes: 25 additions & 4 deletions Sources/CachedAsyncImage/Services/ImageLoader.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,15 @@ final class ImageLoader: ObservableObject {
// MARK: - Property Wrappers

@Published var image: UIImage?
@Published var progress: Double?
@Published var errorMessage: String?

// MARK: - Private Properties

private let networkManager: NetworkManagerProtocol
private let imageCache = TemporaryImageCache.shared

private var cancellable: AnyCancellable?
private var cancellables: Set<AnyCancellable> = []
private(set) var isLoading = false

private static let imageProcessing = DispatchQueue(
Expand Down Expand Up @@ -50,9 +52,27 @@ final class ImageLoader: ObservableObject {
return
}

cancellable = networkManager.fetchImage(from: url)
let (progress, data) = networkManager.fetchImage(from: url)

progress?
.publisher(for: \.fractionCompleted)
.receive(on: DispatchQueue.main)
.sink { [weak self] fractionCompleted in
self?.progress = fractionCompleted
}
.store(in: &cancellables)

data
.map { UIImage(data: $0) }
.replaceError(with: nil)
.catch { [weak self] error -> AnyPublisher<UIImage?, Never> in
if let error = error as? NetworkError {
DispatchQueue.main.async {
self?.errorMessage = error.rawValue
}
}

return Just(nil).eraseToAnyPublisher()
}
.handleEvents(
receiveSubscription: { [weak self] _ in
self?.start()
Expand All @@ -72,6 +92,7 @@ final class ImageLoader: ObservableObject {
.sink { [weak self] in
self?.image = $0
}
.store(in: &cancellables)
}

// MARK: - Private Methods
Expand All @@ -90,6 +111,6 @@ final class ImageLoader: ObservableObject {
}

private func cancel() {
cancellable?.cancel()
cancellables.forEach { $0.cancel() }
}
}
101 changes: 86 additions & 15 deletions Sources/CachedAsyncImage/Services/NetworkManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,25 @@ enum NetworkError: LocalizedError {
case transportError(Error)

// Received an bad response, e.g. non HTTP result.
case badResponse(String = "Bad response.")
case badResponse(String)

var rawValue: String {
switch self {
case .badURL(let message):
return message
case .transportError(let error):
return error.localizedDescription
case .badResponse(let message):
return message
}
}
}

protocol NetworkManagerProtocol {
func fetchImage(from url: URL?) -> AnyPublisher<Data, Error>
func fetchImage(from url: URL?) -> (
progress: Progress?,
publisher: AnyPublisher<Data, Error>
)
}

final class NetworkManager: NetworkManagerProtocol {
Expand All @@ -36,33 +50,90 @@ final class NetworkManager: NetworkManagerProtocol {

// MARK: - Public Methods

func fetchImage(from url: URL?) -> AnyPublisher<Data, Error> {
func fetchImage(from url: URL?) -> (
progress: Progress?,
publisher: AnyPublisher<Data, Error>
) {
guard let url = url else {
return Fail(error: NetworkError.badURL()).eraseToAnyPublisher()
return (nil, Fail(error: NetworkError.badURL())
.eraseToAnyPublisher())
}

return URLSession.shared
.dataTaskPublisher(for: url)
// Handle transport layer errors.
let sharedPublisher = URLSession.shared
.dataTaskProgressPublisher(for: url)

let progress = sharedPublisher
.progress

let result = sharedPublisher
.publisher
// Handle transport layer errors.
.mapError { NetworkError.transportError($0) }
// Handle all other errors.
.tryMap { tuple in
guard let urlResponse = tuple.response as? HTTPURLResponse else {
throw NetworkError.badResponse()
}
// Handle all other errors.
.tryMap { element in
let httpResponse = element.response as? HTTPURLResponse
let statusCode = httpResponse?.statusCode ?? .zero

#if DEBUG
if let httpResponse = httpResponse {
let message = """
**** CachedAsyncImage response.
From: \(urlResponse.url?.absoluteString ?? "")
Status code: \(urlResponse.statusCode)
From: \(httpResponse.url?.absoluteString ?? "")
Status code: \(statusCode)
"""

print(message)
}
#endif

return tuple.data
guard statusCode == 200 else {
throw NetworkError.badResponse(
"Bad response. Status code \(statusCode)"
)
}

return element.data
}
.eraseToAnyPublisher()

return (progress, result)
}
}

// MARK: - Ext. URLSession

extension URLSession {
// MARK: - Typealias

typealias DataTaskProgressPublisher = (
progress: Progress?,
publisher: AnyPublisher<DataTaskPublisher.Output, Error>
)

// MARK: - Public Methods

func dataTaskProgressPublisher(for url: URL) -> DataTaskProgressPublisher {
let progress = Progress(totalUnitCount: 1)

let result = Deferred {
Future<DataTaskPublisher.Output, Error> { handler in
let task = self.dataTask(
with: URLRequest(url: url)
) { data, response, error in
if let error = error {
handler(.failure(error))
} else if let data = data, let response = response {
handler(.success((data, response)))
}
}

progress.addChild(task.progress, withPendingUnitCount: 1)
task.resume()
}
}
.share()
.eraseToAnyPublisher()

return (progress, result)
}
}
74 changes: 69 additions & 5 deletions Sources/CachedAsyncImage/Views/CachedAsyncImage.swift
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,9 @@ public struct CachedAsyncImage: View {

private let url: String
private let placeholder: (() -> any View)?
private let placeholderWithProgress: ((String) -> any View)?
private let image: (UIImage) -> any View
private let error: ((String) -> any View)?

// MARK: - Body

Expand All @@ -36,10 +38,12 @@ public struct CachedAsyncImage: View {
/// - url: The URL for which to create a image.
/// - placeholder: Placeholder to be displayed.
/// - image: Image to be displayed.
/// - error: Error to be displayed.
public init(
url: String,
placeholder: (() -> any View)? = nil,
image: @escaping (UIImage) -> any View
image: @escaping (UIImage) -> any View,
error: ((String) -> any View)? = nil
) {
_imageLoader = StateObject(
wrappedValue: ImageLoader(networkManager: NetworkManager.shared)
Expand All @@ -48,6 +52,32 @@ public struct CachedAsyncImage: View {
self.url = url
self.placeholder = placeholder
self.image = image
self.error = error

self.placeholderWithProgress = nil
}

/// - Parameters:
/// - url: The URL for which to create a image.
/// - placeholder: Placeholder with progress to be displayed.
/// - image: Image to be displayed.
/// - error: Error to be displayed.
public init(
url: String,
placeholder: ((String) -> any View)? = nil,
image: @escaping (UIImage) -> any View,
error: ((String) -> any View)? = nil
) {
_imageLoader = StateObject(
wrappedValue: ImageLoader(networkManager: NetworkManager.shared)
)

self.url = url
self.placeholderWithProgress = placeholder
self.image = image
self.error = error

self.placeholder = nil
}
}

Expand All @@ -59,8 +89,20 @@ extension CachedAsyncImage {
if let uiImage = imageLoader.image {
AnyView(image(uiImage))
} else {
let placeholder = placeholder ?? { Color.clear }
AnyView(placeholder())
if let error = error, let errorMessage = imageLoader.errorMessage {
AnyView(error(errorMessage))
} else {
if let placeholder = placeholder {
AnyView(placeholder())
}

if let placeholderWithProgress = placeholderWithProgress {
let percentValue = Int((imageLoader.progress ?? .zero) * 100)
let progress = String(percentValue)

AnyView(placeholderWithProgress(progress))
}
}
}
}
}
Expand All @@ -74,16 +116,38 @@ struct CachedAsyncImage_Previews: PreviewProvider {
ZStack {
CachedAsyncImage(
url: url,
placeholder: {
placeholder: { progress in
ZStack {
Color.yellow
ProgressView()

ProgressView() {
VStack {
Text("Downloading...")
Text("\(progress) %")
}
}
}
},
image: {
Image(uiImage: $0)
.resizable()
.scaledToFit()
},
error: { error in
ZStack {
Color.yellow

VStack {
Text("Error:")
.bold()

Text(error)
}
.font(.footnote)
.multilineTextAlignment(.center)
.foregroundColor(.red)
.frame(width: 120)
}
}
)
}
Expand Down
Loading

0 comments on commit 0c08f4c

Please sign in to comment.