├── .github └── workflows │ └── build.yml ├── .gitignore ├── .swiftpm └── xcode │ └── package.xcworkspace │ └── contents.xcworkspacedata ├── .travis.yml ├── LICENSE ├── Package.resolved ├── Package.swift ├── README.md ├── Sources └── RemoteImage │ ├── private │ ├── Models │ │ └── RemoteImageServiceError.swift │ ├── Protocols │ │ └── RemoteImageURLDataPublisherProvider.swift │ └── Services │ │ ├── DefaultRemoteImageCache.swift │ │ ├── DefaultRemoteImageServiceDependencies.swift │ │ └── PhotoKitService.swift │ └── public │ ├── Extensions │ └── URLSession+RemoteImageURLDataPublisher.swift │ ├── Models │ ├── PhotoKitServiceError.swift │ ├── RemoteImageState.swift │ ├── RemoteImageType.swift │ └── UniversalImage.swift │ ├── Protocols │ ├── RemoteImageCache.swift │ ├── RemoteImageService.swift │ └── RemoteImageURLDataPublisher.swift │ ├── Services │ ├── DefaultRemoteImageService.swift │ └── DefaultRemoteImageServiceFactory.swift │ └── Views │ └── RemoteImage.swift └── Tests ├── LinuxMain.swift └── RemoteImageTests ├── Extensions ├── RemoteImage+Inspectable.swift └── URLSession+RemoteImageURLDataPublisherTests.swift ├── Mocks ├── MockImageManager.swift ├── MockPHAsset.swift ├── MockPHAssetFetchResult.swift ├── MockPhotoKitService.swift ├── MockRemoteImageServiceDependencies.swift └── MockRemoteImageURLDataPublisher.swift ├── Models ├── PhotoKitServiceErrorTests.swift └── RemoteImageServiceErrorTests.swift ├── Services ├── DefaultRemoteImageCacheTests.swift ├── PhotoKitServiceTests.swift ├── RemoteImageServiceDependenciesTests.swift ├── RemoteImageServiceFactoryTests.swift └── RemoteImageServiceTests.swift ├── Views └── RemoteImageTests.swift └── XCTestManifests.swift /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Build 2 | 3 | on: 4 | push: 5 | branches: [ master ] 6 | pull_request: 7 | branches: [ master ] 8 | 9 | jobs: 10 | build: 11 | name: Test and upload coverage data 12 | runs-on: macos-latest 13 | 14 | steps: 15 | - name: Checkout 16 | uses: actions/checkout@v2 17 | - name: Generate Xcode project 18 | run: | 19 | swift package generate-xcodeproj 20 | - name: Test 21 | run: | 22 | xcodebuild clean test -destination 'name=iPhone 8' -scheme RemoteImage-Package -enableCodeCoverage YES -derivedDataPath .build/derivedData -quiet 23 | - name: Upload Test coverage data 24 | run: | 25 | bash <(curl -s https://codecov.io/bash) -J '^RemoteImage$' -D .build/derivedData 26 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /.build 3 | /Packages 4 | /*.xcodeproj 5 | xcuserdata/ 6 | -------------------------------------------------------------------------------- /.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: swift 2 | osx_image: xcode12 3 | script: 4 | - swift package generate-xcodeproj 5 | - xcodebuild clean test -destination 'name=iPhone 8' -scheme RemoteImage-Package -enableCodeCoverage YES -derivedDataPath .build/derivedData -quiet 6 | after_success: 7 | # upload test coverage data 8 | - bash <(curl -s https://codecov.io/bash) -J '^RemoteImage$' -D .build/derivedData 9 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Christian Elies 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Package.resolved: -------------------------------------------------------------------------------- 1 | { 2 | "object": { 3 | "pins": [ 4 | { 5 | "package": "ViewInspector", 6 | "repositoryURL": "https://github.com/nalexn/ViewInspector.git", 7 | "state": { 8 | "branch": null, 9 | "revision": "ec943ed718cd293b95f17a2b81e8917d6ed70752", 10 | "version": "0.3.8" 11 | } 12 | } 13 | ] 14 | }, 15 | "version": 1 16 | } 17 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version:5.3 2 | // The swift-tools-version declares the minimum version of Swift required to build this package. 3 | 4 | import PackageDescription 5 | 6 | let package = Package( 7 | name: "RemoteImage", 8 | platforms: [ 9 | .iOS(.v13), 10 | .macOS(.v10_15), 11 | .tvOS(.v13) 12 | ], 13 | products: [ 14 | .library( 15 | name: "RemoteImage", 16 | targets: ["RemoteImage"]), 17 | ], 18 | dependencies: [ 19 | .package(url: "https://github.com/nalexn/ViewInspector.git", from: "0.3.8") 20 | ], 21 | targets: [ 22 | .target( 23 | name: "RemoteImage", 24 | dependencies: []), 25 | .testTarget( 26 | name: "RemoteImageTests", 27 | dependencies: ["RemoteImage", "ViewInspector"]), 28 | ] 29 | ) 30 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # RemoteImage 2 | 3 | [![Swift 5.3](https://img.shields.io/badge/swift-5.3-green.svg?longCache=true&style=flat-square)](https://developer.apple.com/swift) 4 | [![Platforms](https://img.shields.io/badge/platforms-iOS%20%7C%20macOS%20%7C%20tvOS-lightgrey.svg?longCache=true&style=flat-square)](https://www.apple.com) 5 | [![Current Version](https://img.shields.io/github/v/tag/crelies/RemoteImage?longCache=true&style=flat-square)](https://github.com/crelies/RemoteImage) 6 | [![Build status](https://github.com/crelies/RemoteImage/actions/workflows/build.yml/badge.svg)](https://github.com/crelies/RemoteImage/actions/workflows/build.yml) 7 | [![Code coverage](https://codecov.io/gh/crelies/RemoteImage/branch/dev/graph/badge.svg?token=DhJyoUKNPM)](https://codecov.io/gh/crelies/RemoteImage) 8 | [![License](https://img.shields.io/badge/license-MIT-lightgrey.svg?longCache=true&style=flat-square)](https://en.wikipedia.org/wiki/MIT_License) 9 | 10 | This Swift package provides a wrapper view around the existing **SwiftUI** `Image view` which adds support for showing and caching remote images. 11 | In addition you can specify a loading and error view. 12 | 13 | You can display images from a specific **URL** or from the **iCloud** (through a `PHAsset` identifier). 14 | 15 | ## 💡 Installation 16 | 17 | Add this Swift package in Xcode using its Github repository url. (File > Swift Packages > Add Package Dependency...) 18 | 19 | ## 🧭 How to use 20 | 21 | Just pass a remote image url or the local identifier of a `PHAsset` and `ViewBuilder`s for the error, image and loading state to the initializer. That's it 🎉 22 | 23 | Clear the image cache through `RemoteImageService.cache.removeAllObjects()`. 24 | 25 | ## 📖 Examples 26 | 27 | The following code truly highlights the **simplicity** of this view: 28 | 29 | **URL example:** 30 | ```swift 31 | let url = URL(string: "https://images.unsplash.com/photo-1524419986249-348e8fa6ad4a?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=crop&w=1950&q=80")! 32 | 33 | RemoteImage(type: .url(url), errorView: { error in 34 | Text(error.localizedDescription) 35 | }, imageView: { image in 36 | image 37 | .resizable() 38 | .aspectRatio(contentMode: .fit) 39 | }, loadingView: { 40 | Text("Loading ...") 41 | }) 42 | ``` 43 | 44 | **PHAsset example:** 45 | ```swift 46 | 47 | RemoteImage(type: .phAsset(localIdentifier: "541D4013-D51C-463C-AD85-0A1E4EA838FD"), errorView: { error in 48 | Text(error.localizedDescription) 49 | }, imageView: { image in 50 | image 51 | .resizable() 52 | .aspectRatio(contentMode: .fit) 53 | }, loadingView: { 54 | Text("Loading ...") 55 | }) 56 | ``` 57 | 58 | ## Custom `RemoteImageURLDataPublisher` 59 | 60 | Under the hood the `URLSession.shared` is used by default as the `RemoteImageURLDataPublisher` to fetch the image at the specified URL. 61 | You can specify a custom publisher through the`remoteImageURLDataPublisher` parameter. 62 | As an example that's how you could add support for low data mode to the `RemoteImage` view. 63 | 64 | ```swift 65 | let url = URL(string: "https://images.unsplash.com/photo-1524419986249-348e8fa6ad4a?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=crop&w=1950&q=80")! 66 | 67 | RemoteImage(type: .url(url), remoteImageURLDataPublisher: { 68 | let configuration = URLSessionConfiguration.default 69 | // Enable low data mode support 70 | configuration.allowsConstrainedNetworkAccess = false 71 | return URLSession(configuration: configuration) 72 | }(), errorView: { error in 73 | Text(error.localizedDescription) 74 | }, imageView: { image in 75 | image 76 | .resizable() 77 | .aspectRatio(contentMode: .fit) 78 | }, loadingView: { 79 | Text("Loading ...") 80 | }) 81 | ``` 82 | 83 | ## Custom `RemoteImageService` 84 | 85 | If you want complete control over the service responsible for managing the state of the view and for fetching the image you could pass an object conforming to the `RemoteImageService` protocol to the related initializer: 86 | 87 | ```swift 88 | final class CustomService: RemoteImageService { ... } 89 | 90 | let url = URL(string: "https://images.unsplash.com/photo-1524419986249-348e8fa6ad4a?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=crop&w=1950&q=80")! 91 | 92 | RemoteImage(type: .url(url), service: CustomService(), errorView: { error in 93 | Text(error.localizedDescription) 94 | }, imageView: { image in 95 | image 96 | .resizable() 97 | .aspectRatio(contentMode: .fit) 98 | }, loadingView: { 99 | Text("Loading ...") 100 | }) 101 | ``` 102 | 103 | In addition to that you could use the new `@StateObject` property wrapper introcuded in Swift by creating an instance of the default built-in `RemoteImageService` and using the above initializer: 104 | 105 | ```swift 106 | @StateObject var service = DefaultRemoteImageServiceFactory.makeDefaultRemoteImageService() 107 | // or 108 | @StateObject var service = DefaultRemoteImageServiceFactory.makeDefaultRemoteImageService(remoteImageURLDataPublisher: yourRemoteImageURLDataPublisher) 109 | 110 | let url = URL(string: "https://images.unsplash.com/photo-1524419986249-348e8fa6ad4a?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=crop&w=1950&q=80")! 111 | 112 | RemoteImage(type: .url(url), service: service, errorView: { error in 113 | Text(error.localizedDescription) 114 | }, imageView: { image in 115 | image 116 | .resizable() 117 | .aspectRatio(contentMode: .fit) 118 | }, loadingView: { 119 | Text("Loading ...") 120 | }) 121 | ``` 122 | 123 | ## Custom cache 124 | 125 | The `RemoteImageService` uses a default cache. To use a custom one just conform to the protocol `RemoteImageCache` and set it on the type `RemoteImageService`. 126 | 127 | ```swift 128 | RemoteImageService.cache = yourCache 129 | ``` 130 | 131 | ## Custom cache key 132 | 133 | 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 134 | 135 | ```swift 136 | RemoteImageService.cacheKeyProvider = { remoteImageType -> AnyObject in 137 | // return a key here 138 | } 139 | ``` 140 | 141 | ## Migration from 0.1.0 -> 1.0.0 142 | 143 | The `url parameter` was refactored to a `type parameter` which makes it easy to fetch images at a URL or from the iCloud. 144 | 145 | Change 146 | ```swift 147 | # Version 0.1.0 148 | let url = URL(string: "https://images.unsplash.com/photo-1524419986249-348e8fa6ad4a?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=crop&w=1950&q=80")! 149 | 150 | RemoteImage(url: url, errorView: { error in 151 | Text(error.localizedDescription) 152 | }, imageView: { image in 153 | image 154 | .resizable() 155 | .aspectRatio(contentMode: .fit) 156 | }, loadingView: { 157 | Text("Loading ...") 158 | }) 159 | ``` 160 | 161 | to 162 | ```swift 163 | # Version 1.0.0 164 | let url = URL(string: "https://images.unsplash.com/photo-1524419986249-348e8fa6ad4a?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=crop&w=1950&q=80")! 165 | 166 | RemoteImage(type: .url(url), errorView: { error in 167 | Text(error.localizedDescription) 168 | }, imageView: { image in 169 | image 170 | .resizable() 171 | .aspectRatio(contentMode: .fit) 172 | }, loadingView: { 173 | Text("Loading ...") 174 | }) 175 | ``` 176 | -------------------------------------------------------------------------------- /Sources/RemoteImage/private/Models/RemoteImageServiceError.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RemoteImageServiceError.swift 3 | // RemoteImage 4 | // 5 | // Created by Christian Elies on 11.08.19. 6 | // Copyright © 2019 Christian Elies. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | enum RemoteImageServiceError: Error { 12 | case couldNotCreateImage 13 | } 14 | 15 | extension RemoteImageServiceError: LocalizedError { 16 | var errorDescription: String? { 17 | return "Could not create image from received data" 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /Sources/RemoteImage/private/Protocols/RemoteImageURLDataPublisherProvider.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RemoteImageURLDataPublisherProvider.swift 3 | // RemoteImage 4 | // 5 | // Created by Christian Elies on 15.12.19. 6 | // 7 | 8 | protocol RemoteImageURLDataPublisherProvider { 9 | var remoteImageURLDataPublisher: RemoteImageURLDataPublisher { get } 10 | } 11 | -------------------------------------------------------------------------------- /Sources/RemoteImage/private/Services/DefaultRemoteImageCache.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DefaultRemoteImageCache.swift 3 | // RemoteImage 4 | // 5 | // Created by Christian Elies on 14.12.19. 6 | // 7 | 8 | import Foundation 9 | 10 | struct DefaultRemoteImageCache { 11 | let cache = NSCache() 12 | } 13 | 14 | extension DefaultRemoteImageCache: RemoteImageCache { 15 | func object(forKey key: AnyObject) -> UniversalImage? { cache.object(forKey: key) } 16 | 17 | func setObject(_ object: UniversalImage, forKey key: AnyObject) { cache.setObject(object, forKey: key) } 18 | 19 | func removeObject(forKey key: AnyObject) { cache.removeObject(forKey: key) } 20 | 21 | func removeAllObjects() { cache.removeAllObjects() } 22 | } 23 | -------------------------------------------------------------------------------- /Sources/RemoteImage/private/Services/DefaultRemoteImageServiceDependencies.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DefaultRemoteImageServiceDependencies.swift 3 | // RemoteImage 4 | // 5 | // Created by Christian Elies on 29.10.19. 6 | // 7 | 8 | import Foundation 9 | 10 | protocol DefaultRemoteImageServiceDependenciesProtocol: PhotoKitServiceProvider, RemoteImageURLDataPublisherProvider { 11 | 12 | } 13 | 14 | struct DefaultRemoteImageServiceDependencies: DefaultRemoteImageServiceDependenciesProtocol { 15 | let photoKitService: PhotoKitServiceProtocol 16 | let remoteImageURLDataPublisher: RemoteImageURLDataPublisher 17 | 18 | init(remoteImageURLDataPublisher: RemoteImageURLDataPublisher) { 19 | photoKitService = PhotoKitService() 20 | self.remoteImageURLDataPublisher = remoteImageURLDataPublisher 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /Sources/RemoteImage/private/Services/PhotoKitService.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PhotoKitService.swift 3 | // RemoteImage 4 | // 5 | // Created by Christian Elies. 6 | // 7 | 8 | import Photos 9 | 10 | protocol PhotoKitServiceProvider { 11 | var photoKitService: PhotoKitServiceProtocol { get } 12 | } 13 | 14 | protocol PhotoKitServiceProtocol { 15 | func getPhotoData( 16 | localIdentifier: String, 17 | _ completion: @escaping (Result) -> Void 18 | ) 19 | } 20 | 21 | final class PhotoKitService { 22 | static var asset: PHAsset.Type = PHAsset.self 23 | static var imageManager: PHImageManager = PHImageManager.default() 24 | } 25 | 26 | extension PhotoKitService: PhotoKitServiceProtocol { 27 | func getPhotoData( 28 | localIdentifier: String, 29 | _ completion: @escaping (Result) -> Void 30 | ) { 31 | let fetchAssetsResult = Self.asset.fetchAssets(withLocalIdentifiers: [localIdentifier], options: nil) 32 | guard let phAsset = fetchAssetsResult.firstObject else { 33 | completion(.failure(PhotoKitServiceError.phAssetNotFound(localIdentifier: localIdentifier))) 34 | return 35 | } 36 | 37 | let options = PHImageRequestOptions() 38 | options.isNetworkAccessAllowed = true 39 | Self.imageManager.requestImageDataAndOrientation(for: phAsset, 40 | options: options, 41 | resultHandler: { data, _, _, info in 42 | if let error = info?[PHImageErrorKey] as? Error { 43 | completion(.failure(error)) 44 | } else if let data = data { 45 | completion(.success(data)) 46 | } else { 47 | completion(.failure(PhotoKitServiceError.missingData)) 48 | } 49 | }) 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /Sources/RemoteImage/public/Extensions/URLSession+RemoteImageURLDataPublisher.swift: -------------------------------------------------------------------------------- 1 | // 2 | // URLSession+RemoteImageURLDataPublisher.swift 3 | // RemoteImage 4 | // 5 | // Created by Christian Elies on 15.12.19. 6 | // 7 | 8 | import Combine 9 | import Foundation 10 | 11 | extension URLSession: RemoteImageURLDataPublisher { 12 | public func dataPublisher(for url: URL) -> AnyPublisher<(data: Data, response: URLResponse), URLError> { 13 | dataTaskPublisher(for: url).eraseToAnyPublisher() 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /Sources/RemoteImage/public/Models/PhotoKitServiceError.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PhotoKitServiceError.swift 3 | // RemoteImage 4 | // 5 | // Created by Christian Elies. 6 | // 7 | 8 | import Foundation 9 | 10 | public enum PhotoKitServiceError: Error { 11 | case missingData 12 | case phAssetNotFound(localIdentifier: String) 13 | } 14 | 15 | extension PhotoKitServiceError: Equatable {} 16 | 17 | extension PhotoKitServiceError: LocalizedError { 18 | public var errorDescription: String? { 19 | switch self { 20 | case .missingData: 21 | return "The asset could not be loaded." 22 | case .phAssetNotFound(let localIdentifier): 23 | return "A PHAsset with the identifier \(localIdentifier) was not found." 24 | } 25 | } 26 | 27 | public var failureReason: String? { 28 | switch self { 29 | case .missingData: 30 | return "The asset data could not be fetched. Maybe you are not connected to the internet." 31 | case .phAssetNotFound(let localIdentifier): 32 | return "An asset with the identifier \(localIdentifier) doesn't exist anymore." 33 | } 34 | } 35 | 36 | public var recoverySuggestion: String? { 37 | switch self { 38 | case .missingData: 39 | return "Check your internet connection or try again later." 40 | default: 41 | return nil 42 | } 43 | } 44 | } 45 | 46 | extension PhotoKitServiceError: CustomNSError { 47 | public static var errorDomain: String { String(describing: PhotoKitService.self) } 48 | 49 | public var errorCode: Int { 50 | switch self { 51 | case .missingData: 52 | return 0 53 | case .phAssetNotFound: 54 | return 1 55 | } 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /Sources/RemoteImage/public/Models/RemoteImageState.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RemoteImageState.swift 3 | // RemoteImage 4 | // 5 | // Created by Christian Elies on 11.08.19. 6 | // Copyright © 2019 Christian Elies. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | public enum RemoteImageState: Hashable { 12 | case error(_ error: NSError) 13 | case image(_ image: UniversalImage) 14 | case loading 15 | } 16 | -------------------------------------------------------------------------------- /Sources/RemoteImage/public/Models/RemoteImageType.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RemoteImageType.swift 3 | // RemoteImage 4 | // 5 | // Created by Christian Elies on 29.10.19. 6 | // 7 | 8 | import Foundation 9 | 10 | public enum RemoteImageType { 11 | @available(*, deprecated, message: "Will be removed in the future because the localIdentifier is device specific and therefore cannot be used to uniquely identify a PHAsset across devices.") 12 | case phAsset(localIdentifier: String) 13 | case url(_ url: URL) 14 | } 15 | -------------------------------------------------------------------------------- /Sources/RemoteImage/public/Models/UniversalImage.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UniversalImage.swift 3 | // RemoteImage 4 | // 5 | // Created by Christian Elies on 01.11.20. 6 | // 7 | 8 | #if os(iOS) || os(tvOS) 9 | import UIKit 10 | 11 | public typealias UniversalImage = UIImage 12 | 13 | #elseif os(macOS) 14 | 15 | import AppKit 16 | 17 | public typealias UniversalImage = NSImage 18 | 19 | #endif 20 | -------------------------------------------------------------------------------- /Sources/RemoteImage/public/Protocols/RemoteImageCache.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RemoteImageCache.swift 3 | // RemoteImage 4 | // 5 | // Created by Christian Elies on 14.12.19. 6 | // 7 | 8 | public protocol RemoteImageCache { 9 | func object(forKey key: AnyObject) -> UniversalImage? 10 | func setObject(_ object: UniversalImage, forKey key: AnyObject) 11 | func removeObject(forKey key: AnyObject) 12 | func removeAllObjects() 13 | } 14 | -------------------------------------------------------------------------------- /Sources/RemoteImage/public/Protocols/RemoteImageService.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RemoteImageService.swift 3 | // RemoteImage 4 | // 5 | // Created by Christian Elies on 15.12.19. 6 | // 7 | 8 | import Combine 9 | 10 | public typealias RemoteImageCacheKeyProvider = (RemoteImageType) -> AnyObject 11 | 12 | /// Represents the service associated with a `RemoteImage` view. Responsible for fetching the image and managing the state. 13 | public protocol RemoteImageService where Self: ObservableObject { 14 | /// The cache for the images fetched by any instance of `RemoteImageService`. 15 | static var cache: RemoteImageCache { get set } 16 | /// Provides a key for a given `RemoteImageType` used for storing an image in the cache. 17 | static var cacheKeyProvider: RemoteImageCacheKeyProvider { get set } 18 | 19 | /// The current state of the image fetching process - `loading`, `error` or `image (success)`. 20 | var state: RemoteImageState { get set } 21 | 22 | /// Fetches the image with the given type. 23 | /// 24 | /// - Parameter type: Specifies the source type of the remote image. Choose between `.url` or `.phAsset`. 25 | func fetchImage(ofType type: RemoteImageType) 26 | } 27 | -------------------------------------------------------------------------------- /Sources/RemoteImage/public/Protocols/RemoteImageURLDataPublisher.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RemoteImageURLDataPublisher.swift 3 | // RemoteImage 4 | // 5 | // Created by Christian Elies on 15.12.19. 6 | // 7 | 8 | import Combine 9 | import Foundation 10 | 11 | public protocol RemoteImageURLDataPublisher { 12 | func dataPublisher(for url: URL) -> AnyPublisher<(data: Data, response: URLResponse), URLError> 13 | } 14 | -------------------------------------------------------------------------------- /Sources/RemoteImage/public/Services/DefaultRemoteImageService.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DefaultRemoteImageService.swift 3 | // RemoteImage 4 | // 5 | // Created by Christian Elies on 11.08.19. 6 | // Copyright © 2019 Christian Elies. All rights reserved. 7 | // 8 | 9 | import Combine 10 | import Foundation 11 | 12 | public final class DefaultRemoteImageService: RemoteImageService { 13 | private let dependencies: DefaultRemoteImageServiceDependenciesProtocol 14 | private var cancellable: AnyCancellable? 15 | 16 | @Published public var state: RemoteImageState = .loading 17 | 18 | public static var cache: RemoteImageCache = DefaultRemoteImageCache() 19 | public static var cacheKeyProvider: RemoteImageCacheKeyProvider = { remoteImageType in 20 | switch remoteImageType { 21 | case .phAsset(let localIdentifier): return localIdentifier as NSString 22 | case .url(let url): return url as NSURL 23 | } 24 | } 25 | 26 | init(dependencies: DefaultRemoteImageServiceDependenciesProtocol) { 27 | self.dependencies = dependencies 28 | } 29 | 30 | public func fetchImage(ofType type: RemoteImageType) { 31 | switch type { 32 | case .url(let url): 33 | fetchImage(atURL: url) 34 | case .phAsset(let localIdentifier): 35 | fetchImage(withLocalIdentifier: localIdentifier) 36 | } 37 | } 38 | } 39 | 40 | private extension DefaultRemoteImageService { 41 | func fetchImage(atURL url: URL) { 42 | cancellable?.cancel() 43 | 44 | let cacheKey = Self.cacheKeyProvider(.url(url)) 45 | if let image = Self.cache.object(forKey: cacheKey) { 46 | state = .image(image) 47 | return 48 | } 49 | 50 | cancellable = dependencies.remoteImageURLDataPublisher.dataPublisher(for: url) 51 | .map { UniversalImage(data: $0.data) } 52 | .receive(on: RunLoop.main) 53 | .sink(receiveCompletion: { [weak self] completion in 54 | guard let weakSelf = self else { 55 | return 56 | } 57 | 58 | switch completion { 59 | case .failure(let error): 60 | weakSelf.state = .error(error as NSError) 61 | case .finished: () 62 | } 63 | }) { [weak self] image in 64 | guard let weakSelf = self else { 65 | return 66 | } 67 | 68 | if let image = image { 69 | Self.cache.setObject(image, forKey: cacheKey) 70 | weakSelf.state = .image(image) 71 | } else { 72 | weakSelf.state = .error(RemoteImageServiceError.couldNotCreateImage as NSError) 73 | } 74 | } 75 | } 76 | 77 | func fetchImage(withLocalIdentifier localIdentifier: String) { 78 | let cacheKey = Self.cacheKeyProvider(.phAsset(localIdentifier: localIdentifier)) 79 | if let image = Self.cache.object(forKey: cacheKey) { 80 | state = .image(image) 81 | return 82 | } 83 | 84 | dependencies.photoKitService.getPhotoData(localIdentifier: localIdentifier) { result in 85 | switch result { 86 | case .success(let data): 87 | if let image = UniversalImage(data: data) { 88 | Self.cache.setObject(image, forKey: cacheKey) 89 | self.state = .image(image) 90 | } else { 91 | self.state = .error(RemoteImageServiceError.couldNotCreateImage as NSError) 92 | } 93 | case .failure(let error): 94 | self.state = .error(error as NSError) 95 | } 96 | } 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /Sources/RemoteImage/public/Services/DefaultRemoteImageServiceFactory.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DefaultRemoteImageServiceFactory.swift 3 | // RemoteImage 4 | // 5 | // Created by Christian Elies on 29.10.19. 6 | // 7 | 8 | import Foundation 9 | 10 | public final class DefaultRemoteImageServiceFactory { 11 | public static func makeDefaultRemoteImageService(remoteImageURLDataPublisher: RemoteImageURLDataPublisher = URLSession.shared) -> DefaultRemoteImageService { 12 | let dependencies = DefaultRemoteImageServiceDependencies(remoteImageURLDataPublisher: remoteImageURLDataPublisher) 13 | return DefaultRemoteImageService(dependencies: dependencies) 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /Sources/RemoteImage/public/Views/RemoteImage.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RemoteImage.swift 3 | // RemoteImage 4 | // 5 | // Created by Christian Elies on 11.08.19. 6 | // Copyright © 2019 Christian Elies. All rights reserved. 7 | // 8 | 9 | #if canImport(SwiftUI) 10 | import Combine 11 | import SwiftUI 12 | 13 | /// A custom Image view for remote images with support for a loading and error state. 14 | public struct RemoteImage: View { 15 | private let type: RemoteImageType 16 | private let errorView: (Error) -> ErrorView 17 | private let imageView: (Image) -> ImageView 18 | private let loadingView: () -> LoadingView 19 | 20 | @ObservedObject private var service: Service 21 | 22 | public var body: some View { 23 | switch service.state { 24 | case .loading: 25 | loadingView() 26 | case let .error(error): 27 | errorView(error) 28 | case let .image(uiImage): 29 | #if os(macOS) 30 | imageView(Image(nsImage: uiImage)) 31 | #elseif os(iOS) 32 | imageView(Image(uiImage: uiImage)) 33 | #endif 34 | } 35 | } 36 | 37 | /// Initializes the view with the given values, especially with a custom `RemoteImageService`. 38 | /// 39 | /// - Parameters: 40 | /// - type: Specifies the source type of the remote image. Choose between `.url` or `.phAsset`. 41 | /// - service: An object conforming to the `RemoteImageService` protocol. Responsible for fetching the image and managing the state. 42 | /// - errorView: A view builder used to create the view displayed in the error state. 43 | /// - imageView: A view builder used to create the `Image` displayed in the image state. 44 | /// - loadingView: A view builder used to create the view displayed in the loading state. 45 | public init(type: RemoteImageType, service: Service, @ViewBuilder errorView: @escaping (Error) -> ErrorView, @ViewBuilder imageView: @escaping (Image) -> ImageView, @ViewBuilder loadingView: @escaping () -> LoadingView) { 46 | self.type = type 47 | self.errorView = errorView 48 | self.imageView = imageView 49 | self.loadingView = loadingView 50 | _service = ObservedObject(wrappedValue: service) 51 | 52 | service.fetchImage(ofType: type) 53 | } 54 | } 55 | 56 | extension RemoteImage where Service == DefaultRemoteImageService { 57 | /// Initializes the view with the given values. Uses the built-in `DefaultRemoteImageService`. 58 | /// 59 | /// - Parameters: 60 | /// - type: Specifies the source type of the remote image. Choose between `.url` or `.phAsset`. 61 | /// - remoteImageURLDataPublisher: An object conforming to the `RemoteImageURLDataPublisher` protocol, by default `URLSession.shared` is used. 62 | /// - errorView: A view builder used to create the view displayed in the error state. 63 | /// - imageView: A view builder used to create the `Image` displayed in the image state. 64 | /// - loadingView: A view builder used to create the view displayed in the loading state. 65 | public init(type: RemoteImageType, remoteImageURLDataPublisher: RemoteImageURLDataPublisher = URLSession.shared, @ViewBuilder errorView: @escaping (Error) -> ErrorView, @ViewBuilder imageView: @escaping (Image) -> ImageView, @ViewBuilder loadingView: @escaping () -> LoadingView) { 66 | self.type = type 67 | self.errorView = errorView 68 | self.imageView = imageView 69 | self.loadingView = loadingView 70 | 71 | let service = DefaultRemoteImageServiceFactory.makeDefaultRemoteImageService(remoteImageURLDataPublisher: remoteImageURLDataPublisher) 72 | _service = ObservedObject(wrappedValue: service) 73 | 74 | service.fetchImage(ofType: type) 75 | } 76 | } 77 | 78 | #if DEBUG 79 | struct RemoteImage_Previews: PreviewProvider { 80 | static var previews: some View { 81 | let url = URL(string: "https://images.unsplash.com/photo-1524419986249-348e8fa6ad4a?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=crop&w=1950&q=80")! 82 | return RemoteImage(type: .url(url), errorView: { error in 83 | Text(error.localizedDescription) 84 | }, imageView: { image in 85 | image 86 | }, loadingView: { 87 | Text("Loading ...") 88 | }) 89 | } 90 | } 91 | #endif 92 | 93 | #endif 94 | -------------------------------------------------------------------------------- /Tests/LinuxMain.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | 3 | import RemoteImageTests 4 | 5 | var tests = [XCTestCaseEntry]() 6 | tests += RemoteImageTests.allTests() 7 | XCTMain(tests) 8 | -------------------------------------------------------------------------------- /Tests/RemoteImageTests/Extensions/RemoteImage+Inspectable.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RemoteImage+Inspectable.swift 3 | // RemoteImageTests 4 | // 5 | // Created by Christian Elies on 22.02.20. 6 | // 7 | 8 | @testable import RemoteImage 9 | import ViewInspector 10 | 11 | extension RemoteImage: Inspectable {} 12 | -------------------------------------------------------------------------------- /Tests/RemoteImageTests/Extensions/URLSession+RemoteImageURLDataPublisherTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // URLSession+RemoteImageURLDataPublisherTests.swift 3 | // RemoteImageTests 4 | // 5 | // Created by Christian Elies on 15.12.19. 6 | // 7 | 8 | import Foundation 9 | @testable import RemoteImage 10 | import XCTest 11 | 12 | final class URLSession_RemoteImageURLDataPublisherTests: XCTestCase { 13 | func testDataPublisher() { 14 | guard let url = URL(string: "https://google.de") else { 15 | XCTFail("Could not create mock URL") 16 | return 17 | } 18 | let urlSession: URLSession = .shared 19 | let dataTaskPublisher = urlSession.dataTaskPublisher(for: url).eraseToAnyPublisher() 20 | let dataPublisher = urlSession.dataPublisher(for: url) 21 | XCTAssertEqual(dataPublisher.description, dataTaskPublisher.description) 22 | } 23 | 24 | static var allTests = [ 25 | ("testDataPublisher", testDataPublisher) 26 | ] 27 | } 28 | -------------------------------------------------------------------------------- /Tests/RemoteImageTests/Mocks/MockImageManager.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MockImageManager.swift 3 | // RemoteImageTests 4 | // 5 | // Created by Christian Elies on 14.12.19. 6 | // 7 | 8 | import Photos 9 | 10 | final class MockImageManager: PHImageManager { 11 | var imageRequestID = PHImageRequestID() 12 | var dataToReturn: Data? 13 | var infoToReturn: [AnyHashable:Any]? 14 | 15 | override func requestImageDataAndOrientation(for asset: PHAsset, options: PHImageRequestOptions?, resultHandler: @escaping (Data?, String?, CGImagePropertyOrientation, [AnyHashable : Any]?) -> Void) -> PHImageRequestID { 16 | resultHandler(dataToReturn, nil, .up, infoToReturn) 17 | return imageRequestID 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /Tests/RemoteImageTests/Mocks/MockPHAsset.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MockPHAsset.swift 3 | // RemoteImageTests 4 | // 5 | // Created by Christian Elies on 14.12.19. 6 | // 7 | 8 | import Photos 9 | 10 | final class MockPHAsset: PHAsset { 11 | static var fetchResult = MockPHAssetFetchResult() 12 | 13 | override class func fetchAssets(withLocalIdentifiers identifiers: [String], options: PHFetchOptions?) -> PHFetchResult { 14 | fetchResult 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /Tests/RemoteImageTests/Mocks/MockPHAssetFetchResult.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MockPHAssetFetchResult.swift 3 | // RemoteImageTests 4 | // 5 | // Created by Christian Elies on 14.12.19. 6 | // 7 | 8 | import Photos 9 | 10 | final class MockPHAssetFetchResult: PHFetchResult { 11 | var firstObjectToReturn: PHAsset? 12 | 13 | override var firstObject: PHAsset? { firstObjectToReturn } 14 | } 15 | -------------------------------------------------------------------------------- /Tests/RemoteImageTests/Mocks/MockPhotoKitService.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MockPhotoKitService.swift 3 | // RemoteImageTests 4 | // 5 | // Created by Christian Elies on 15.12.19. 6 | // 7 | 8 | import Foundation 9 | @testable import RemoteImage 10 | 11 | final class MockPhotoKitService: PhotoKitServiceProtocol { 12 | var resultToReturn: Result = .success(Data()) 13 | 14 | func getPhotoData(localIdentifier: String, _ completion: @escaping (Result) -> Void) { 15 | completion(resultToReturn) 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /Tests/RemoteImageTests/Mocks/MockRemoteImageServiceDependencies.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MockRemoteImageServiceDependencies.swift 3 | // RemoteImageTests 4 | // 5 | // Created by Christian Elies on 15.12.19. 6 | // 7 | 8 | @testable import RemoteImage 9 | 10 | struct MockRemoteImageServiceDependencies: DefaultRemoteImageServiceDependenciesProtocol { 11 | let photoKitService: PhotoKitServiceProtocol 12 | let remoteImageURLDataPublisher: RemoteImageURLDataPublisher 13 | 14 | init() { 15 | photoKitService = MockPhotoKitService() 16 | remoteImageURLDataPublisher = MockRemoteImageURLDataPublisher() 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /Tests/RemoteImageTests/Mocks/MockRemoteImageURLDataPublisher.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MockRemoteImageURLDataPublisher.swift 3 | // RemoteImageTests 4 | // 5 | // Created by Christian Elies on 15.12.19. 6 | // 7 | 8 | import Combine 9 | import Foundation 10 | @testable import RemoteImage 11 | 12 | final class MockRemoteImageURLDataPublisher: RemoteImageURLDataPublisher { 13 | var publisher = PassthroughSubject<(data: Data, response: URLResponse), URLError>() 14 | 15 | func dataPublisher(for url: URL) -> AnyPublisher<(data: Data, response: URLResponse), URLError> { 16 | publisher.eraseToAnyPublisher() 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /Tests/RemoteImageTests/Models/PhotoKitServiceErrorTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PhotoKitServiceErrorTests.swift 3 | // RemoteImageTests 4 | // 5 | // Created by Christian Elies on 14.12.19. 6 | // 7 | 8 | @testable import RemoteImage 9 | import XCTest 10 | 11 | final class PhotoKitServiceErrorTests: XCTestCase { 12 | let localIdentifier = "TestIdentifier" 13 | 14 | func testMissingDataErrorDescription() { 15 | let error: PhotoKitServiceError = .missingData 16 | let expectedErrorDescription = "The asset could not be loaded." 17 | XCTAssertEqual(error.errorDescription, expectedErrorDescription) 18 | } 19 | 20 | func testMissingDataFailureReason() { 21 | let error: PhotoKitServiceError = .missingData 22 | let expectedFailureReason = "The asset data could not be fetched. Maybe you are not connected to the internet." 23 | XCTAssertEqual(error.failureReason, expectedFailureReason) 24 | } 25 | 26 | func testMissingDataRecoverySuggestion() { 27 | let error: PhotoKitServiceError = .missingData 28 | let expectedRecoverySuggestion = "Check your internet connection or try again later." 29 | XCTAssertEqual(error.recoverySuggestion, expectedRecoverySuggestion) 30 | } 31 | 32 | func testMissingDataErrorCode() { 33 | let error: PhotoKitServiceError = .missingData 34 | let expectedErrorCode: Int = 0 35 | XCTAssertEqual(error.errorCode, expectedErrorCode) 36 | } 37 | 38 | func testPhAssetNotFoundErrorDescription() { 39 | let error: PhotoKitServiceError = .phAssetNotFound(localIdentifier: localIdentifier) 40 | let expectedErrorDescription = "A PHAsset with the identifier \(localIdentifier) was not found." 41 | XCTAssertEqual(error.errorDescription, expectedErrorDescription) 42 | } 43 | 44 | func testPhAssetNotFoundFailureReason() { 45 | let error: PhotoKitServiceError = .phAssetNotFound(localIdentifier: localIdentifier) 46 | let expectedFailureReason = "An asset with the identifier \(localIdentifier) doesn't exist anymore." 47 | XCTAssertEqual(error.failureReason, expectedFailureReason) 48 | } 49 | 50 | func testPhAssetNotFoundRecoverySuggestion() { 51 | let error: PhotoKitServiceError = .phAssetNotFound(localIdentifier: localIdentifier) 52 | XCTAssertNil(error.recoverySuggestion) 53 | } 54 | 55 | func testPhAssetNotFoundErrorCode() { 56 | let error: PhotoKitServiceError = .phAssetNotFound(localIdentifier: localIdentifier) 57 | let expectedErrorCode: Int = 1 58 | XCTAssertEqual(error.errorCode, expectedErrorCode) 59 | } 60 | 61 | func testErrorDomain() { 62 | XCTAssertEqual(PhotoKitServiceError.errorDomain, String(describing: PhotoKitService.self)) 63 | } 64 | 65 | static var allTests = [ 66 | ("testMissingDataErrorDescription", testMissingDataErrorDescription), 67 | ("testMissingDataFailureReason", testMissingDataFailureReason), 68 | ("testMissingDataRecoverySuggestion", testMissingDataRecoverySuggestion), 69 | ("testMissingDataErrorCode", testMissingDataErrorCode), 70 | ("testPhAssetNotFoundErrorDescription", testPhAssetNotFoundErrorDescription), 71 | ("testPhAssetNotFoundFailureReason", testPhAssetNotFoundFailureReason), 72 | ("testPhAssetNotFoundRecoverySuggestion", testPhAssetNotFoundRecoverySuggestion), 73 | ("testPhAssetNotFoundErrorCode", testPhAssetNotFoundErrorCode), 74 | ("testErrorDomain", testErrorDomain) 75 | ] 76 | } 77 | -------------------------------------------------------------------------------- /Tests/RemoteImageTests/Models/RemoteImageServiceErrorTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RemoteImageServiceErrorTests.swift 3 | // RemoteImageTests 4 | // 5 | // Created by Christian Elies on 14.12.19. 6 | // 7 | 8 | @testable import RemoteImage 9 | import XCTest 10 | 11 | final class RemoteImageServiceErrorTests: XCTestCase { 12 | func testCouldNotCreateImageDescription() { 13 | let description = RemoteImageServiceError.couldNotCreateImage.errorDescription 14 | let expectedDescription = "Could not create image from received data" 15 | XCTAssertEqual(description, expectedDescription) 16 | } 17 | 18 | static var allTests = [ 19 | ("testCouldNotCreateImageDescription", testCouldNotCreateImageDescription) 20 | ] 21 | } 22 | -------------------------------------------------------------------------------- /Tests/RemoteImageTests/Services/DefaultRemoteImageCacheTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DefaultRemoteImageCacheTests.swift 3 | // RemoteImageTests 4 | // 5 | // Created by Christian Elies on 14.12.19. 6 | // 7 | 8 | #if canImport(UIKit) 9 | @testable import RemoteImage 10 | import UIKit 11 | import XCTest 12 | 13 | final class DefaultRemoteImageCacheTests: XCTestCase { 14 | let remoteImageCache = DefaultRemoteImageCache() 15 | 16 | override func setUp() { 17 | remoteImageCache.cache.removeAllObjects() 18 | } 19 | 20 | func testSetImage() { 21 | let key = "Test" as NSString 22 | let image = UIImage() 23 | remoteImageCache.setObject(image, forKey: key) 24 | XCTAssertEqual(remoteImageCache.object(forKey: key), image) 25 | } 26 | 27 | func testRemoveImage() { 28 | let key = "Test" as NSString 29 | let image = UIImage() 30 | remoteImageCache.setObject(image, forKey: key) 31 | XCTAssertEqual(remoteImageCache.object(forKey: key), image) 32 | remoteImageCache.removeObject(forKey: key) 33 | XCTAssertNil(remoteImageCache.object(forKey: key)) 34 | } 35 | 36 | static var allTests = [ 37 | ("testSetImage", testSetImage), 38 | ("testRemoveImage", testRemoveImage) 39 | ] 40 | } 41 | #endif 42 | -------------------------------------------------------------------------------- /Tests/RemoteImageTests/Services/PhotoKitServiceTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PhotoKitServiceTests.swift 3 | // RemoteImageTests 4 | // 5 | // Created by Christian Elies on 15.12.19. 6 | // 7 | 8 | @testable import RemoteImage 9 | import Photos 10 | import XCTest 11 | 12 | final class PhotoKitServiceTests: XCTestCase { 13 | let imageManager = MockImageManager() 14 | let asset = MockPHAsset() 15 | let service = PhotoKitService() 16 | let localIdentifier = "TestIdentifier" 17 | 18 | override func setUp() { 19 | PhotoKitService.asset = MockPHAsset.self 20 | PhotoKitService.imageManager = imageManager 21 | 22 | MockPHAsset.fetchResult.firstObjectToReturn = nil 23 | imageManager.dataToReturn = nil 24 | imageManager.infoToReturn = nil 25 | } 26 | 27 | func testPhotoDataNotFound() { 28 | let expectation = self.expectation(description: "PhotoDataResult") 29 | var result: Result? 30 | service.getPhotoData(localIdentifier: localIdentifier) { res in 31 | result = res 32 | expectation.fulfill() 33 | } 34 | 35 | waitForExpectations(timeout: 2) 36 | 37 | switch result { 38 | case .failure(let error): 39 | guard case PhotoKitServiceError.phAssetNotFound(localIdentifier) = error else { 40 | XCTFail("Invalid error") 41 | return 42 | } 43 | default: 44 | XCTFail("Invalid photo data result") 45 | } 46 | } 47 | 48 | func testPhotoDataFailure() { 49 | MockPHAsset.fetchResult.firstObjectToReturn = asset 50 | imageManager.infoToReturn = [PHImageErrorKey: PhotoKitServiceError.missingData] 51 | 52 | let expectation = self.expectation(description: "PhotoDataResult") 53 | var result: Result? 54 | service.getPhotoData(localIdentifier: localIdentifier) { res in 55 | result = res 56 | expectation.fulfill() 57 | } 58 | 59 | waitForExpectations(timeout: 2) 60 | 61 | switch result { 62 | case .failure(let error): 63 | XCTAssertEqual(error as? PhotoKitServiceError, .missingData) 64 | default: 65 | XCTFail("Invalid photo data result") 66 | } 67 | } 68 | 69 | func testPhotoDataSuccess() { 70 | let expectedData = Data() 71 | MockPHAsset.fetchResult.firstObjectToReturn = asset 72 | imageManager.dataToReturn = expectedData 73 | 74 | let expectation = self.expectation(description: "PhotoDataResult") 75 | var result: Result? 76 | service.getPhotoData(localIdentifier: localIdentifier) { res in 77 | result = res 78 | expectation.fulfill() 79 | } 80 | 81 | waitForExpectations(timeout: 2) 82 | 83 | switch result { 84 | case .success(let data): 85 | XCTAssertEqual(data, expectedData) 86 | default: 87 | XCTFail("Invalid photo data result") 88 | } 89 | } 90 | 91 | static var allTests = [ 92 | ("testPhotoDataNotFound", testPhotoDataNotFound), 93 | ("testPhotoDataFailure", testPhotoDataFailure), 94 | ("testPhotoDataSuccess", testPhotoDataSuccess) 95 | ] 96 | } 97 | -------------------------------------------------------------------------------- /Tests/RemoteImageTests/Services/RemoteImageServiceDependenciesTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RemoteImageServiceDependenciesTests.swift 3 | // RemoteImageTests 4 | // 5 | // Created by Christian Elies on 15.12.19. 6 | // 7 | 8 | import Foundation 9 | @testable import RemoteImage 10 | import XCTest 11 | 12 | final class RemoteImageServiceDependenciesTests: XCTestCase { 13 | func testInitialization() { 14 | let dependencies = DefaultRemoteImageServiceDependencies(remoteImageURLDataPublisher: URLSession.shared) 15 | XCTAssertTrue(dependencies.photoKitService is PhotoKitService) 16 | XCTAssertTrue(dependencies.remoteImageURLDataPublisher is URLSession) 17 | } 18 | 19 | static var allTests = [ 20 | ("testInitialization", testInitialization) 21 | ] 22 | } 23 | -------------------------------------------------------------------------------- /Tests/RemoteImageTests/Services/RemoteImageServiceFactoryTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RemoteImageServiceFactoryTests.swift 3 | // RemoteImageTests 4 | // 5 | // Created by Christian Elies on 15.12.19. 6 | // 7 | 8 | #if canImport(UIKit) 9 | @testable import RemoteImage 10 | import XCTest 11 | 12 | final class RemoteImageServiceFactoryTests: XCTestCase { 13 | func testMakeRemoteImageService() { 14 | let service = DefaultRemoteImageServiceFactory.makeDefaultRemoteImageService(remoteImageURLDataPublisher: URLSession.shared) 15 | XCTAssertEqual(service.state, .loading) 16 | } 17 | 18 | static var allTests = [ 19 | ("testMakeRemoteImageService", testMakeRemoteImageService) 20 | ] 21 | } 22 | #endif 23 | -------------------------------------------------------------------------------- /Tests/RemoteImageTests/Services/RemoteImageServiceTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RemoteImageServiceTests.swift 3 | // RemoteImageTests 4 | // 5 | // Created by Christian Elies on 15.12.19. 6 | // 7 | 8 | #if canImport(SwiftUI) && canImport(UIKit) 9 | import Combine 10 | @testable import RemoteImage 11 | import SwiftUI 12 | import UIKit 13 | import XCTest 14 | 15 | final class RemoteImageServiceTests: XCTestCase { 16 | private var cancellable: AnyCancellable? 17 | 18 | let dependencies = MockRemoteImageServiceDependencies() 19 | lazy var photoKitService = dependencies.photoKitService as? MockPhotoKitService 20 | lazy var remoteImageURLDataPublisher = dependencies.remoteImageURLDataPublisher as? MockRemoteImageURLDataPublisher 21 | lazy var service = DefaultRemoteImageService(dependencies: dependencies) 22 | 23 | override func setUp() { 24 | DefaultRemoteImageService.cache.removeAllObjects() 25 | photoKitService?.resultToReturn = .success(Data()) 26 | } 27 | 28 | func testFetchImageURLSuccess() { 29 | guard let url = URL(string: "https://www.google.de") else { 30 | XCTFail("Could not create mock URL") 31 | return 32 | } 33 | 34 | guard let data = UIImage(systemName: "paperplane.fill")?.jpegData(compressionQuality: 1) else { 35 | XCTFail("Could not create mock data") 36 | return 37 | } 38 | 39 | let expectation = self.expectation(description: "FetchImageURL") 40 | let response = URLResponse() 41 | let remoteImageType: RemoteImageType = .url(url) 42 | service.fetchImage(ofType: remoteImageType) 43 | 44 | // publish mock data 45 | remoteImageURLDataPublisher?.publisher.send((data: data, response: response)) 46 | 47 | var state: RemoteImageState? 48 | cancellable = service.$state.sink { st in 49 | guard case RemoteImageState.image = st else { 50 | return 51 | } 52 | state = st 53 | expectation.fulfill() 54 | } 55 | 56 | waitForExpectations(timeout: 1) 57 | 58 | switch state { 59 | case .image(let image): 60 | XCTAssertNotNil(image.imageAsset) 61 | default: 62 | XCTFail("Invalid fetch image URL result") 63 | } 64 | } 65 | 66 | func testFetchImageURLFailure() { 67 | guard let url = URL(string: "https://www.google.de") else { 68 | XCTFail("Could not create mock URL") 69 | return 70 | } 71 | 72 | let data = Data() 73 | 74 | let expectation = self.expectation(description: "FetchImageURL") 75 | let response = URLResponse() 76 | let remoteImageType: RemoteImageType = .url(url) 77 | service.fetchImage(ofType: remoteImageType) 78 | 79 | // publish mock data 80 | remoteImageURLDataPublisher?.publisher.send((data: data, response: response)) 81 | 82 | var state: RemoteImageState? 83 | cancellable = service.$state.sink { st in 84 | guard case RemoteImageState.error = st else { 85 | return 86 | } 87 | state = st 88 | expectation.fulfill() 89 | } 90 | 91 | waitForExpectations(timeout: 1) 92 | 93 | switch state { 94 | case .error(let error): 95 | XCTAssertEqual(error as? RemoteImageServiceError, .couldNotCreateImage) 96 | default: 97 | XCTFail("Invalid fetch image URL result") 98 | } 99 | } 100 | 101 | func testFetchImageURLFailureCompletion() { 102 | guard let url = URL(string: "https://www.google.de") else { 103 | XCTFail("Could not create mock URL") 104 | return 105 | } 106 | 107 | let expectation = self.expectation(description: "FetchImageURLState") 108 | 109 | let remoteImageType: RemoteImageType = .url(url) 110 | service.fetchImage(ofType: remoteImageType) 111 | 112 | // publish completion 113 | let expectedError = URLError(.cancelled) 114 | remoteImageURLDataPublisher?.publisher.send(completion: .failure(expectedError)) 115 | 116 | var state: RemoteImageState? 117 | cancellable = service.$state.sink { st in 118 | guard case RemoteImageState.error = st else { 119 | return 120 | } 121 | state = st 122 | expectation.fulfill() 123 | } 124 | 125 | waitForExpectations(timeout: 1) 126 | 127 | switch state { 128 | case .error(let error): 129 | XCTAssertEqual(error as? URLError, expectedError) 130 | default: 131 | XCTFail("Invalid fetch image URL completion") 132 | } 133 | } 134 | 135 | func testFetchImageURLCached() { 136 | guard let url = URL(string: "https://www.google.de") else { 137 | XCTFail("Could not create mock URL") 138 | return 139 | } 140 | 141 | guard let image = UIImage(systemName: "paperplane.fill") else { 142 | XCTFail("Could not create mock image") 143 | return 144 | } 145 | 146 | let cacheKey = url as NSURL 147 | DefaultRemoteImageService.cache.setObject(image, forKey: cacheKey) 148 | 149 | let expectation = self.expectation(description: "FetchImageURLCached") 150 | let remoteImageType: RemoteImageType = .url(url) 151 | service.fetchImage(ofType: remoteImageType) 152 | 153 | var state: RemoteImageState? 154 | cancellable = service.$state.sink { st in 155 | guard case RemoteImageState.image = st else { 156 | return 157 | } 158 | state = st 159 | expectation.fulfill() 160 | } 161 | 162 | waitForExpectations(timeout: 1) 163 | 164 | switch state { 165 | case .image(let image): 166 | XCTAssertNotNil(image.imageAsset) 167 | default: 168 | XCTFail("Invalid fetch image URL cached result") 169 | } 170 | } 171 | 172 | func testFetchPHAssetSuccess() { 173 | guard let data = UIImage(systemName: "paperplane.fill")?.jpegData(compressionQuality: 1) else { 174 | XCTFail("Could not create mock data") 175 | return 176 | } 177 | 178 | let expectation = self.expectation(description: "FetchPHAsset") 179 | photoKitService?.resultToReturn = .success(data) 180 | let localIdentifier = "TestIdentifier" 181 | let remoteImageType: RemoteImageType = .phAsset(localIdentifier: localIdentifier) 182 | service.fetchImage(ofType: remoteImageType) 183 | 184 | var state: RemoteImageState? 185 | cancellable = service.$state.sink { st in 186 | guard case RemoteImageState.image = st else { 187 | return 188 | } 189 | state = st 190 | expectation.fulfill() 191 | } 192 | 193 | waitForExpectations(timeout: 1) 194 | 195 | switch state { 196 | case .image(let image): 197 | XCTAssertNotNil(image.imageAsset) 198 | default: 199 | XCTFail("Invalid fetch ph asset result") 200 | } 201 | } 202 | 203 | func testFetchPHAccessInvalidData() { 204 | let expectation = self.expectation(description: "FetchPHAsset") 205 | photoKitService?.resultToReturn = .success(Data()) 206 | let localIdentifier = "TestIdentifier" 207 | let remoteImageType: RemoteImageType = .phAsset(localIdentifier: localIdentifier) 208 | service.fetchImage(ofType: remoteImageType) 209 | 210 | var state: RemoteImageState? 211 | cancellable = service.$state.sink { st in 212 | guard case RemoteImageState.error = st else { 213 | return 214 | } 215 | state = st 216 | expectation.fulfill() 217 | } 218 | 219 | waitForExpectations(timeout: 1) 220 | 221 | switch state { 222 | case .error(let error): 223 | XCTAssertEqual(error as? RemoteImageServiceError, .couldNotCreateImage) 224 | default: 225 | XCTFail("Invalid fetch ph asset result") 226 | } 227 | } 228 | 229 | func testFetchPHAccessFailure() { 230 | let expectation = self.expectation(description: "FetchPHAsset") 231 | let expectedError: RemoteImageServiceError = .couldNotCreateImage 232 | photoKitService?.resultToReturn = .failure(expectedError) 233 | let localIdentifier = "TestIdentifier" 234 | let remoteImageType: RemoteImageType = .phAsset(localIdentifier: localIdentifier) 235 | service.fetchImage(ofType: remoteImageType) 236 | 237 | var state: RemoteImageState? 238 | cancellable = service.$state.sink { st in 239 | guard case RemoteImageState.error = st else { 240 | return 241 | } 242 | state = st 243 | expectation.fulfill() 244 | } 245 | 246 | waitForExpectations(timeout: 1) 247 | 248 | switch state { 249 | case .error(let error): 250 | XCTAssertEqual(error as? RemoteImageServiceError, expectedError) 251 | default: 252 | XCTFail("Invalid fetch ph asset result") 253 | } 254 | } 255 | 256 | func testFetchPHAssetCached() { 257 | guard let image = UIImage(systemName: "paperplane.fill") else { 258 | XCTFail("Could not create mock image") 259 | return 260 | } 261 | 262 | let localIdentifier = "TestIdentifier" 263 | let cacheKey = localIdentifier as NSString 264 | DefaultRemoteImageService.cache.setObject(image, forKey: cacheKey) 265 | 266 | let expectation = self.expectation(description: "FetchPHAssetCached") 267 | let remoteImageType: RemoteImageType = .phAsset(localIdentifier: localIdentifier) 268 | service.fetchImage(ofType: remoteImageType) 269 | 270 | var state: RemoteImageState? 271 | cancellable = service.$state.sink { st in 272 | guard case RemoteImageState.image = st else { 273 | return 274 | } 275 | state = st 276 | expectation.fulfill() 277 | } 278 | 279 | waitForExpectations(timeout: 1) 280 | 281 | switch state { 282 | case .image(let image): 283 | XCTAssertNotNil(image.imageAsset) 284 | default: 285 | XCTFail("Invalid fetch ph asset cached result") 286 | } 287 | } 288 | 289 | static var allTests = [ 290 | ("testFetchImageURLSuccess", testFetchImageURLSuccess), 291 | ("testFetchImageURLFailure", testFetchImageURLFailure), 292 | ("testFetchImageURLFailureCompletion", testFetchImageURLFailureCompletion), 293 | ("testFetchImageURLCached", testFetchImageURLCached), 294 | ("testFetchPHAssetSuccess", testFetchPHAssetSuccess), 295 | ("testFetchPHAccessInvalidData", testFetchPHAccessInvalidData), 296 | ("testFetchPHAccessFailure", testFetchPHAccessFailure), 297 | ("testFetchPHAssetCached", testFetchPHAssetCached) 298 | ] 299 | } 300 | #endif 301 | -------------------------------------------------------------------------------- /Tests/RemoteImageTests/Views/RemoteImageTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RemoteImageTests.swift 3 | // RemoteImageTests 4 | // 5 | // Created by Christian Elies on 22.02.20. 6 | // 7 | 8 | #if canImport(UIKit) 9 | @testable import RemoteImage 10 | import SwiftUI 11 | import ViewInspector 12 | import XCTest 13 | 14 | final class RemoteImageTests: XCTestCase { 15 | private let fileManager: FileManager = .default 16 | private lazy var mockImageURL = URL(fileURLWithPath: "\(fileManager.currentDirectoryPath)/mock.jpeg") 17 | private let errorStateViewString = "Error" 18 | private lazy var errorView = Text(errorStateViewString) 19 | private let loadingStateViewString = "Loading ..." 20 | private lazy var loadingView = Text(loadingStateViewString) 21 | 22 | func testLoadingState() { 23 | let view = RemoteImage(type: .url(mockImageURL), 24 | errorView: { _ in self.errorView }, 25 | imageView: { image in image }, 26 | loadingView: { self.loadingView }) 27 | 28 | do { 29 | let inspectableView = try view.body.inspect() 30 | let text = try inspectableView.text() 31 | let textString = try text.string() 32 | XCTAssertEqual(textString, loadingStateViewString) 33 | } catch { 34 | XCTFail("\(error)") 35 | } 36 | } 37 | 38 | static var allTests = [ 39 | ("testLoadingState", testLoadingState) 40 | ] 41 | } 42 | #endif 43 | -------------------------------------------------------------------------------- /Tests/RemoteImageTests/XCTestManifests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | 3 | #if !canImport(ObjectiveC) 4 | public func allTests() -> [XCTestCaseEntry] { 5 | return [ 6 | testCase(DefaultRemoteImageCacheTests.allTests), 7 | testCase(PhotoKitServiceErrorTests.allTests), 8 | testCase(PhotoKitServiceTests.allTests), 9 | testCase(RemoteImageTests.allTests), 10 | testCase(RemoteImageServiceDependenciesTests.allTests), 11 | testCase(RemoteImageServiceErrorTests.allTests), 12 | testCase(RemoteImageServiceFactoryTests.allTests), 13 | testCase(RemoteImageServiceTests.allTests), 14 | testCase(RemoteImageStateTests.allTests), 15 | testCase(URLSession_RemoteImageURLDataPublisherTests.allTests) 16 | ] 17 | } 18 | #endif 19 | --------------------------------------------------------------------------------