Try to preserve a user’s position in the thumbnail list
- ID
a7de9be- date
2023-06-11 04:52:51+00:00- author
Alex Chan <alex@alexwlchan.net>- parent
e4749db- message
Try to preserve a user's position in the thumbnail list- changed files
2 files, 143 additions, 4 deletions
Changed files
BlinkReviewer/BlinkReviewer/Photos/PhotosLibrary.swift (6187) → BlinkReviewer/BlinkReviewer/Photos/PhotosLibrary.swift (6877)
diff --git a/BlinkReviewer/BlinkReviewer/Photos/PhotosLibrary.swift b/BlinkReviewer/BlinkReviewer/Photos/PhotosLibrary.swift
index 5acd571..3a631d8 100644
--- a/BlinkReviewer/BlinkReviewer/Photos/PhotosLibrary.swift
+++ b/BlinkReviewer/BlinkReviewer/Photos/PhotosLibrary.swift
@@ -18,6 +18,22 @@ class PhotosLibrary: NSObject, ObservableObject, PHPhotoLibraryChangeObserver {
@Published var rejectedAssets: PHFetchResult<PHAsset> = PHFetchResult()
@Published var needsActionAssets: PHFetchResult<PHAsset> = PHFetchResult()
+ // We publish the latest changes we detect from the Photos library.
+ //
+ // Views can subscribe to updates with
+ //
+ // ```swift
+ // .onChange(of: photosLibrary.latestChangeDetails, perform: { lastChangeDetails in
+ // ...
+ // }
+ // ```
+ //
+ // and then access the individual properties to work out how to rearrange the
+ // UI to preserve the user's focused position (if possible).
+ //
+ // See https://developer.apple.com/documentation/photokit/phfetchresultchangedetails/1613898-enumeratemoves
+ @Published var latestChangeDetails: PHFetchResultChangeDetails<PHAsset>? = nil
+
private lazy var approved = getAlbum(withName: "Approved")
private lazy var rejected = getAlbum(withName: "Rejected")
private lazy var needsAction = getAlbum(withName: "Needs Action")
@@ -55,6 +71,7 @@ class PhotosLibrary: NSObject, ObservableObject, PHPhotoLibraryChangeObserver {
if let assetsChangeDetails = changeInstance.changeDetails(for: self.assets2) {
self.assets2 = assetsChangeDetails.fetchResultAfterChanges
+ self.latestChangeDetails = assetsChangeDetails
}
if let approvedChangeDetails = changeInstance.changeDetails(for: self.approvedAssets) {
BlinkReviewer/BlinkReviewer/Views/PhotoReviewer.swift (8102) → BlinkReviewer/BlinkReviewer/Views/PhotoReviewer.swift (14279)
diff --git a/BlinkReviewer/BlinkReviewer/Views/PhotoReviewer.swift b/BlinkReviewer/BlinkReviewer/Views/PhotoReviewer.swift
index ece2d8d..7e9b31e 100644
--- a/BlinkReviewer/BlinkReviewer/Views/PhotoReviewer.swift
+++ b/BlinkReviewer/BlinkReviewer/Views/PhotoReviewer.swift
@@ -10,6 +10,8 @@ import SwiftUI
import Photos
struct PhotoReviewer: View {
+ let logger = Logger()
+
@EnvironmentObject var photosLibrary: PhotosLibrary
@ObservedObject var fullSizeImage: PHAssetImage = PHAssetImage(nil, size: PHImageManagerMaximumSize, deliveryMode: .highQualityFormat)
@@ -21,8 +23,10 @@ struct PhotoReviewer: View {
// is the 0th.
@State var focusedAssetIndex: Int = 0
+ @State var _focusedAsset: PHAsset? = nil
+
var focusedAsset: PHAsset {
- photosLibrary.assets2.object(at: focusedAssetIndex)
+ return photosLibrary.assets2.object(at: focusedAssetIndex)
}
@State var showStatistics: Bool = false
@@ -44,7 +48,7 @@ struct PhotoReviewer: View {
ThumbnailList(focusedAssetIndex: $focusedAssetIndex)
.environmentObject(photosLibrary)
.frame(height: 90)
- .background(.gray.opacity(0.7))
+ .background(.gray.opacity(0.3))
FocusedImage(assetImage: focusedAssetImage)
.environmentObject(photosLibrary)
@@ -88,10 +92,128 @@ struct PhotoReviewer: View {
// See the comments on FocusedImage for more explanation of why this is
// managed this way.
.onAppear { focusedAssetImage.asset = focusedAsset }
- .onChange(of: focusedAsset) { newFocusedAsset in
- focusedAssetImage.asset = newFocusedAsset
+ .onChange(of: focusedAssetIndex) { _ in
+ focusedAssetImage.asset = focusedAsset
}
+ // These two methods are used to preserve position when there are changes
+ // in the Photos Library, e.g. deleted assets.
+ //
+ // We cache the currently focused asset, so we know what we were looking at
+ // before the library changed, and we call the `updateFocusAfterLibraryChange`
+ // handler whenever we see a change.
+ .onChange(of: focusedAssetIndex, perform: { _ in
+ self._focusedAsset = self.focusedAsset
+ })
+ .onChange(of: photosLibrary.latestChangeDetails, perform: updateFocusAfterLibraryChange)
+ }
+ }
+
+ /// Try to maintain the focused asset when the Photos Library changes.
+ ///
+ /// The goal is to keep the user looking at the same asset before/after the
+ /// library data changes. This isn't always possible, e.g. if the asset has
+ /// just been deleted, but we do a best effort attempt.
+ private func updateFocusAfterLibraryChange(lastChangeDetails: PHFetchResultChangeDetails<PHAsset>?) -> Void {
+
+ // Create a change ID. This doesn't mean anything outside the context
+ // of this function, but is useful for correlating log messages.
+ let changeId = UUID()
+
+ logger.debug("Updating focus after Photos Library change [\(changeId, privacy: .public)]")
+
+ // Maybe this change doesn't affect the currently focused asset; if so,
+ // we can stop immediately.
+ //
+ // e.g. the change is about album data, or all the changes are further
+ // along than the focused asset.
+ if photosLibrary.assets2.object(at: focusedAssetIndex) == self._focusedAsset {
+ logger.debug("Focused asset is in the same place as before, nothing to do [\(changeId, privacy: .public)]")
+ return
+ }
+
+ let start = DispatchTime.now()
+ var elapsed = start
+
+ func printElapsed(_ label: String) -> Void {
+ let now = DispatchTime.now()
+
+ let totalInterval = Double(now.uptimeNanoseconds - start.uptimeNanoseconds) / 1_000_000_000
+ let elapsedInterval = Double(now.uptimeNanoseconds - elapsed.uptimeNanoseconds) / 1_000_000_000
+
+ elapsed = DispatchTime.now()
+
+ print("Time to \(label):\n \(elapsedInterval) seconds (\(totalInterval) total)")
}
+
+ // The ChangeDetails can tell us how many assets were inserted/removed by
+ // the change, but only if it was a small change -- if it was a bigger change,
+ // we're meant to reload from scratch.
+ //
+ // Try looking at these properties first -- these deltas will typically be small,
+ // so we can evaluate them quickly. We look for all the indexes which have changed
+ // before the currently focused index.
+ let hasLastChangeDetails = lastChangeDetails != nil
+ let hasIncrementalChanges = lastChangeDetails?.hasIncrementalChanges == true
+
+ var delta: Int? = nil
+
+ if hasLastChangeDetails && hasIncrementalChanges {
+ logger.debug("Photos Library update has incremental changes [\(changeId, privacy: .public)]")
+ let removedIndexes = lastChangeDetails!.removedIndexes?
+ .filter { $0 <= focusedAssetIndex }
+ .count ?? 0
+
+ let insertedIndexes = lastChangeDetails!.insertedIndexes?
+ .filter { $0 <= focusedAssetIndex }
+ .count ?? 0
+
+ logger.debug("Removed indexes = \(removedIndexes, privacy: .public), inserted indexes = \(insertedIndexes, privacy: .public) [\(changeId, privacy: .public)]")
+
+ delta = insertedIndexes - removedIndexes
+ }
+
+ // If we've got a delta, check to see if it points us to the right asset.
+ //
+ // If it does, we're done!
+ if photosLibrary.assets2.object(at: focusedAssetIndex + (delta ?? 0)) == self._focusedAsset {
+ logger.debug("Incremental changes found the new position of the asset [\(changeId, privacy: .public)]")
+ focusedAssetIndex += delta ?? 0
+ return
+ }
+
+ // If we didn't get incremental changes or the incremental changes pointed us
+ // to the wrong place, then something bigger has changed in the Photos library.
+ //
+ // Maybe some assets have "moved" (I don't fully understand what that means without
+ // an example, and I suspect it may not apply to this use case, where we're sorting
+ // all the assets by creationDate), or maybe there were too many updates for
+ // an incremental change.
+ //
+ // In this case, let's see if we can find the asset in the update FetchResult.
+ //
+ // This is potentially quite slow, especially if we've already gone a long way
+ // into the Photos Library, which is why we leave it for last.
+ let matchingAssetInUpdatedLibrary =
+ (0..<photosLibrary.assets2.count)
+ .first(where: {
+ photosLibrary.assets2.object(at: $0).localIdentifier ==
+ self._focusedAsset?.localIdentifier
+ })
+
+ if let newIndex = matchingAssetInUpdatedLibrary {
+ logger.debug("Found an asset with matching identifier by doing a linear search [\(changeId, privacy: .public)]")
+ return
+ }
+
+ // If we still haven't found the asset, then it must have been deleted as
+ // part of this change. We can't keep the user in the same place, but
+ // maybe we can keep them nearby.
+ //
+ // Apply the delta from incremental changes (if we have it); there's not much
+ // more we can do without storing ever-larger amounts of state to pick a
+ // suitable restore point.
+ logger.debug("Focused asset was deleted as part of the changes; making best-effort guess at new focus position [\(changeId, privacy: .public)]")
+ self.focusedAssetIndex += (delta ?? 0)
}
private func handleKeyDown(_ event: NSEvent) {