Skip to content

Commit

Permalink
Allow specifying the scale to use for the image
Browse files Browse the repository at this point in the history
  • Loading branch information
gonzalezreal committed Dec 10, 2021
1 parent 7ff0f10 commit bd00cd0
Show file tree
Hide file tree
Showing 7 changed files with 99 additions and 59 deletions.
18 changes: 3 additions & 15 deletions Sources/NetworkImage/Core/ImageDecoding.swift
Original file line number Diff line number Diff line change
@@ -1,22 +1,10 @@
#if os(iOS) || os(tvOS) || os(watchOS)
import UIKit

#if os(watchOS)
import WatchKit
#endif

public typealias OSImage = UIImage

private func screenScale() -> CGFloat {
#if os(watchOS)
return WKInterfaceDevice.current().screenScale
#else
return UIScreen.main.scale
#endif
}

internal func decodeImage(from data: Data) throws -> UIImage {
guard let image = UIImage(data: data, scale: screenScale()) else {
internal func decodeImage(from data: Data, scale: CGFloat) throws -> UIImage {
guard let image = UIImage(data: data, scale: scale) else {
throw NetworkImageError.invalidData(data)
}

Expand All @@ -31,7 +19,7 @@

public typealias OSImage = NSImage

internal func decodeImage(from data: Data) throws -> NSImage {
internal func decodeImage(from data: Data, scale _: CGFloat) throws -> NSImage {
guard let bitmapImageRep = NSBitmapImageRep(data: data) else {
throw NetworkImageError.invalidData(data)
}
Expand Down
51 changes: 38 additions & 13 deletions Sources/NetworkImage/Core/NetworkImageCache.swift
Original file line number Diff line number Diff line change
@@ -1,41 +1,66 @@
import CoreGraphics
import Foundation

/// Temporarily store images, keyed by their URL.
public struct NetworkImageCache {
private let _image: (URL) -> OSImage?
private let _setImage: (OSImage, URL) -> Void
private let _image: (URL, CGFloat) -> OSImage?
private let _setImage: (OSImage, URL, CGFloat) -> Void

public init() {
class Key: NSObject {
let url: URL
let scale: CGFloat

init(_ url: URL, _ scale: CGFloat) {
self.url = url
self.scale = scale
}

override func isEqual(_ object: Any?) -> Bool {
guard let other = object as? Key else { return false }
return url == other.url && scale == other.scale
}

override var hash: Int {
return url.hashValue ^ scale.hashValue
}
}

let nsCache = NSCache<Key, OSImage>()

public init(nsCache: NSCache<NSURL, OSImage> = NSCache()) {
self.init(
image: { url in
nsCache.object(forKey: url as NSURL)
image: { url, scale in
nsCache.object(forKey: Key(url, scale))
},
setImage: { image, url in
nsCache.setObject(image, forKey: url as NSURL)
setImage: { image, url, scale in
nsCache.setObject(image, forKey: Key(url, scale))
}
)
}

init(image: @escaping (URL) -> OSImage?, setImage: @escaping (OSImage, URL) -> Void) {
init(
image: @escaping (URL, CGFloat) -> OSImage?,
setImage: @escaping (OSImage, URL, CGFloat) -> Void
) {
_image = image
_setImage = setImage
}

/// Returns the image associated with a given URL.
public func image(for url: URL) -> OSImage? {
_image(url)
public func image(for url: URL, scale: CGFloat = 1) -> OSImage? {
_image(url, scale)
}

/// Stores the image in the cache, associated with the specified URL.
public func setImage(_ image: OSImage, for url: URL) {
_setImage(image, url)
public func setImage(_ image: OSImage, for url: URL, scale: CGFloat = 1) {
_setImage(image, url, scale)
}
}

#if DEBUG
extension NetworkImageCache {
public static var noop: Self {
Self(image: { _ in nil }, setImage: { _, _ in })
Self(image: { _, _ in nil }, setImage: { _, _, _ in })
}
}
#endif
49 changes: 26 additions & 23 deletions Sources/NetworkImage/Core/NetworkImageLoader.swift
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
import Combine
import CoreGraphics
import Foundation
import XCTestDynamicOverlay

/// Loads and caches images.
public struct NetworkImageLoader {
private let _image: (URL) -> AnyPublisher<OSImage, Error>
private let _cachedImage: (URL) -> OSImage?
private let _image: (URL, CGFloat) -> AnyPublisher<OSImage, Error>
private let _cachedImage: (URL, CGFloat) -> OSImage?

/// Creates an image loader.
/// - Parameters:
Expand All @@ -17,8 +17,8 @@ public struct NetworkImageLoader {

init(urlLoader: URLLoader, imageCache: NetworkImageCache) {
self.init(
image: { url in
if let image = imageCache.image(for: url) {
image: { url, scale in
if let image = imageCache.image(for: url, scale: scale) {
return Just(image)
.setFailureType(to: Error.self)
.eraseToAnyPublisher()
Expand All @@ -31,36 +31,36 @@ public struct NetworkImageLoader {
}
}

return try decodeImage(from: data)
return try decodeImage(from: data, scale: scale)
}
.handleEvents(receiveOutput: { image in
imageCache.setImage(image, for: url)
imageCache.setImage(image, for: url, scale: scale)
})
.eraseToAnyPublisher()
}
},
cachedImage: { url in
imageCache.image(for: url)
cachedImage: { url, scale in
imageCache.image(for: url, scale: scale)
}
)
}

init(
image: @escaping (URL) -> AnyPublisher<OSImage, Error>,
cachedImage: @escaping (URL) -> OSImage?
image: @escaping (URL, CGFloat) -> AnyPublisher<OSImage, Error>,
cachedImage: @escaping (URL, CGFloat) -> OSImage?
) {
_image = image
_cachedImage = cachedImage
}

/// Returns a publisher that loads an image for a given URL.
public func image(for url: URL) -> AnyPublisher<OSImage, Error> {
_image(url)
public func image(for url: URL, scale: CGFloat = 1) -> AnyPublisher<OSImage, Error> {
_image(url, scale)
}

/// Returns the cached image for a given URL if there is any.
public func cachedImage(for url: URL) -> OSImage? {
_cachedImage(url)
public func cachedImage(for url: URL, scale: CGFloat = 1) -> OSImage? {
_cachedImage(url, scale)
}
}

Expand All @@ -73,39 +73,42 @@ extension NetworkImageLoader {
}

#if DEBUG
import XCTestDynamicOverlay

extension NetworkImageLoader {
public static func mock<P>(
url matchingURL: URL,
scale matchingScale: CGFloat = 1,
withResponse response: P
) -> Self where P: Publisher, P.Output == OSImage, P.Failure == Error {
Self { url in
if url != matchingURL {
XCTFail("\(Self.self).image recevied an unexpected URL: \(url)")
Self { url, scale in
if url != matchingURL, scale != matchingScale {
XCTFail("\(Self.self).image received an unexpected URL: \(url) or scale: \(scale)")
}

return response.eraseToAnyPublisher()
} cachedImage: { _ in
} cachedImage: { _, _ in
nil
}
}

public static func mock<P>(
response: P
) -> Self where P: Publisher, P.Output == OSImage, P.Failure == Error {
Self { _ in
Self { _, _ in
response.eraseToAnyPublisher()
} cachedImage: { _ in
} cachedImage: { _, _ in
nil
}
}

public static var failing: Self {
Self { _ in
Self { _, _ in
XCTFail("\(Self.self).image is unimplemented")
return Just(OSImage())
.setFailureType(to: Error.self)
.eraseToAnyPublisher()
} cachedImage: { _ in
} cachedImage: { _, _ in
nil
}
}
Expand Down
21 changes: 21 additions & 0 deletions Sources/NetworkImage/Internal/Deprecations.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import Combine
import Foundation

// NB: Deprecated in 3.1.3

extension NetworkImageCache {
@available(
*, deprecated,
message: "NetworkImageCache no longer supports providing a NSCache in the initializer"
)
public init(nsCache: NSCache<NSURL, OSImage> = NSCache()) {
self.init(
image: { url, _ in
nsCache.object(forKey: url as NSURL)
},
setImage: { image, url, _ in
nsCache.setObject(image, forKey: url as NSURL)
}
)
}
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import Combine
import Foundation

// NB: Unavailable in 3.0.0

@available(*, unavailable, renamed: "NetworkImageCache")
public protocol ImageCache: AnyObject {
func image(for url: URL) -> OSImage?
Expand Down
2 changes: 1 addition & 1 deletion Tests/NetworkImageTests/Fixtures.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ enum Fixtures {
)
static let anyResponse = Data(base64Encoded: "Z29uemFsZXpyZWFs")!

static let anyImage = try! decodeImage(from: anyImageResponse)
static let anyImage = try! decodeImage(from: anyImageResponse, scale: 1)
static let anyError = NetworkImageError.badStatus(500)
}

Expand Down
15 changes: 8 additions & 7 deletions Tests/NetworkImageTests/NetworkImageLoaderTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ final class NetworkImageLoaderTests: XCTestCase {

// when
var result: OSImage?
imageLoader.image(for: Fixtures.anyImageURL)
imageLoader.image(for: Fixtures.anyImageURL, scale: 1)
.assertNoFailure()
.sink(receiveValue: {
result = $0
Expand All @@ -43,19 +43,20 @@ final class NetworkImageLoaderTests: XCTestCase {

// then
let unwrappedResult = try XCTUnwrap(result)
XCTAssertTrue(unwrappedResult.isEqual(imageCache.image(for: Fixtures.anyImageURL)))
XCTAssertTrue(unwrappedResult.isEqual(imageLoader.cachedImage(for: Fixtures.anyImageURL)))
XCTAssertTrue(unwrappedResult.isEqual(imageCache.image(for: Fixtures.anyImageURL, scale: 1)))
XCTAssertTrue(
unwrappedResult.isEqual(imageLoader.cachedImage(for: Fixtures.anyImageURL, scale: 1)))
}

func testImageReturnsCachedImageIfAvailable() throws {
// given
let imageCache = NetworkImageCache()
let imageLoader = NetworkImageLoader(urlLoader: .failing, imageCache: imageCache)
imageCache.setImage(Fixtures.anyImage, for: Fixtures.anyImageURL)
imageCache.setImage(Fixtures.anyImage, for: Fixtures.anyImageURL, scale: 1)

// when
var result: OSImage?
imageLoader.image(for: Fixtures.anyImageURL)
imageLoader.image(for: Fixtures.anyImageURL, scale: 1)
.assertNoFailure()
.sink(receiveValue: {
result = $0
Expand Down Expand Up @@ -90,7 +91,7 @@ final class NetworkImageLoaderTests: XCTestCase {

// when
var result: Error?
imageLoader.image(for: Fixtures.anyImageURL)
imageLoader.image(for: Fixtures.anyImageURL, scale: 1)
.sink(
receiveCompletion: { completion in
if case let .failure(error) = completion {
Expand Down Expand Up @@ -129,7 +130,7 @@ final class NetworkImageLoaderTests: XCTestCase {

// when
var result: Error?
imageLoader.image(for: Fixtures.anyImageURL)
imageLoader.image(for: Fixtures.anyImageURL, scale: 1)
.sink(
receiveCompletion: { completion in
if case let .failure(error) = completion {
Expand Down

0 comments on commit bd00cd0

Please sign in to comment.