Skip to main content

Blink/Views/PHAssetImage.swift

1import SwiftUI
2import Photos
4/// This view gets an NSImage for a PHAsset.
5///
6/// When you get a photo from the Photos library, it may not be available
7/// immediately -- for example, if the image has to be downloaded from
8/// iCloud first. Downstream views can create an instance of this object,
9/// and then watch the `image` property -- this will be populated with the
10/// appropriate image as it loads.
11///
12/// You can use this class in two ways:
13///
14/// 1. Create a new instance for every PHAsset you want to render
15/// 2. Create a single instance and update the `asset` property; the `image`
16/// property will be updated shortly after
17///
18/// Note: PhotoKit may return multiple versions of an image, e.g. a low-res
19/// version immediately and a high-res version later. You can inspect the
20/// `isDegraded` property -- this will tell you if Photos has returned a
21/// low quality image now and expects to return a higher quality image later.
22class PHAssetImage: NSObject, ObservableObject {
24 @Published var image = NSImage()
25 @Published var isDegraded = false
27 init(_ asset: PHAsset?, size: CGSize, deliveryMode: PHImageRequestOptionsDeliveryMode) {
28 self.size = size
29 self.deliveryMode = deliveryMode
30 self.imageCache = Dictionary()
32 super.init()
34 self.asset = asset
35 }
37 private var _asset: PHAsset?
38 private var size: CGSize
39 private var deliveryMode: PHImageRequestOptionsDeliveryMode
41 // Often we'll be retrieving the same image repeatedly, as the user shuttles
42 // back and forth between a few images they're comparing. In this case, we
43 // don't want to go back to Photos every time -- so we keep a cache of images
44 // we've retrieved previously.
45 //
46 // In theory the `PHCachingImageManager` does this for us; in practice the app
47 // feels snappier to me with this additional cache.
48 //
49 // TODO: Replace this Dictionary with an LRU cache of some sort; this could
50 // allow the app's memory usage to balloon indefinitely.
51 private var imageCache: Dictionary<PHAsset, NSImage>
53 var asset: PHAsset? {
54 get {
55 self._asset
56 }
58 set {
59 self._asset = newValue
60 self.isDegraded = true
61 regenerateImage()
62 }
63 }
65 private func regenerateImage() {
66 if let thisAsset = asset {
67 if let nsImage = imageCache[thisAsset] {
68 self.image = nsImage
69 self.isDegraded = false
70 return
71 }
73 print("regenerating image for \(thisAsset.localIdentifier)")
75 // This implementation is based on code in a Stack Overflow answer
76 // by Francois Nadeau: https://stackoverflow.com/a/48755517/1558022
78 let options = PHImageRequestOptions()
80 options.isSynchronous = false
81 options.deliveryMode = deliveryMode
83 // If i don't set this value, then sometimes I get an error like
84 // this in the `info` variable:
85 //
86 // Error Domain=PHPhotosErrorDomain Code=3164 "(null)"
87 //
88 // This means that the asset is in the cloud, and by default Photos
89 // isn't allowed to download assets here. Apple's documentation
90 // suggests adding this option as the fix.
91 //
92 // See https://developer.apple.com/documentation/photokit/phphotoserror/phphotoserrornetworkaccessrequired
93 options.isNetworkAccessAllowed = true
95 PHCachingImageManager.default()
96 .requestImage(
97 for: thisAsset,
98 targetSize: size,
99 contentMode: .aspectFill,
100 options: options,
101 resultHandler: { (result, info) -> Void in
102 if let isDegraded = info?[PHImageResultIsDegradedKey] as? Bool {
103 self.isDegraded = isDegraded
104 }
106 if let imageResult = result {
107// print("got image!")
108 self.image = imageResult
110 if !self.isDegraded {
111 self.imageCache[thisAsset] = imageResult
112 }
113 } else {
114 self.isDegraded = true
115 print("Error getting image: \(String(describing: info))")
116 }
117 }
118 )
119 }
120 }