Skip to content

Commit

Permalink
Merge pull request #7 from bullinnyc/add-image-loader-state
Browse files Browse the repository at this point in the history
Add image loader state.
  • Loading branch information
bullinnyc authored Jan 20, 2024
2 parents 32a1740 + a0b4741 commit 945a9b0
Show file tree
Hide file tree
Showing 5 changed files with 97 additions and 104 deletions.
21 changes: 11 additions & 10 deletions Examples/ContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,16 @@ struct ContentView: View {

private static let standartPadding: CGFloat = 20

// MARK: - Initializers

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

// MARK: - Body

var body: some View {
Expand All @@ -45,6 +55,7 @@ struct ContentView: View {
ProgressView() {
VStack {
Text("Downloading...")

Text("\(progress) %")
}
}
Expand Down Expand Up @@ -98,16 +109,6 @@ struct ContentView: View {
#endif
}

// MARK: - Initializers

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

// MARK: - Private Methods

private func getIdealHeight(
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ CachedAsyncImage(
// Create any view for placeholder (optional).
ZStack {
Color.yellow

ProgressView()
}
},
Expand Down Expand Up @@ -60,6 +61,7 @@ CachedAsyncImage(
ProgressView() {
VStack {
Text("Downloading...")

Text("\(progress) %")
}
}
Expand Down
62 changes: 26 additions & 36 deletions Sources/CachedAsyncImage/Services/ImageLoader.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,19 +10,25 @@ import Foundation
import Combine

final class ImageLoader: ObservableObject {
// MARK: - Public Enums

enum State {
case idle
case loading(_ progress: Double = .zero)
case failed(_ error: String)
case loaded(_ image: CPImage)
}

// MARK: - Property Wrappers

@Published var image: CPImage?
@Published var progress: Double?
@Published var errorMessage: String?
@Published private(set) var state: State = .idle

// MARK: - Private Properties

private var imageCache: ImageCacheProtocol
private let networkManager: NetworkProtocol

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

private static let imageProcessing = DispatchQueue(
label: "com.cachedAsyncImage.imageProcessing"
Expand All @@ -44,12 +50,12 @@ final class ImageLoader: ObservableObject {
// MARK: - Public Methods

func fetchImage(from url: String) {
guard !isLoading else { return }
if case .loading = state { return }

let url = URL(string: url)

if let url = url, let cachedImage = imageCache[url] {
image = cachedImage
state = .loaded(cachedImage)
return
}

Expand All @@ -59,15 +65,17 @@ final class ImageLoader: ObservableObject {
.publisher(for: \.fractionCompleted)
.receive(on: DispatchQueue.main)
.sink { [weak self] fractionCompleted in
self?.progress = fractionCompleted
self?.state = .loading(fractionCompleted)
}
.store(in: &cancellables)

data
.map { CPImage(data: $0) }
.catch { [weak self] error -> AnyPublisher<CPImage?, Never> in
.catch { error -> AnyPublisher<CPImage?, Never> in
if let error = error as? NetworkError {
self?.errorMessage(with: error.rawValue)
Task { @MainActor [weak self] in
self?.state = .failed(error.rawValue)
}

#if DEBUG
print("**** CachedAsyncImage error: \(error.rawValue)")
Expand All @@ -77,50 +85,32 @@ final class ImageLoader: ObservableObject {
return Just(nil).eraseToAnyPublisher()
}
.handleEvents(
receiveSubscription: { [weak self] _ in
self?.start()
receiveSubscription: { _ in
Task { @MainActor [weak self] in
self?.state = .loading()
}
},
receiveOutput: { [weak self] in
self?.cache(url: url, image: $0)
},
receiveCompletion: { [weak self] _ in
self?.finish()
},
receiveCancel: { [weak self] in
self?.finish()
}
)
.subscribe(on: Self.imageProcessing)
.receive(on: DispatchQueue.main)
.sink { [weak self] in
self?.image = $0
.sink { [weak self] image in
guard let image = image else { return }
self?.state = .loaded(image)
}
.store(in: &cancellables)
}

// MARK: - Private Methods

private func start() {
isLoading = true
errorMessage(with: nil)
}

private func finish() {
isLoading = false
}

private func cancel() {
cancellables.forEach { $0.cancel() }
}

private func cache(url: URL?, image: CPImage?) {
guard let url = url else { return }
image.map { imageCache[url] = $0 }
}

private func errorMessage(with text: String?) {
Task { @MainActor [weak self] in
self?.errorMessage = text
}
private func cancel() {
cancellables.forEach { $0.cancel() }
}
}
67 changes: 36 additions & 31 deletions Sources/CachedAsyncImage/Views/CachedAsyncImage.swift
Original file line number Diff line number Diff line change
Expand Up @@ -22,24 +22,6 @@ public struct CachedAsyncImage: View {
private let image: (CPImage) -> any View
private let error: ((String) -> any View)?

// MARK: - Body

public var body: some View {
ZStack {
if let uiImage = imageLoader.image {
AnyView(image(uiImage))
} else {
errorOrPlaceholder
}
}
.onChange(of: url) { _, newValue in
imageLoader.fetchImage(from: newValue)
}
.onAppear {
imageLoader.fetchImage(from: url)
}
}

// MARK: - Initializers

/// - Parameters:
Expand Down Expand Up @@ -117,26 +99,47 @@ public struct CachedAsyncImage: View {

placeholderWithProgress = placeholder
}

// MARK: - Body

public var body: some View {
ZStack {
switch imageLoader.state {
case .idle:
Color.clear
.onAppear {
imageLoader.fetchImage(from: url)
}
case .loading(let progress):
placeholder(progress)
case .failed(let errorMessage):
if let error = error {
AnyView(error(errorMessage))
}
case .loaded(let image):
AnyView(self.image(image))
}
}
.onChange(of: url) { _, newValue in
imageLoader.fetchImage(from: newValue)
}
}
}

// MARK: - Ext. Configure views

extension CachedAsyncImage {
@ViewBuilder
private var errorOrPlaceholder: some View {
if let error = error, let errorMessage = imageLoader.errorMessage {
AnyView(error(errorMessage))
} else {
if let placeholder = placeholder {
AnyView(placeholder())
}
private func placeholder(_ progress: Double) -> some View {
if let placeholder = placeholder {
AnyView(placeholder())
}

if let placeholderWithProgress = placeholderWithProgress {
let percentValue = Int(progress * 100)
let progress = String(percentValue)

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

AnyView(placeholderWithProgress(progress))
}
AnyView(placeholderWithProgress(progress))
}
}
}
Expand All @@ -147,6 +150,7 @@ struct CachedAsyncImage_Previews: PreviewProvider {
static var placeholder: some View {
ZStack {
Color.yellow

ProgressView()
}
}
Expand All @@ -158,6 +162,7 @@ struct CachedAsyncImage_Previews: PreviewProvider {
ProgressView() {
VStack {
Text("Downloading...")

Text("\(progress) %")
}
}
Expand Down
49 changes: 22 additions & 27 deletions Tests/CachedAsyncImageTests/ImageLoaderTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -46,15 +46,20 @@ final class ImageLoaderTests: XCTestCase {
imageLoader.fetchImage(from: url)

// Then
XCTAssertNotNil(imageLoader.image, "Image should be not nil.")
var imageLoaderImage: CPImage?

switch imageLoader.state {
case .loaded(let image):
imageLoaderImage = image
default:
break
}

XCTAssertEqual(
imageLoader.image,
imageLoaderImage,
cachedImage,
"Image's should be equal."
)

XCTAssertFalse(imageLoader.isLoading, "Loading should be false.")
}

func testFetchImage_WithoutCachedImage() {
Expand All @@ -78,17 +83,22 @@ final class ImageLoaderTests: XCTestCase {
fatalError("Bad URL or nil.")
}

XCTAssertNil(imageLoader.image, "Image should be nil.")
XCTAssertNil(imageLoader.progress, "Progress message should be nil.")
XCTAssertNil(imageCache[imageUrl], "Image cache should be nil.")

let expectation = XCTestExpectation(description: "Fetch image.")

let subscription = imageLoader.$image
.sink { image in
if image != nil {
let subscription = imageLoader.$state
.sink { state in
var imageLoaderImage: CPImage?

switch state {
case .loaded(let image):
imageLoaderImage = image
default:
break
}

if imageLoaderImage != nil {
XCTAssertNotNil(
image,
imageLoaderImage,
"Image should be not nil."
)

Expand All @@ -97,21 +107,6 @@ final class ImageLoaderTests: XCTestCase {
"Image cache should be not nil."
)

XCTAssertNotNil(
imageLoader.progress,
"Progress message should be not nil."
)

XCTAssertNil(
imageLoader.errorMessage,
"Error message should be nil."
)

XCTAssertFalse(
imageLoader.isLoading,
"Loading should be false."
)

expectation.fulfill()
}
}
Expand Down

0 comments on commit 945a9b0

Please sign in to comment.