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

Allow specifying the scale to use for the image #24

Merged
merged 1 commit into from
Dec 10, 2021
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
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