Skip to main content

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) {