├── .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 | [](https://developer.apple.com/swift)
4 | [](https://www.apple.com)
5 | [](https://github.com/crelies/RemoteImage)
6 | [](https://github.com/crelies/RemoteImage/actions/workflows/build.yml)
7 | [](https://codecov.io/gh/crelies/RemoteImage)
8 | [](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 |
--------------------------------------------------------------------------------