Skip to content
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: 18 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,24 @@ RemoteImage(type: .phAsset(localIdentifier: "541D4013-D51C-463C-AD85-0A1E4EA838F
})
```

## Custom cache

The `RemoteImageService` uses a default cache. To use a custom one just conform to the protocol `RemoteImageCache` and set it on the type `RemoteImageService`.

```swift
RemoteImageService.cache = yourCache
```

## Custom cache key

The default cache uses the associated value of the related `RemoteImageType` as the key. You can customize this by setting a cache key provider through

```swift
RemoteImageService.cacheKeyProvider = { remoteImageType -> AnyObject in
// return a key here
}
```

## Migration from 0.1.0 -> 1.0.0

The `url parameter` was refactored to a `type parameter` which makes it easy to fetch images at a URL or from the iCloud.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
//
// URLSession+RemoteImageURLDataPublisher.swift
// RemoteImage
//
// Created by Christian Elies on 15.12.19.
//

import Combine
import Foundation

extension URLSession: RemoteImageURLDataPublisher {
func dataPublisher(for request: URLRequest) -> AnyPublisher<(data: Data, response: URLResponse), URLError> {
dataTaskPublisher(for: request).eraseToAnyPublisher()
}
}
19 changes: 2 additions & 17 deletions Sources/RemoteImage/private/Models/RemoteImageState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,23 +8,8 @@

import Foundation

enum RemoteImageState {
case error(_ error: Error)
enum RemoteImageState: Hashable {
case error(_ error: NSError)
case image(_ image: PlatformSpecificImageType)
case loading
}

extension RemoteImageState: Equatable {
static func == (lhs: RemoteImageState, rhs: RemoteImageState) -> Bool {
switch (lhs, rhs) {
case (.error(let lhsError), .error(let rhsError)):
return (lhsError as NSError) == (rhsError as NSError)
case (.image(let lhsImage), .image(let rhsImage)):
return lhsImage == rhsImage
case (.loading, .loading):
return true
default:
return false
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
//
// RemoteImageServiceProtocol.swift
// RemoteImage
//
// Created by Christian Elies on 15.12.19.
//

import Combine

protocol RemoteImageServiceProtocol where Self: ObservableObject {
static var cache: RemoteImageCache { get set }
static var cacheKeyProvider: RemoteImageCacheKeyProvider { get set }

var state: RemoteImageState { get set }
func fetchImage(ofType type: RemoteImageType)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
//
// RemoteImageURLDataPublisher.swift
// RemoteImage
//
// Created by Christian Elies on 15.12.19.
//

import Combine
import Foundation

protocol RemoteImageURLDataPublisher {
func dataPublisher(for request: URLRequest) -> AnyPublisher<(data: Data, response: URLResponse), URLError>
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
//
// RemoteImageURLDataPublisherProvider.swift
// RemoteImage
//
// Created by Christian Elies on 15.12.19.
//

protocol RemoteImageURLDataPublisherProvider {
var remoteImageURLDataPublisher: RemoteImageURLDataPublisher { get }
}
22 changes: 22 additions & 0 deletions Sources/RemoteImage/private/Services/DefaultRemoteImageCache.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
//
// DefaultRemoteImageCache.swift
//
//
// Created by Christian Elies on 14.12.19.
//

import Foundation

struct DefaultRemoteImageCache {
let cache = NSCache<AnyObject, PlatformSpecificImageType>()
}

extension DefaultRemoteImageCache: RemoteImageCache {
func object(forKey key: AnyObject) -> PlatformSpecificImageType? { cache.object(forKey: key) }

func setObject(_ object: PlatformSpecificImageType, forKey key: AnyObject) { cache.setObject(object, forKey: key) }

func removeObject(forKey key: AnyObject) { cache.removeObject(forKey: key) }

func removeAllObjects() { cache.removeAllObjects() }
}
27 changes: 13 additions & 14 deletions Sources/RemoteImage/private/Services/PhotoKitService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,35 +6,34 @@ protocol PhotoKitServiceProvider {

protocol PhotoKitServiceProtocol {
func getPhotoData(localIdentifier: String,
success: @escaping (Data) -> Void,
failure: @escaping (Error) -> Void)
_ completion: @escaping (Result<Data, Error>) -> Void)
}

final class PhotoKitService {

static var asset: PHAsset.Type = PHAsset.self
static var imageManager: PHImageManager = PHImageManager.default()
}

extension PhotoKitService: PhotoKitServiceProtocol {
func getPhotoData(localIdentifier: String,
success: @escaping (Data) -> Void,
failure: @escaping (Error) -> Void) {
let fetchAssetsResult = PHAsset.fetchAssets(withLocalIdentifiers: [localIdentifier], options: nil)
_ completion: @escaping (Result<Data, Error>) -> Void) {
let fetchAssetsResult = Self.asset.fetchAssets(withLocalIdentifiers: [localIdentifier], options: nil)
guard let phAsset = fetchAssetsResult.firstObject else {
failure(PhotoKitServiceError.phAssetNotFound(localIdentifier: localIdentifier))
completion(.failure(PhotoKitServiceError.phAssetNotFound(localIdentifier: localIdentifier)))
return
}

let options = PHImageRequestOptions()
options.isNetworkAccessAllowed = true
PHImageManager.default().requestImageDataAndOrientation(for: phAsset,
options: options,
resultHandler: { data, _, _, info in
Self.imageManager.requestImageDataAndOrientation(for: phAsset,
options: options,
resultHandler: { data, _, _, info in
if let error = info?[PHImageErrorKey] as? Error {
failure(error)
completion(.failure(error))
} else if let data = data {
success(data)
completion(.success(data))
} else {
failure(PhotoKitServiceError.missingData)
completion(.failure(PhotoKitServiceError.missingData))
}
})
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,16 @@

import Foundation

protocol RemoteImageServiceDependenciesProtocol: PhotoKitServiceProvider {
protocol RemoteImageServiceDependenciesProtocol: PhotoKitServiceProvider, RemoteImageURLDataPublisherProvider {

}

struct RemoteImageServiceDependencies: RemoteImageServiceDependenciesProtocol {
let photoKitService: PhotoKitServiceProtocol
let remoteImageURLDataPublisher: RemoteImageURLDataPublisher

init() {
photoKitService = PhotoKitService()
remoteImageURLDataPublisher = URLSession.shared
}
}
2 changes: 2 additions & 0 deletions Sources/RemoteImage/public/Models/PhotoKitServiceError.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ public enum PhotoKitServiceError: Error {
case phAssetNotFound(localIdentifier: String)
}

extension PhotoKitServiceError: Equatable {}

extension PhotoKitServiceError: LocalizedError {
public var errorDescription: String? {
switch self {
Expand Down
15 changes: 15 additions & 0 deletions Sources/RemoteImage/public/Protocols/RemoteImageCache.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
//
// RemoteImageCache.swift
//
//
// Created by Christian Elies on 14.12.19.
//

import Foundation

public protocol RemoteImageCache {
func object(forKey key: AnyObject) -> PlatformSpecificImageType?
func setObject(_ object: PlatformSpecificImageType, forKey key: AnyObject)
func removeObject(forKey key: AnyObject)
func removeAllObjects()
}
66 changes: 39 additions & 27 deletions Sources/RemoteImage/public/Services/RemoteImageService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,18 +9,26 @@
import Combine
import Foundation

public final class RemoteImageService: NSObject, ObservableObject {
public typealias RemoteImageCacheKeyProvider = (RemoteImageType) -> AnyObject

public final class RemoteImageService: NSObject, ObservableObject, RemoteImageServiceProtocol {
private let dependencies: RemoteImageServiceDependenciesProtocol
private var cancellable: AnyCancellable?

@Published var state: RemoteImageState = .loading

public static let cache = NSCache<NSObject, PlatformSpecificImageType>()


public static var cache: RemoteImageCache = DefaultRemoteImageCache()
public static var cacheKeyProvider: RemoteImageCacheKeyProvider = { remoteImageType in
switch remoteImageType {
case .phAsset(let localIdentifier): return localIdentifier as NSString
case .url(let url): return url as NSURL
}
}

init(dependencies: RemoteImageServiceDependenciesProtocol) {
self.dependencies = dependencies
}

func fetchImage(ofType type: RemoteImageType) {
switch type {
case .url(let url):
Expand All @@ -34,49 +42,53 @@ public final class RemoteImageService: NSObject, ObservableObject {
extension RemoteImageService {
private func fetchImage(atURL url: URL) {
cancellable?.cancel()

if let image = RemoteImageService.cache.object(forKey: url as NSURL) {

let cacheKey = Self.cacheKeyProvider(.url(url))
if let image = Self.cache.object(forKey: cacheKey) {
state = .image(image)
return
}

let urlSession = URLSession.shared

let urlRequest = URLRequest(url: url)
cancellable = urlSession.dataTaskPublisher(for: urlRequest)

cancellable = dependencies.remoteImageURLDataPublisher.dataPublisher(for: urlRequest)
.map { PlatformSpecificImageType(data: $0.data) }
.receive(on: RunLoop.main)
.sink(receiveCompletion: { completion in
switch completion {
case .failure(let failure):
self.state = .error(failure)
case .failure(let error):
self.state = .error(error as NSError)
default: ()
}
}) { image in
if let image = image {
RemoteImageService.cache.setObject(image, forKey: url as NSURL)
Self.cache.setObject(image, forKey: cacheKey)
self.state = .image(image)
} else {
self.state = .error(RemoteImageServiceError.couldNotCreateImage)
self.state = .error(RemoteImageServiceError.couldNotCreateImage as NSError)
}
}
}

private func fetchImage(withLocalIdentifier localIdentifier: String) {
if let image = RemoteImageService.cache.object(forKey: localIdentifier as NSString) {
let cacheKey = Self.cacheKeyProvider(.phAsset(localIdentifier: localIdentifier))
if let image = Self.cache.object(forKey: cacheKey) {
state = .image(image)
return
}

dependencies.photoKitService.getPhotoData(localIdentifier: localIdentifier, success: { data in
if let image = PlatformSpecificImageType(data: data) {
RemoteImageService.cache.setObject(image, forKey: localIdentifier as NSString)
self.state = .image(image)
} else {
self.state = .error(RemoteImageServiceError.couldNotCreateImage)

dependencies.photoKitService.getPhotoData(localIdentifier: localIdentifier) { result in
switch result {
case .success(let data):
if let image = PlatformSpecificImageType(data: data) {
Self.cache.setObject(image, forKey: cacheKey)
self.state = .image(image)
} else {
self.state = .error(RemoteImageServiceError.couldNotCreateImage as NSError)
}
case .failure(let error):
self.state = .error(error as NSError)
}
}) { error in
self.state = .error(error)
}
}
}
6 changes: 3 additions & 3 deletions Sources/RemoteImage/public/Views/RemoteImage.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,9 @@ public struct RemoteImage<ErrorView: View, ImageView: View, LoadingView: View>:
private let errorView: (Error) -> ErrorView
private let imageView: (Image) -> ImageView
private let loadingView: () -> LoadingView

@ObservedObject private var service = RemoteImageServiceFactory.makeRemoteImageService()

public var body: AnyView {
switch service.state {
case .error(let error):
Expand Down Expand Up @@ -46,7 +46,7 @@ public struct RemoteImage<ErrorView: View, ImageView: View, LoadingView: View>:
)
}
}

public init(type: RemoteImageType, @ViewBuilder errorView: @escaping (Error) -> ErrorView, @ViewBuilder imageView: @escaping (Image) -> ImageView, @ViewBuilder loadingView: @escaping () -> LoadingView) {
self.type = type
self.errorView = errorView
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
//
// URLSession+RemoteImageURLDataPublisherTests.swift
// RemoteImageTests
//
// Created by Christian Elies on 15.12.19.
//

import Foundation
@testable import RemoteImage
import XCTest

final class URLSession_RemoteImageURLDataPublisherTests: XCTestCase {
func testDataPublisher() {
guard let url = URL(string: "https://google.de") else {
XCTFail("Could not create mock URL")
return
}
let urlSession: URLSession = .shared
let urlRequest = URLRequest(url: url)
let dataTaskPublisher = urlSession.dataTaskPublisher(for: urlRequest).eraseToAnyPublisher()
let dataPublisher = urlSession.dataPublisher(for: urlRequest)
XCTAssertEqual(dataPublisher.description, dataTaskPublisher.description)
}
}
19 changes: 19 additions & 0 deletions Tests/RemoteImageTests/Mocks/MockImageManager.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
//
// MockImageManager.swift
// RemoteImageTests
//
// Created by Christian Elies on 14.12.19.
//

import Photos

final class MockImageManager: PHImageManager {
var imageRequestID = PHImageRequestID()
var dataToReturn: Data?
var infoToReturn: [AnyHashable:Any]?

override func requestImageDataAndOrientation(for asset: PHAsset, options: PHImageRequestOptions?, resultHandler: @escaping (Data?, String?, CGImagePropertyOrientation, [AnyHashable : Any]?) -> Void) -> PHImageRequestID {
resultHandler(dataToReturn, nil, .up, infoToReturn)
return imageRequestID
}
}
Loading