├── .github └── FUNDING.yml ├── .gitignore ├── CHANGELOG.md ├── LICENSE ├── NukeUI.podspec ├── Package.resolved ├── Package.swift ├── README.md └── Sources ├── Image.swift ├── ImageView.swift ├── Internal.swift ├── LazyImage.swift ├── LazyImageView.swift └── VideoPlayerView.swift /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: kean 4 | patreon: # Replace with a single Patreon username 5 | open_collective: # Replace with a single Open Collective username 6 | ko_fi: # Replace with a single Ko-fi username 7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 8 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry 9 | liberapay: # Replace with a single Liberapay username 10 | issuehunt: # Replace with a single IssueHunt username 11 | otechie: # Replace with a single Otechie username 12 | custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] 13 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Xcode 2 | # 3 | # gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore 4 | 5 | ## User settings 6 | xcuserdata/ 7 | 8 | ## compatibility with Xcode 8 and earlier (ignoring not required starting Xcode 9) 9 | *.xcscmblueprint 10 | *.xccheckout 11 | 12 | ## compatibility with Xcode 3 and earlier (ignoring not required starting Xcode 4) 13 | build/ 14 | DerivedData/ 15 | *.moved-aside 16 | *.pbxuser 17 | !default.pbxuser 18 | *.mode1v3 19 | !default.mode1v3 20 | *.mode2v3 21 | !default.mode2v3 22 | *.perspectivev3 23 | !default.perspectivev3 24 | 25 | ## Obj-C/Swift specific 26 | *.hmap 27 | 28 | ## App packaging 29 | *.ipa 30 | *.dSYM.zip 31 | *.dSYM 32 | 33 | ## Playgrounds 34 | timeline.xctimeline 35 | playground.xcworkspace 36 | 37 | # Swift Package Manager 38 | # 39 | # Add this line if you want to avoid checking in source code from Swift Package Manager dependencies. 40 | # Packages/ 41 | # Package.pins 42 | # Package.resolved 43 | # *.xcodeproj 44 | # 45 | # Xcode automatically generates this directory with a .xcworkspacedata file and xcuserdata 46 | # hence it is not needed unless you have added a package configuration file to your project 47 | .swiftpm 48 | 49 | .build/ 50 | 51 | # CocoaPods 52 | # 53 | # We recommend against adding the Pods directory to your .gitignore. However 54 | # you should judge for yourself, the pros and cons are mentioned at: 55 | # https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control 56 | # 57 | # Pods/ 58 | # 59 | # Add this line if you want to avoid checking in source code from the Xcode workspace 60 | # *.xcworkspace 61 | 62 | # Carthage 63 | # 64 | # Add this line if you want to avoid checking in source code from Carthage dependencies. 65 | # Carthage/Checkouts 66 | 67 | Carthage/Build/ 68 | 69 | # Accio dependency management 70 | Dependencies/ 71 | .accio/ 72 | 73 | # fastlane 74 | # 75 | # It is recommended to not store the screenshots in the git repo. 76 | # Instead, use fastlane to re-generate the screenshots whenever they are needed. 77 | # For more information about the recommended setup visit: 78 | # https://docs.fastlane.tools/best-practices/source-control/#source-control 79 | 80 | fastlane/report.xml 81 | fastlane/Preview.html 82 | fastlane/screenshots/**/*.png 83 | fastlane/test_output 84 | 85 | # Code Injection 86 | # 87 | # After new code Injection tools there's a generated folder /iOSInjectionProject 88 | # https://github.com/johnno1962/injectionforxcode 89 | 90 | iOSInjectionProject/ 91 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | 2 | # NukeUI 0.x 3 | 4 | ## NukeUI 0.8.2 5 | 6 | *Jun 12, 2022* 7 | 8 | - Fix an issue with video playback not resuming when retruning from background - [#46](https://github.com/kean/NukeUI/pull/46), thanks to [Andy Meagher](https://github.com/AndyMeagher) 9 | 10 | ## NukeUI 0.8.1 11 | 12 | *Apr 3, 2022* 13 | 14 | - Fix an issue with previous custom `contentView` not removed on reuse - [#40](https://github.com/kean/NukeUI/pull/40), thanks to [Jon Funkhouser](https://github.com/jonfunkhouser) 15 | 16 | ## NukeUI 0.8.0 17 | 18 | *Jan 27, 2022* 19 | 20 | - Add `DisappearBehavior.lowerPriority` - [#33](https://github.com/kean/NukeUI/pull/33), thanks to [Peter Kurzok](https://github.com/pkurzok) 21 | - Deprecate `DisappearBehavior.reset` (crashy) 22 | - Add more options to control video: loop, finish callback – [#32](https://github.com/kean/NukeUI/pull/32), thanks to [Son Changwoo](https://github.com/kor45cw) 23 | - Fix GIF playback when using customizing image view using `LazyImageState` - [#34](https://github.com/kean/NukeUI/issues/34) 24 | 25 | ## NukeUI 0.7.0 26 | 27 | *Oct 26, 2021* 28 | 29 | - Update to Nuke 10.5 (video support moved to Nuke) 30 | 31 | ## NukeUI 0.6.8 32 | 33 | *Aug 29, 2021* 34 | 35 | - Fix a compilation issue on Catalyst - [#16](https://github.com/kean/NukeUI/issues/16) 36 | 37 | ## NukeUI 0.6.5 38 | 39 | *Jul 26, 2021* 40 | 41 | - Fix an issue with incorrect `source` change handling - [#14](https://github.com/kean/NukeUI/issues/14) 42 | 43 | ## NukeUI 0.6.4 44 | 45 | *Jul 18, 2021* 46 | 47 | - Fix an issue with video decoder not being registered automatically for `LazyImage` - [#495](https://github.com/kean/Nuke/issues/495) 48 | 49 | ## NukeUI 0.6.3 50 | 51 | *Jul 8, 2021* 52 | 53 | - Revert the changes to `Image` sizing behavior. Now it again simply takes all the available space and you can use `resizingMode` to change the image rendering behavior. 54 | 55 | ## NukeUI 0.6.1 56 | 57 | *Jun 11, 2021* 58 | 59 | - Fix default placeholder color for `LazyImage` 60 | - Update `LazyImageView` to match `LazyImage` in terms of the default parameters: placeholder and animation 61 | 62 | ## NukeUI 0.6.0 63 | 64 | *Jun 11, 2021* 65 | 66 | - Add `ImageView` (UIKit, AppKit) and `Image` (SwiftUI) components that support animated images and are now used by `LazyImageView` 67 | - Remove `LazyImageView` API for setting image, use `ImageView` directly instead 68 | - Fix reloading when the source changes but view identity is the same 69 | - All views now support video rendering by default 70 | - Rename `contentMode` to `resizingMode` 71 | - `LazyImage` custom initialized now suggest `NukeUI.Image` 72 | 73 | ## NukeUI 0.5.0 74 | 75 | *Jun 10, 2021* 76 | 77 | - Rework `LazyImage` to use `FetchImage` on all platforms 78 | - Add new `init(source:content:)` initializer to `LazyImage`: 79 | 80 | ```swift 81 | LazyImage(source: $0) { state in 82 | if let image = state.image { 83 | image // Displays the loaded image 84 | } else if state.error != nil { 85 | Color.red // Indicates an error 86 | } else { 87 | Color.blue // Acts as a placeholder 88 | } 89 | } 90 | ``` 91 | 92 | - Add default placeholder to `LazyImage` (gray background) 93 | - Temporarily increase `LazyImage` supported platforms to iOS 14.0, tvOS 14.0, watchOS 7.0, macOS 10.16 94 | - `LazyImage` on watchOS now has an almost complete feature parity with other platforms. The main exception is the support for animated images which is currently missing. 95 | - Remove `LazyImage` initializer that take `ImageContainer` – use `LazyImageView` directly instead 96 | - Add infrastructure for registering custom rendering engines: 97 | 98 | ```swift 99 | import SwiftSVG 100 | 101 | // Affects both all `LazyImage` and `LazyImageView` instances 102 | LazyImageView.registerContentView { 103 | if $0.type == .svg, let string = $0.data.map( { 104 | UIView(SVGData: data) 105 | } 106 | return nil 107 | } 108 | ``` 109 | 110 | ## NukeUI 0.4.0 111 | 112 | *Jun 6, 2021* 113 | 114 | - Extend watchOS support 115 | - Add animated images support on macOS 116 | - Display a GIF preview until it is ready to be played (Nuke 10.2 feature) 117 | - Fix how images are displayed on macOS by default 118 | 119 | ## NukeUI 0.3.0 120 | 121 | *Jun 4, 2021* 122 | 123 | - Allow user interaction during animated transitions 124 | - Animated transitions are now supported for video 125 | - Add access to the underlying `videoPlayerView`, remove separate `videoGravity` property 126 | - Add `isLooping` property to `VideoPlayerView` which is `true` by default 127 | - Add `contentView` where all content views (both images and video) are displayed. It simplified animations. 128 | 129 | ## NukeUI 0.2.0 130 | 131 | *Jun 3, 2021* 132 | 133 | - Display the first frame of the video as a preview until the video is downloaded and ready to be played 134 | - Enable video rendering by default. The option renamed from `isExperimentalVideoSupportEnabled` to `isVideoRenderingEnabled`. 135 | - Make sure video doesn't prevent the display from sleeping by setting `preventsDisplaySleepDuringVideoPlayback` to `false` 136 | - Add video support on macOS 137 | - Optimize performance during scrolling 138 | 139 | ## NukeUI 0.1.0 140 | 141 | *Jun 1, 2021* 142 | 143 | - Initial release 144 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Alexander Grebenyuk 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 | -------------------------------------------------------------------------------- /NukeUI.podspec: -------------------------------------------------------------------------------- 1 | Pod::Spec.new do |s| 2 | s.name = 'NukeUI' 3 | s.version = '0.8.1' 4 | s.summary = 'A powerful image loading and caching system' 5 | s.description = <<-EOS 6 | A powerful image loading and caching system which makes simple tasks like loading images into views extremely simple, while also supporting more advanced features for more demanding apps. 7 | EOS 8 | 9 | s.homepage = 'https://github.com/kean/NukeUI' 10 | s.license = 'MIT' 11 | s.author = 'Alexander Grebenyuk' 12 | s.social_media_url = 'https://twitter.com/a_grebenyuk' 13 | s.source = { :git => 'https://github.com/kean/NukeUI.git', :tag => s.version.to_s } 14 | 15 | s.swift_versions = ['5.1', '5.2', '5.3', '5.4'] 16 | 17 | s.ios.deployment_target = '12.0' 18 | s.macos.deployment_target = '10.14' 19 | s.tvos.deployment_target = '12.0' 20 | s.watchos.deployment_target = '5.0' 21 | 22 | s.source_files = 'Sources/**/*' 23 | 24 | s.dependency 'Nuke', '~> 10.5' 25 | s.ios.dependency 'Gifu', '~> 3.0' 26 | s.tvos.dependency 'Gifu', '~> 3.0' 27 | end 28 | -------------------------------------------------------------------------------- /Package.resolved: -------------------------------------------------------------------------------- 1 | { 2 | "object": { 3 | "pins": [ 4 | { 5 | "package": "Gifu", 6 | "repositoryURL": "https://github.com/kaishin/Gifu", 7 | "state": { 8 | "branch": null, 9 | "revision": "0ffe24744cc3d82ab9edece53670d0352c6d5507", 10 | "version": "3.3.0" 11 | } 12 | }, 13 | { 14 | "package": "Nuke", 15 | "repositoryURL": "https://github.com/kean/Nuke.git", 16 | "state": { 17 | "branch": null, 18 | "revision": "472a03c15f39592007d94f25abe554350bc8aa2b", 19 | "version": "10.5.1" 20 | } 21 | } 22 | ] 23 | }, 24 | "version": 1 25 | } 26 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version:5.3 2 | import PackageDescription 3 | 4 | let package = Package( 5 | name: "NukeUI", 6 | platforms: [ 7 | .macOS(.v10_14), 8 | .iOS(.v12), 9 | .tvOS(.v12), 10 | .watchOS(.v5) 11 | ], 12 | products: [ 13 | .library(name: "NukeUI", targets: ["NukeUI"]) 14 | ], 15 | dependencies: [ 16 | .package(url: "https://github.com/kean/Nuke.git", from: "10.5.0"), 17 | .package(url: "https://github.com/kaishin/Gifu", from: "3.0.0") 18 | ], 19 | targets: [ 20 | .target(name: "NukeUI", dependencies: [ 21 | .product(name: "Nuke", package: "Nuke"), 22 | .product(name: "Gifu", package: "Gifu", condition: .when(platforms: [.iOS, .tvOS])) 23 | ], path: "Sources") 24 | ] 25 | ) 26 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # NukeUI 2 | 3 | **Update**: Starting with [Nuke 11](https://github.com/kean/Nuke/releases/tag/11.0.0), NukeUI is now part of the main repo. 4 | -------------------------------------------------------------------------------- /Sources/Image.swift: -------------------------------------------------------------------------------- 1 | // The MIT License (MIT) 2 | // 3 | // Copyright (c) 2015-2021 Alexander Grebenyuk (github.com/kean). 4 | 5 | import SwiftUI 6 | 7 | #if os(macOS) 8 | import AppKit 9 | #else 10 | import UIKit 11 | #endif 12 | 13 | #if os(macOS) 14 | @available(iOS 13.0, tvOS 13.0, macOS 10.15, *) 15 | public struct Image: NSViewRepresentable { 16 | let imageContainer: ImageContainer 17 | let onCreated: ((ImageView) -> Void)? 18 | @Binding var isVideoLooping: Bool 19 | var onVideoFinished: (() -> Void)? 20 | var restartVideo: Bool = false 21 | 22 | public init(_ image: NSImage) { 23 | self.init(ImageContainer(image: image)) 24 | } 25 | 26 | public init(_ imageContainer: ImageContainer, 27 | isVideoLooping: Binding = .constant(true), 28 | onCreated: ((ImageView) -> Void)? = nil) { 29 | self.imageContainer = imageContainer 30 | self._isVideoLooping = isVideoLooping 31 | self.onCreated = onCreated 32 | } 33 | 34 | public func makeNSView(context: Context) -> ImageView { 35 | let view = ImageView() 36 | if view.isVideoLooping != isVideoLooping { 37 | view.isVideoLooping = isVideoLooping 38 | } 39 | view.onVideoFinished = onVideoFinished 40 | onCreated?(view) 41 | return view 42 | } 43 | 44 | public func updateNSView(_ imageView: ImageView, context: Context) { 45 | if imageView.isVideoLooping != isVideoLooping { 46 | imageView.isVideoLooping = isVideoLooping 47 | } 48 | if restartVideo { 49 | imageView.restartVideo() 50 | } 51 | guard imageView.imageContainer?.image !== imageContainer.image else { return } 52 | imageView.imageContainer = imageContainer 53 | } 54 | 55 | public func onVideoFinished(content: @escaping () -> Void) -> Self { 56 | var copy = self 57 | copy.onVideoFinished = content 58 | return copy 59 | } 60 | 61 | public func restartVideo(_ value: Bool) -> Self { 62 | var copy = self 63 | copy.restartVideo = value 64 | return copy 65 | } 66 | } 67 | #elseif os(iOS) || os(tvOS) 68 | @available(iOS 13.0, tvOS 13.0, macOS 10.15, *) 69 | public struct Image: UIViewRepresentable { 70 | let imageContainer: ImageContainer 71 | let onCreated: ((ImageView) -> Void)? 72 | var resizingMode: ImageResizingMode? 73 | @Binding var isVideoLooping: Bool 74 | var onVideoFinished: (() -> Void)? 75 | var restartVideo: Bool = false 76 | 77 | public init(_ image: UIImage) { 78 | self.init(ImageContainer(image: image)) 79 | } 80 | 81 | public init(_ imageContainer: ImageContainer, 82 | isVideoLooping: Binding = .constant(true), 83 | onCreated: ((ImageView) -> Void)? = nil) { 84 | self.imageContainer = imageContainer 85 | self._isVideoLooping = isVideoLooping 86 | self.onCreated = onCreated 87 | } 88 | 89 | public func makeUIView(context: Context) -> ImageView { 90 | let imageView = ImageView() 91 | if let resizingMode = self.resizingMode { 92 | imageView.resizingMode = resizingMode 93 | } 94 | if imageView.isVideoLooping != isVideoLooping { 95 | imageView.isVideoLooping = isVideoLooping 96 | } 97 | imageView.onVideoFinished = onVideoFinished 98 | onCreated?(imageView) 99 | return imageView 100 | } 101 | 102 | public func updateUIView(_ imageView: ImageView, context: Context) { 103 | if imageView.isVideoLooping != isVideoLooping { 104 | imageView.isVideoLooping = isVideoLooping 105 | } 106 | if restartVideo { 107 | imageView.restartVideo() 108 | } 109 | guard imageView.imageContainer?.image !== imageContainer.image else { return } 110 | imageView.imageContainer = imageContainer 111 | } 112 | 113 | /// Sets the resizing mode for the image. 114 | public func resizingMode(_ mode: ImageResizingMode) -> Self { 115 | var copy = self 116 | copy.resizingMode = mode 117 | return copy 118 | } 119 | 120 | public func onVideoFinished(content: @escaping () -> Void) -> Self { 121 | var copy = self 122 | copy.onVideoFinished = content 123 | return copy 124 | } 125 | 126 | public func restartVideo(_ value: Bool) -> Self { 127 | var copy = self 128 | copy.restartVideo = value 129 | return copy 130 | } 131 | } 132 | #endif 133 | -------------------------------------------------------------------------------- /Sources/ImageView.swift: -------------------------------------------------------------------------------- 1 | // The MIT License (MIT) 2 | // 3 | // Copyright (c) 2015-2021 Alexander Grebenyuk (github.com/kean). 4 | 5 | import Foundation 6 | 7 | #if !os(watchOS) 8 | 9 | #if os(macOS) 10 | import AppKit 11 | #else 12 | import UIKit 13 | #endif 14 | 15 | #if (os(iOS) || os(tvOS)) && !targetEnvironment(macCatalyst) 16 | import Gifu 17 | 18 | public typealias AnimatedImageView = Gifu.GIFImageView 19 | #endif 20 | 21 | /// Lazily loads and displays images. 22 | public class ImageView: _PlatformBaseView { 23 | 24 | // MARK: Underlying Views 25 | 26 | #if os(macOS) 27 | /// Returns an underlying image view. 28 | public let imageView = NSImageView() 29 | #else 30 | /// Returns an underlying image view. 31 | public let imageView = UIImageView() 32 | #endif 33 | 34 | #if os(iOS) || os(tvOS) 35 | /// Sets the content mode for all container views. 36 | public var resizingMode: ImageResizingMode = .aspectFill { 37 | didSet { 38 | imageView.contentMode = .init(resizingMode: resizingMode) 39 | #if !targetEnvironment(macCatalyst) 40 | _animatedImageView?.contentMode = .init(resizingMode: resizingMode) 41 | #endif 42 | _videoPlayerView?.videoGravity = .init(resizingMode) 43 | } 44 | } 45 | #endif 46 | 47 | #if (os(iOS) || os(tvOS)) && !targetEnvironment(macCatalyst) 48 | /// Returns an underlying animated image view used for rendering animated images. 49 | public var animatedImageView: AnimatedImageView { 50 | if let view = _animatedImageView { 51 | return view 52 | } 53 | let view = makeAnimatedImageView() 54 | addContentView(view) 55 | _animatedImageView = view 56 | return view 57 | } 58 | 59 | private func makeAnimatedImageView() -> AnimatedImageView { 60 | let view = AnimatedImageView() 61 | view.contentMode = .init(resizingMode: resizingMode) 62 | return view 63 | } 64 | 65 | private var _animatedImageView: AnimatedImageView? 66 | #endif 67 | 68 | /// Returns an underlying video player view. 69 | public var videoPlayerView: VideoPlayerView { 70 | if let view = _videoPlayerView { 71 | return view 72 | } 73 | let view = makeVideoPlayerView() 74 | addContentView(view) 75 | _videoPlayerView = view 76 | return view 77 | } 78 | 79 | private func makeVideoPlayerView() -> VideoPlayerView { 80 | let view = VideoPlayerView() 81 | #if os(macOS) 82 | view.videoGravity = .resizeAspect 83 | #else 84 | view.videoGravity = .init(resizingMode) 85 | #endif 86 | return view 87 | } 88 | 89 | private var _videoPlayerView: VideoPlayerView? 90 | 91 | public var customContentView: _PlatformBaseView? { 92 | get { _customContentView } 93 | set { 94 | _customContentView?.removeFromSuperview() 95 | _customContentView = newValue 96 | if let customView = _customContentView { 97 | addContentView(customView) 98 | customView.isHidden = false 99 | } 100 | } 101 | } 102 | 103 | private var _customContentView: _PlatformBaseView? 104 | 105 | /// `true` by default. If disabled, animated image rendering will be disabled. 106 | public var isAnimatedImageRenderingEnabled = true 107 | 108 | /// `true` by default. Set to `true` to enable video support. 109 | public var isVideoRenderingEnabled = true 110 | 111 | 112 | // MARK: Initializers 113 | 114 | public override init(frame: CGRect) { 115 | super.init(frame: frame) 116 | didInit() 117 | } 118 | 119 | public required init?(coder: NSCoder) { 120 | super.init(coder: coder) 121 | didInit() 122 | } 123 | 124 | private func didInit() { 125 | addContentView(imageView) 126 | 127 | #if !os(macOS) 128 | clipsToBounds = true 129 | imageView.contentMode = .scaleAspectFill 130 | #else 131 | imageView.translatesAutoresizingMaskIntoConstraints = false 132 | imageView.setContentCompressionResistancePriority(.defaultLow, for: .vertical) 133 | imageView.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) 134 | imageView.animates = true // macOS supports animated images out of the box 135 | #endif 136 | } 137 | 138 | /// Displays the given image. 139 | /// 140 | /// Supports platform images (`UIImage`) and `ImageContainer`. Use `ImageContainer` 141 | /// if you need to pass additional parameters alongside the image, like 142 | /// original image data for GIF rendering. 143 | public var imageContainer: ImageContainer? { 144 | get { _imageContainer } 145 | set { 146 | _imageContainer = newValue 147 | if let imageContainer = newValue { 148 | display(imageContainer) 149 | } else { 150 | reset() 151 | } 152 | } 153 | } 154 | var _imageContainer: ImageContainer? 155 | 156 | public var isVideoLooping: Bool { 157 | set { 158 | videoPlayerView.isLooping = newValue 159 | } 160 | get { 161 | videoPlayerView.isLooping 162 | } 163 | } 164 | var onVideoFinished: (() -> Void)? 165 | 166 | func restartVideo() { 167 | videoPlayerView.restart() 168 | } 169 | 170 | #if os(macOS) 171 | public var image: NSImage? { 172 | get { imageContainer?.image } 173 | set { imageContainer = newValue.map { ImageContainer(image: $0) } } 174 | } 175 | #else 176 | public var image: UIImage? { 177 | get { imageContainer?.image } 178 | set { imageContainer = newValue.map { ImageContainer(image: $0) } } 179 | } 180 | #endif 181 | 182 | private func display(_ container: ImageContainer) { 183 | if let customView = makeCustomContentView(for: container) { 184 | customContentView = customView 185 | return 186 | } 187 | #if (os(iOS) || os(tvOS)) && !targetEnvironment(macCatalyst) 188 | if isAnimatedImageRenderingEnabled, let data = container.data, container.type == .gif { 189 | animatedImageView.animate(withGIFData: data) 190 | animatedImageView.isHidden = false 191 | return 192 | } 193 | #endif 194 | if isVideoRenderingEnabled, let asset = container.asset { 195 | videoPlayerView.isHidden = false 196 | videoPlayerView.asset = asset 197 | videoPlayerView.onVideoFinished = onVideoFinished 198 | videoPlayerView.play() 199 | } else { 200 | imageView.image = container.image 201 | imageView.isHidden = false 202 | } 203 | } 204 | 205 | private func makeCustomContentView(for container: ImageContainer) -> _PlatformBaseView? { 206 | for closure in ImageView.registersContentViews { 207 | if let view = closure(container) { 208 | return view 209 | } 210 | } 211 | return nil 212 | } 213 | 214 | /// Cancels current request and prepares the view for reuse. 215 | func reset() { 216 | _imageContainer = nil 217 | 218 | imageView.isHidden = true 219 | imageView.image = nil 220 | 221 | #if (os(iOS) || os(tvOS)) && !targetEnvironment(macCatalyst) 222 | _animatedImageView?.isHidden = true 223 | _animatedImageView?.image = nil 224 | #endif 225 | 226 | _videoPlayerView?.isHidden = true 227 | _videoPlayerView?.reset() 228 | 229 | _customContentView?.removeFromSuperview() 230 | _customContentView = nil 231 | } 232 | 233 | // MARK: Extending Rendering System 234 | 235 | #if os(iOS) || os(tvOS) 236 | /// Registers a custom content view to be used for displaying the given image. 237 | /// 238 | /// - parameter closure: A closure to get called when the image needs to be 239 | /// displayed. The view gets added to the `contentView`. You can return `nil` 240 | /// if you want the default rendering to happen. 241 | public static func registerContentView(_ closure: @escaping (ImageContainer) -> UIView?) { 242 | registersContentViews.append(closure) 243 | } 244 | #else 245 | /// Registers a custom content view to be used for displaying the given image. 246 | /// 247 | /// - parameter closure: A closure to get called when the image needs to be 248 | /// displayed. The view gets added to the `contentView`. You can return `nil` 249 | /// if you want the default rendering to happen. 250 | public static func registerContentView(_ closure: @escaping (ImageContainer) -> NSView?) { 251 | registersContentViews.append(closure) 252 | } 253 | #endif 254 | 255 | public static func removeAllRegisteredContentViews() { 256 | registersContentViews.removeAll() 257 | } 258 | 259 | private static var registersContentViews: [(ImageContainer) -> _PlatformBaseView?] = [] 260 | 261 | // MARK: Misc 262 | 263 | private func addContentView(_ view: _PlatformBaseView) { 264 | addSubview(view) 265 | view.pinToSuperview() 266 | view.isHidden = true 267 | } 268 | } 269 | #endif 270 | -------------------------------------------------------------------------------- /Sources/Internal.swift: -------------------------------------------------------------------------------- 1 | // The MIT License (MIT) 2 | // 3 | // Copyright (c) 2015-2021 Alexander Grebenyuk (github.com/kean). 4 | 5 | import Foundation 6 | 7 | #if !os(watchOS) 8 | 9 | #if os(macOS) 10 | import AppKit 11 | #else 12 | import UIKit 13 | #endif 14 | 15 | import SwiftUI 16 | import Nuke 17 | 18 | #if os(macOS) 19 | public typealias _PlatformBaseView = NSView 20 | typealias _PlatformImage = NSImage 21 | typealias _PlatformImageView = NSImageView 22 | typealias _PlatformColor = NSColor 23 | #else 24 | public typealias _PlatformBaseView = UIView 25 | typealias _PlatformImage = UIImage 26 | typealias _PlatformImageView = UIImageView 27 | typealias _PlatformColor = UIColor 28 | #endif 29 | 30 | extension _PlatformBaseView { 31 | @discardableResult 32 | func pinToSuperview() -> [NSLayoutConstraint] { 33 | translatesAutoresizingMaskIntoConstraints = false 34 | let constraints = [ 35 | topAnchor.constraint(equalTo: superview!.topAnchor), 36 | bottomAnchor.constraint(equalTo: superview!.bottomAnchor), 37 | leftAnchor.constraint(equalTo: superview!.leftAnchor), 38 | rightAnchor.constraint(equalTo: superview!.rightAnchor) 39 | ] 40 | NSLayoutConstraint.activate(constraints) 41 | return constraints 42 | } 43 | 44 | @discardableResult 45 | func centerInSuperview() -> [NSLayoutConstraint] { 46 | translatesAutoresizingMaskIntoConstraints = false 47 | let constraints = [ 48 | centerXAnchor.constraint(equalTo: superview!.centerXAnchor), 49 | centerYAnchor.constraint(equalTo: superview!.centerYAnchor) 50 | ] 51 | NSLayoutConstraint.activate(constraints) 52 | return constraints 53 | } 54 | 55 | @discardableResult 56 | func layout(with position: LazyImageView.SubviewPosition) -> [NSLayoutConstraint] { 57 | switch position { 58 | case .center: return centerInSuperview() 59 | case .fill: return pinToSuperview() 60 | } 61 | } 62 | } 63 | 64 | extension CALayer { 65 | func animateOpacity(duration: CFTimeInterval) { 66 | let animation = CABasicAnimation(keyPath: "opacity") 67 | animation.duration = duration 68 | animation.fromValue = 0 69 | animation.toValue = 1 70 | add(animation, forKey: "imageTransition") 71 | } 72 | } 73 | 74 | #if os(macOS) 75 | extension NSView { 76 | func setNeedsUpdateConstraints() { 77 | needsUpdateConstraints = true 78 | } 79 | 80 | func insertSubview(_ subivew: NSView, at index: Int) { 81 | addSubview(subivew, positioned: .below, relativeTo: subviews.first) 82 | } 83 | } 84 | 85 | extension NSColor { 86 | static var secondarySystemBackground: NSColor { 87 | .controlBackgroundColor // Close-enough, but we should define a custom color 88 | } 89 | } 90 | #endif 91 | 92 | #if os(iOS) || os(tvOS) 93 | extension UIView.ContentMode { 94 | init(resizingMode: ImageResizingMode) { 95 | switch resizingMode { 96 | case .aspectFill: self = .scaleAspectFill 97 | case .aspectFit: self = .scaleAspectFit 98 | case .center: self = .center 99 | case .fill: self = .scaleToFill 100 | } 101 | } 102 | } 103 | #endif 104 | 105 | #endif 106 | 107 | #if os(tvOS) || os(watchOS) 108 | import UIKit 109 | 110 | extension UIColor { 111 | @objc(secondarySystemBackgroundColor) 112 | public static var secondarySystemBackground: UIColor { 113 | return lightGray.withAlphaComponent(0.5) 114 | } 115 | } 116 | #endif 117 | -------------------------------------------------------------------------------- /Sources/LazyImage.swift: -------------------------------------------------------------------------------- 1 | // The MIT License (MIT) 2 | // 3 | // Copyright (c) 2015-2021 Alexander Grebenyuk (github.com/kean). 4 | 5 | import Foundation 6 | import Nuke 7 | import SwiftUI 8 | import Combine 9 | 10 | public typealias ImageRequest = Nuke.ImageRequest 11 | public typealias ImagePipeline = Nuke.ImagePipeline 12 | public typealias ImageContainer = Nuke.ImageContainer 13 | 14 | private struct HashableRequest: Hashable { 15 | let request: ImageRequest 16 | 17 | func hash(into hasher: inout Hasher) { 18 | hasher.combine(request.imageId) 19 | hasher.combine(request.options) 20 | hasher.combine(request.priority) 21 | } 22 | 23 | static func == (lhs: HashableRequest, rhs: HashableRequest) -> Bool { 24 | let lhs = lhs.request 25 | let rhs = rhs.request 26 | return lhs.imageId == rhs.imageId && 27 | lhs.priority == rhs.priority && 28 | lhs.options == rhs.options 29 | } 30 | 31 | } 32 | 33 | /// Lazily loads and displays images. 34 | /// 35 | /// The image view is lazy and doesn't know the size of the image before it is 36 | /// downloaded. You must specify the size for the view before loading the image. 37 | /// By default, the image will resize to fill the available space but preserve 38 | /// the aspect ratio. You can change this behavior by passing a different content mode. 39 | @available(iOS 14.0, tvOS 14.0, watchOS 7.0, macOS 10.16, *) 40 | public struct LazyImage: View { 41 | @StateObject private var model = FetchImage() 42 | 43 | private let request: HashableRequest? 44 | 45 | #if !os(watchOS) 46 | private var onCreated: ((ImageView) -> Void)? 47 | #endif 48 | 49 | // Options 50 | private var makeContent: ((LazyImageState) -> Content)? 51 | private var animation: Animation? = .default 52 | private var processors: [ImageProcessing]? 53 | private var priority: ImageRequest.Priority? 54 | private var pipeline: ImagePipeline = .shared 55 | private var onDisappearBehavior: DisappearBehavior? = .cancel 56 | private var onStart: ((_ task: ImageTask) -> Void)? 57 | private var onProgress: ((_ response: ImageResponse?, _ completed: Int64, _ total: Int64) -> Void)? 58 | private var onSuccess: ((_ response: ImageResponse) -> Void)? 59 | private var onFailure: ((_ response: Error) -> Void)? 60 | private var onCompletion: ((_ result: Result) -> Void)? 61 | private var resizingMode: ImageResizingMode? 62 | 63 | // MARK: Initializers 64 | 65 | #if !os(macOS) 66 | /// Loads and displays an image from the given URL when the view appears on screen. 67 | /// 68 | /// - Parameters: 69 | /// - source: The image source (`String`, `URL`, `URLRequest`, or `ImageRequest`) 70 | /// - resizingMode: `.aspectFill` by default. 71 | public init(source: ImageRequestConvertible?, resizingMode: ImageResizingMode = .aspectFill) where Content == Image { 72 | self.request = source.map { HashableRequest(request: $0.asImageRequest()) } 73 | self.resizingMode = resizingMode 74 | } 75 | #else 76 | /// Loads and displays an image from the given URL when the view appears on screen. 77 | /// 78 | /// - Parameters: 79 | /// - source: The image source (`String`, `URL`, `URLRequest`, or `ImageRequest`) 80 | public init(source: ImageRequestConvertible?) where Content == Image { 81 | self.request = source.map { HashableRequest(request: $0.asImageRequest()) } 82 | } 83 | #endif 84 | 85 | /// Loads and displays an image from the given URL when the view appears on screen. 86 | /// 87 | /// - Parameters: 88 | /// - source: The image source (`String`, `URL`, `URLRequest`, or `ImageRequest`) 89 | /// - content: The view to show for each of the image loading states. 90 | /// 91 | /// ```swift 92 | /// LazyImage(source: $0) { state in 93 | /// if let image = state.image { 94 | /// image // Displays the loaded image. 95 | /// } else if state.error != nil { 96 | /// Color.red // Indicates an error. 97 | /// } else { 98 | /// Color.blue // Acts as a placeholder. 99 | /// } 100 | /// } 101 | /// ``` 102 | public init(source: ImageRequestConvertible?, @ViewBuilder content: @escaping (LazyImageState) -> Content) { 103 | self.request = source.map { HashableRequest(request: $0.asImageRequest()) } 104 | self.makeContent = content 105 | } 106 | 107 | // MARK: Animation 108 | 109 | /// Animations to be used when displaying the loaded images. By default, `.default`. 110 | /// 111 | /// - note: Animation isn't used when image is available in memory cache. 112 | public func animation(_ animation: Animation?) -> Self { 113 | map { $0.animation = animation } 114 | } 115 | 116 | // MARK: Managing Image Tasks 117 | 118 | /// Sets processors to be applied to the image. 119 | /// 120 | /// If you pass an image requests with a non-empty list of processors as 121 | /// a source, your processors will be applied instead. 122 | public func processors(_ processors: [ImageProcessing]?) -> Self { 123 | map { $0.processors = processors } 124 | } 125 | 126 | /// Sets the priority of the requests. 127 | public func priority(_ priority: ImageRequest.Priority?) -> Self { 128 | map { $0.priority = priority } 129 | } 130 | 131 | /// Changes the underlying pipeline used for image loading. 132 | public func pipeline(_ pipeline: ImagePipeline) -> Self { 133 | map { $0.pipeline = pipeline } 134 | } 135 | 136 | public enum DisappearBehavior { 137 | @available(*, deprecated, message: "Please use cancel instead.") 138 | case reset 139 | /// Cancels the current request but keeps the presentation state of 140 | /// the already displayed image. 141 | case cancel 142 | /// Lowers the request's priority to very low 143 | case lowerPriority 144 | } 145 | 146 | /// Override the behavior on disappear. By default, the view is reset. 147 | public func onDisappear(_ behavior: DisappearBehavior?) -> Self { 148 | map { $0.onDisappearBehavior = behavior } 149 | } 150 | 151 | // MARK: Callbacks 152 | 153 | /// Gets called when the request is started. 154 | public func onStart(_ closure: @escaping (_ task: ImageTask) -> Void) -> Self { 155 | map { $0.onStart = closure } 156 | } 157 | 158 | /// Gets called when the request progress is updated. 159 | public func onProgress(_ closure: @escaping (_ response: ImageResponse?, _ completed: Int64, _ total: Int64) -> Void) -> Self { 160 | map { $0.onProgress = closure } 161 | } 162 | 163 | /// Gets called when the requests finished successfully. 164 | public func onSuccess(_ closure: @escaping (_ response: ImageResponse) -> Void) -> Self { 165 | map { $0.onSuccess = closure } 166 | } 167 | 168 | /// Gets called when the requests fails. 169 | public func onFailure(_ closure: @escaping (_ response: Error) -> Void) -> Self { 170 | map { $0.onFailure = closure } 171 | } 172 | 173 | /// Gets called when the request is completed. 174 | public func onCompletion(_ closure: @escaping (_ result: Result) -> Void) -> Self { 175 | map { $0.onCompletion = closure } 176 | } 177 | 178 | #if !os(watchOS) 179 | 180 | /// Returns an underlying image view. 181 | /// 182 | /// - parameter configure: A closure that gets called once when the view is 183 | /// created and allows you to configure it based on your needs. 184 | public func onCreated(_ configure: ((ImageView) -> Void)?) -> Self { 185 | map { $0.onCreated = configure } 186 | } 187 | #endif 188 | 189 | // MARK: Body 190 | 191 | public var body: some View { 192 | // Using ZStack to add an identity to the view to prevent onAppear from 193 | // getting called whenever the content changes. 194 | ZStack { 195 | content 196 | } 197 | .onAppear(perform: onAppear) 198 | .onDisappear(perform: onDisappear) 199 | .onChange(of: request, perform: load) 200 | } 201 | 202 | @ViewBuilder private var content: some View { 203 | if let makeContent = makeContent { 204 | makeContent(LazyImageState(model)) 205 | } else { 206 | makeDefaultContent() 207 | } 208 | } 209 | 210 | @ViewBuilder private func makeDefaultContent() -> some View { 211 | if let imageContainer = model.imageContainer { 212 | #if os(watchOS) 213 | switch resizingMode ?? ImageResizingMode.aspectFill { 214 | case .center: model.view 215 | case .aspectFit, .aspectFill: 216 | model.view? 217 | .resizable() 218 | .aspectRatio(contentMode: resizingMode == .aspectFit ? .fit : .fill) 219 | case .fill: 220 | model.view? 221 | .resizable() 222 | } 223 | #else 224 | Image(imageContainer) { 225 | #if os(iOS) || os(tvOS) 226 | if let resizingMode = self.resizingMode { 227 | $0.resizingMode = resizingMode 228 | } 229 | #endif 230 | onCreated?($0) 231 | } 232 | #endif 233 | } else { 234 | Rectangle().foregroundColor(Color(.secondarySystemBackground)) 235 | } 236 | } 237 | 238 | private func onAppear() { 239 | // Unfortunately, you can't modify @State directly in the properties 240 | // that set these options. 241 | model.animation = animation 242 | if let processors = processors { model.processors = processors } 243 | if let priority = priority { model.priority = priority } 244 | model.pipeline = pipeline 245 | model.onStart = onStart 246 | model.onProgress = onProgress 247 | model.onSuccess = onSuccess 248 | model.onFailure = onFailure 249 | model.onCompletion = onCompletion 250 | 251 | load(request) 252 | } 253 | 254 | private func load(_ request: HashableRequest?) { 255 | model.load(request?.request) 256 | } 257 | 258 | private func onDisappear() { 259 | guard let behavior = onDisappearBehavior else { return } 260 | switch behavior { 261 | case .reset: model.reset() 262 | case .cancel: model.cancel() 263 | case .lowerPriority: model.priority = .veryLow 264 | } 265 | } 266 | 267 | private func map(_ closure: (inout LazyImage) -> Void) -> Self { 268 | var copy = self 269 | closure(©) 270 | return copy 271 | } 272 | } 273 | 274 | @available(iOS 13.0, tvOS 13.0, watchOS 7.0, macOS 10.15, *) 275 | public struct LazyImageState { 276 | /// Returns the current fetch result. 277 | public let result: Result? 278 | 279 | /// Returns a current error. 280 | public var error: Error? { 281 | if case .failure(let error) = result { 282 | return error 283 | } 284 | return nil 285 | } 286 | 287 | /// Returns an image view. 288 | public var image: Image? { 289 | #if os(macOS) 290 | return imageContainer.map { Image($0) } 291 | #elseif os(watchOS) 292 | return imageContainer.map { Image(uiImage: $0.image) } 293 | #else 294 | return imageContainer.map { Image($0) } 295 | #endif 296 | } 297 | 298 | /// Returns the fetched image. 299 | /// 300 | /// - note: In case pipeline has `isProgressiveDecodingEnabled` option enabled 301 | /// and the image being downloaded supports progressive decoding, the `image` 302 | /// might be updated multiple times during the download. 303 | public let imageContainer: ImageContainer? 304 | 305 | /// Returns `true` if the image is being loaded. 306 | public let isLoading: Bool 307 | 308 | /// The download progress. 309 | public struct Progress: Equatable { 310 | /// The number of bytes that the task has received. 311 | public let completed: Int64 312 | 313 | /// A best-guess upper bound on the number of bytes the client expects to send. 314 | public let total: Int64 315 | } 316 | 317 | /// The progress of the image download. 318 | public let progress: Progress 319 | 320 | init(_ fetchImage: FetchImage) { 321 | self.result = fetchImage.result 322 | self.imageContainer = fetchImage.imageContainer 323 | self.isLoading = fetchImage.isLoading 324 | self.progress = Progress(completed: fetchImage.progress.completed, total: fetchImage.progress.total) 325 | } 326 | } 327 | 328 | public enum ImageResizingMode { 329 | case aspectFit 330 | case aspectFill 331 | case center 332 | case fill 333 | } 334 | -------------------------------------------------------------------------------- /Sources/LazyImageView.swift: -------------------------------------------------------------------------------- 1 | // The MIT License (MIT) 2 | // 3 | // Copyright (c) 2015-2021 Alexander Grebenyuk (github.com/kean). 4 | 5 | import Foundation 6 | import Nuke 7 | 8 | #if !os(watchOS) 9 | 10 | #if os(macOS) 11 | import AppKit 12 | #else 13 | import UIKit 14 | #endif 15 | 16 | /// Lazily loads and displays images. 17 | public final class LazyImageView: _PlatformBaseView { 18 | 19 | // MARK: Placeholder View 20 | 21 | #if os(macOS) 22 | /// An image to be shown while the request is in progress. 23 | public var placeholderImage: NSImage? { 24 | didSet { setPlaceholderImage(placeholderImage) } 25 | } 26 | 27 | /// A view to be shown while the request is in progress. For example, 28 | /// a spinner. 29 | public var placeholderView: NSView? { 30 | didSet { setPlaceholderView(oldValue, placeholderView) } 31 | } 32 | #else 33 | /// An image to be shown while the request is in progress. 34 | public var placeholderImage: UIImage? { 35 | didSet { setPlaceholderImage(placeholderImage) } 36 | } 37 | 38 | /// A view to be shown while the request is in progress. For example, 39 | /// a spinner. 40 | public var placeholderView: UIView? { 41 | didSet { setPlaceholderView(oldValue, placeholderView) } 42 | } 43 | #endif 44 | 45 | /// The position of the placeholder. `.fill` by default. 46 | /// 47 | /// It also affects `placeholderImage` because it gets converted to a view. 48 | public var placeholderViewPosition: SubviewPosition = .fill { 49 | didSet { 50 | guard oldValue != placeholderViewPosition, 51 | placeholderView != nil else { return } 52 | setNeedsUpdateConstraints() 53 | } 54 | } 55 | 56 | private var placeholderViewConstraints: [NSLayoutConstraint] = [] 57 | 58 | // MARK: Failure View 59 | 60 | #if os(macOS) 61 | /// An image to be shown if the request fails. 62 | public var failureImage: NSImage? { 63 | didSet { setFailureImage(failureImage) } 64 | } 65 | 66 | /// A view to be shown if the request fails. 67 | public var failureView: NSView? { 68 | didSet { setFailureView(oldValue, failureView) } 69 | } 70 | #else 71 | /// An image to be shown if the request fails. 72 | public var failureImage: UIImage? { 73 | didSet { setFailureImage(failureImage) } 74 | } 75 | 76 | /// A view to be shown if the request fails. 77 | public var failureView: UIView? { 78 | didSet { setFailureView(oldValue, failureView) } 79 | } 80 | #endif 81 | 82 | /// The position of the failure vuew. `.fill` by default. 83 | /// 84 | /// It also affects `failureImage` because it gets converted to a view. 85 | public var failureViewPosition: SubviewPosition = .fill { 86 | didSet { 87 | guard oldValue != failureViewPosition, 88 | failureView != nil else { return } 89 | setNeedsUpdateConstraints() 90 | } 91 | } 92 | 93 | private var failureViewConstraints: [NSLayoutConstraint] = [] 94 | 95 | // MARK: Transition 96 | 97 | /// A animated transition to be performed when displaying a loaded image 98 | /// By default, `.fadeIn(duration: 0.33)`. 99 | public var transition: Transition? 100 | 101 | /// An animated transition. 102 | public enum Transition { 103 | /// Fade-in transition. 104 | case fadeIn(duration: TimeInterval) 105 | /// A custom image view transition. 106 | /// 107 | /// The closure will get called after the image is already displayed but 108 | /// before `imageContainer` value is updated. 109 | case custom(closure: (LazyImageView, ImageContainer) -> Void) 110 | } 111 | 112 | // MARK: Underlying Views 113 | 114 | /// Returns the underlying image view. 115 | public let imageView = ImageView() 116 | 117 | // MARK: Managing Image Tasks 118 | 119 | /// Processors to be applied to the image. `nil` by default. 120 | /// 121 | /// If you pass an image requests with a non-empty list of processors as 122 | /// a source, your processors will be applied instead. 123 | public var processors: [ImageProcessing]? 124 | 125 | /// Sets the priority of the image task. The priorit can be changed 126 | /// dynamically. `nil` by default. 127 | public var priority: ImageRequest.Priority? { 128 | didSet { 129 | if let priority = self.priority { 130 | imageTask?.priority = priority 131 | } 132 | } 133 | } 134 | 135 | /// Current image task. 136 | public var imageTask: ImageTask? 137 | 138 | /// The pipeline to be used for download. `shared` by default. 139 | public var pipeline: ImagePipeline = .shared 140 | 141 | // MARK: Callbacks 142 | 143 | /// Gets called when the request is started. 144 | public var onStart: ((_ task: ImageTask) -> Void)? 145 | 146 | /// Gets called when the request progress is updated. 147 | public var onProgress: ((_ response: ImageResponse?, _ completed: Int64, _ total: Int64) -> Void)? 148 | 149 | /// Gets called when the requests finished successfully. 150 | public var onSuccess: ((_ response: ImageResponse) -> Void)? 151 | 152 | /// Gets called when the requests fails. 153 | public var onFailure: ((_ response: Error) -> Void)? 154 | 155 | /// Gets called when the request is completed. 156 | public var onCompletion: ((_ result: Result) -> Void)? 157 | 158 | // MARK: Other Options 159 | 160 | /// `true` by default. If disabled, progressive image scans will be ignored. 161 | /// 162 | /// This option also affects the previews for animated images or videos. 163 | public var isProgressiveImageRenderingEnabled = true 164 | 165 | /// `true` by default. If enabled, the image view will be cleared before the 166 | /// new download is started. You can disable it if you want to keep the 167 | /// previous content while the new download is in progress. 168 | public var isResetEnabled = true 169 | 170 | // MARK: Private 171 | 172 | private var isResetNeeded = false 173 | private var isDisplayingContent = false 174 | 175 | // MARK: Initializers 176 | 177 | deinit { 178 | cancel() 179 | } 180 | 181 | public override init(frame: CGRect) { 182 | super.init(frame: frame) 183 | didInit() 184 | } 185 | 186 | public required init?(coder: NSCoder) { 187 | super.init(coder: coder) 188 | didInit() 189 | } 190 | 191 | private func didInit() { 192 | addSubview(imageView) 193 | imageView.pinToSuperview() 194 | 195 | placeholderView = { 196 | let view = _PlatformBaseView() 197 | let color: _PlatformColor 198 | if #available(iOS 13.0, *) { 199 | color = .secondarySystemBackground 200 | } else { 201 | color = _PlatformColor.lightGray.withAlphaComponent(0.5) 202 | } 203 | #if os(macOS) 204 | view.wantsLayer = true 205 | view.layer?.backgroundColor = color.cgColor 206 | #else 207 | view.backgroundColor = color 208 | #endif 209 | 210 | return view 211 | }() 212 | 213 | transition = .fadeIn(duration: 0.33) 214 | } 215 | 216 | /// Sets the given source and immediately starts the download. 217 | public var source: ImageRequestConvertible? { 218 | didSet { load(source) } 219 | } 220 | 221 | public override func updateConstraints() { 222 | super.updateConstraints() 223 | 224 | updatePlaceholderViewConstraints() 225 | updateFailureViewConstraints() 226 | } 227 | 228 | /// Cancels current request and prepares the view for reuse. 229 | public func reset() { 230 | cancel() 231 | 232 | imageView.imageContainer = nil 233 | imageView.isHidden = true 234 | 235 | setPlaceholderViewHidden(true) 236 | setFailureViewHidden(true) 237 | 238 | isDisplayingContent = false 239 | isResetNeeded = false 240 | } 241 | 242 | /// Cancels current request. 243 | public func cancel() { 244 | imageTask?.cancel() 245 | imageTask = nil 246 | } 247 | 248 | // MARK: Load (ImageRequestConvertible) 249 | 250 | /// Loads an image with the given request. 251 | private func load(_ request: ImageRequestConvertible?) { 252 | assert(Thread.isMainThread, "Must be called from the main thread") 253 | 254 | cancel() 255 | 256 | if isResetEnabled { 257 | reset() 258 | } else { 259 | isResetNeeded = true 260 | } 261 | 262 | guard var request = request?.asImageRequest() else { 263 | handle(result: .failure(FetchImageError.sourceEmpty), isSync: true) 264 | return 265 | } 266 | 267 | if let processors = self.processors, !processors.isEmpty, !request.processors.isEmpty { 268 | request.processors = processors 269 | } 270 | if let priority = self.priority { 271 | request.priority = priority 272 | } 273 | 274 | // Quick synchronous memory cache lookup 275 | if let image = pipeline.cache[request] { 276 | if image.isPreview { 277 | display(image, isFromMemory: true) // Display progressive preview 278 | } else { 279 | let response = ImageResponse(container: image, cacheType: .memory) 280 | handle(result: .success(response), isSync: true) 281 | return 282 | } 283 | } 284 | 285 | setPlaceholderViewHidden(false) 286 | 287 | let task = pipeline.loadImage( 288 | with: request, 289 | queue: .main, 290 | progress: { [weak self] response, completedCount, totalCount in 291 | guard let self = self else { return } 292 | if let response = response { 293 | self.handle(preview: response) 294 | } 295 | self.onProgress?(response, completedCount, totalCount) 296 | }, 297 | completion: { [weak self] result in 298 | self?.handle(result: result.mapError { $0 }, isSync: false) 299 | } 300 | ) 301 | imageTask = task 302 | onStart?(task) 303 | } 304 | 305 | private func handle(preview: ImageResponse) { 306 | guard isProgressiveImageRenderingEnabled else { 307 | return 308 | } 309 | setPlaceholderViewHidden(true) 310 | display(preview.container, isFromMemory: false) 311 | } 312 | 313 | private func handle(result: Result, isSync: Bool) { 314 | resetIfNeeded() 315 | setPlaceholderViewHidden(true) 316 | 317 | switch result { 318 | case let .success(response): 319 | display(response.container, isFromMemory: isSync) 320 | case .failure: 321 | setFailureViewHidden(false) 322 | } 323 | 324 | imageTask = nil 325 | switch result { 326 | case .success(let response): onSuccess?(response) 327 | case .failure(let error): onFailure?(error) 328 | } 329 | onCompletion?(result) 330 | } 331 | 332 | private func display(_ container: ImageContainer, isFromMemory: Bool) { 333 | resetIfNeeded() 334 | 335 | imageView.imageContainer = container 336 | imageView.isHidden = false 337 | 338 | if !isFromMemory, let transition = transition { 339 | runTransition(transition, container) 340 | } 341 | 342 | // It's used to determine when to perform certain transitions 343 | isDisplayingContent = true 344 | } 345 | 346 | // MARK: Private (Placeholder View) 347 | 348 | private func setPlaceholderViewHidden(_ isHidden: Bool) { 349 | placeholderView?.isHidden = isHidden 350 | } 351 | 352 | private func setPlaceholderImage(_ placeholderImage: _PlatformImage?) { 353 | guard let placeholderImage = placeholderImage else { 354 | placeholderView = nil 355 | return 356 | } 357 | placeholderView = _PlatformImageView(image: placeholderImage) 358 | } 359 | 360 | private func setPlaceholderView(_ oldView: _PlatformBaseView?, _ newView: _PlatformBaseView?) { 361 | if let oldView = oldView { 362 | oldView.removeFromSuperview() 363 | } 364 | if let newView = newView { 365 | newView.isHidden = true 366 | insertSubview(newView, at: 0) 367 | setNeedsUpdateConstraints() 368 | #if os(iOS) || os(tvOS) 369 | if let spinner = newView as? UIActivityIndicatorView { 370 | spinner.startAnimating() 371 | } 372 | #endif 373 | } 374 | } 375 | 376 | private func updatePlaceholderViewConstraints() { 377 | NSLayoutConstraint.deactivate(placeholderViewConstraints) 378 | placeholderViewConstraints = placeholderView?.layout(with: placeholderViewPosition) ?? [] 379 | } 380 | 381 | // MARK: Private (Failure View) 382 | 383 | private func setFailureViewHidden(_ isHidden: Bool) { 384 | failureView?.isHidden = isHidden 385 | } 386 | 387 | private func setFailureImage(_ failureImage: _PlatformImage?) { 388 | guard let failureImage = failureImage else { 389 | failureView = nil 390 | return 391 | } 392 | failureView = _PlatformImageView(image: failureImage) 393 | } 394 | 395 | private func setFailureView(_ oldView: _PlatformBaseView?, _ newView: _PlatformBaseView?) { 396 | if let oldView = oldView { 397 | oldView.removeFromSuperview() 398 | } 399 | if let newView = newView { 400 | newView.isHidden = true 401 | insertSubview(newView, at: 0) 402 | setNeedsUpdateConstraints() 403 | } 404 | } 405 | 406 | private func updateFailureViewConstraints() { 407 | NSLayoutConstraint.deactivate(failureViewConstraints) 408 | failureViewConstraints = failureView?.layout(with: failureViewPosition) ?? [] 409 | } 410 | 411 | // MARK: Private (Transitions) 412 | 413 | private func runTransition(_ transition: Transition, _ image: ImageContainer) { 414 | switch transition { 415 | case .fadeIn(let duration): 416 | runFadeInTransition(duration: duration) 417 | case .custom(let closure): 418 | closure(self, image) 419 | } 420 | } 421 | 422 | #if os(iOS) || os(tvOS) 423 | 424 | private func runFadeInTransition(duration: TimeInterval) { 425 | guard !isDisplayingContent else { return } 426 | imageView.alpha = 0 427 | UIView.animate(withDuration: duration, delay: 0, options: [.allowUserInteraction]) { 428 | self.imageView.alpha = 1 429 | } 430 | } 431 | 432 | #elseif os(macOS) 433 | 434 | private func runFadeInTransition(duration: TimeInterval) { 435 | guard !isDisplayingContent else { return } 436 | imageView.layer?.animateOpacity(duration: duration) 437 | } 438 | 439 | #endif 440 | 441 | // MARK: Misc 442 | 443 | public enum SubviewPosition { 444 | /// Center in the superview. 445 | case center 446 | 447 | /// Fill the superview. 448 | case fill 449 | } 450 | 451 | private func resetIfNeeded() { 452 | if isResetNeeded { 453 | reset() 454 | isResetNeeded = false 455 | } 456 | } 457 | } 458 | 459 | #endif 460 | -------------------------------------------------------------------------------- /Sources/VideoPlayerView.swift: -------------------------------------------------------------------------------- 1 | // The MIT License (MIT) 2 | // 3 | // Copyright (c) 2015-2021 Alexander Grebenyuk (github.com/kean). 4 | 5 | import AVKit 6 | import Foundation 7 | 8 | #if !os(watchOS) 9 | 10 | public final class VideoPlayerView: _PlatformBaseView { 11 | // MARK: Configuration 12 | 13 | /// `.resizeAspectFill` by default. 14 | public var videoGravity: AVLayerVideoGravity { 15 | get { playerLayer.videoGravity } 16 | set { playerLayer.videoGravity = newValue } 17 | } 18 | 19 | /// `true` by default. If disabled, will only play a video once. 20 | public var isLooping = true { 21 | didSet { 22 | player?.actionAtItemEnd = isLooping ? .none : .pause 23 | if isLooping, !(player?.nowPlaying ?? false) { 24 | restart() 25 | } 26 | } 27 | } 28 | 29 | /// Add if you want to do something at the end of the video 30 | var onVideoFinished: (() -> Void)? 31 | 32 | // MARK: Initialization 33 | #if !os(macOS) 34 | public override class var layerClass: AnyClass { 35 | AVPlayerLayer.self 36 | } 37 | 38 | public var playerLayer: AVPlayerLayer { 39 | (layer as? AVPlayerLayer) ?? AVPlayerLayer() // The right side should never happen 40 | } 41 | #else 42 | public let playerLayer = AVPlayerLayer() 43 | 44 | public override init(frame frameRect: NSRect) { 45 | super.init(frame: frameRect) 46 | 47 | // Creating a view backed by a custom layer on macOS is ... hard 48 | wantsLayer = true 49 | layer?.addSublayer(playerLayer) 50 | playerLayer.frame = bounds 51 | } 52 | 53 | public override func layout() { 54 | super.layout() 55 | 56 | playerLayer.frame = bounds 57 | } 58 | 59 | public required init?(coder: NSCoder) { 60 | fatalError("init(coder:) has not been implemented") 61 | } 62 | #endif 63 | 64 | // MARK: Private 65 | 66 | private var player: AVPlayer? { 67 | didSet { 68 | registerNotifications() 69 | } 70 | } 71 | 72 | private var playerObserver: AnyObject? 73 | 74 | public func reset() { 75 | playerLayer.player = nil 76 | player = nil 77 | playerObserver = nil 78 | } 79 | 80 | public var asset: AVAsset? { 81 | didSet { assetDidChange() } 82 | } 83 | 84 | private func assetDidChange() { 85 | if asset == nil { 86 | reset() 87 | } 88 | } 89 | 90 | private var shouldResumeOnInterruption:Bool{ 91 | return player?.nowPlaying == false && 92 | player?.status == .readyToPlay && 93 | isLooping 94 | } 95 | 96 | private func registerNotifications() { 97 | NotificationCenter.default 98 | .addObserver(self, 99 | selector: #selector(registerNotification(_:)), 100 | name: .AVPlayerItemDidPlayToEndTime, 101 | object: player?.currentItem) 102 | #if os(iOS) || os(tvOS) 103 | NotificationCenter.default 104 | .addObserver(self, 105 | selector: #selector(willEnterForeground), 106 | name: UIApplication.willEnterForegroundNotification, 107 | object: nil) 108 | #endif 109 | } 110 | 111 | @objc private func willEnterForeground(){ 112 | if shouldResumeOnInterruption { 113 | player?.play() 114 | } 115 | } 116 | 117 | #if !os(macOS) 118 | public override func willMove(toWindow newWindow: UIWindow?) { 119 | if newWindow != nil && shouldResumeOnInterruption { 120 | player?.play() 121 | } 122 | } 123 | #endif 124 | 125 | public func restart() { 126 | player?.seek(to: CMTime.zero) 127 | player?.play() 128 | } 129 | 130 | public func play() { 131 | guard let asset = asset else { 132 | return 133 | } 134 | 135 | let playerItem = AVPlayerItem(asset: asset) 136 | let player = AVQueuePlayer(playerItem: playerItem) 137 | player.isMuted = true 138 | player.preventsDisplaySleepDuringVideoPlayback = false 139 | player.actionAtItemEnd = isLooping ? .none : .pause 140 | self.player = player 141 | 142 | playerLayer.player = player 143 | 144 | playerObserver = player.observe(\.status, options: [.new, .initial]) { player, change in 145 | if player.status == .readyToPlay { 146 | player.play() 147 | } 148 | } 149 | } 150 | 151 | @objc private func registerNotification(_ notification: Notification) { 152 | guard let playerItem = notification.object as? AVPlayerItem else { 153 | return 154 | } 155 | 156 | if isLooping { 157 | playerItem.seek(to: CMTime.zero, completionHandler: nil) 158 | } else { 159 | onVideoFinished?() 160 | } 161 | } 162 | } 163 | 164 | extension AVLayerVideoGravity { 165 | init(_ contentMode: ImageResizingMode) { 166 | switch contentMode { 167 | case .aspectFit: self = .resizeAspect 168 | case .aspectFill: self = .resizeAspectFill 169 | case .center: self = .resizeAspect 170 | case .fill: self = .resize 171 | } 172 | } 173 | } 174 | 175 | extension AVPlayer { 176 | var nowPlaying: Bool { 177 | return rate != 0 && error == nil 178 | } 179 | } 180 | 181 | #endif 182 | --------------------------------------------------------------------------------