Skip to main content

Break up all the thumbnail bits

ID
5fda324
date
2023-06-09 22:13:07+00:00
author
Alex Chan <alex@alexwlchan.net>
parent
d7a568b
message
Break up all the thumbnail bits
changed files
8 files, 189 additions, 29 deletions

Changed files

BlinkReviewer/BlinkReviewer.xcodeproj/project.pbxproj (29350) → BlinkReviewer/BlinkReviewer.xcodeproj/project.pbxproj (31238)

diff --git a/BlinkReviewer/BlinkReviewer.xcodeproj/project.pbxproj b/BlinkReviewer/BlinkReviewer.xcodeproj/project.pbxproj
index 363ac31..97aaaa5 100644
--- a/BlinkReviewer/BlinkReviewer.xcodeproj/project.pbxproj
+++ b/BlinkReviewer/BlinkReviewer.xcodeproj/project.pbxproj
@@ -9,6 +9,10 @@
 /* Begin PBXBuildFile section */
 		940331732A336B5100200C5D /* DeferredRendering.swift in Sources */ = {isa = PBXBuildFile; fileRef = 940331722A336B5100200C5D /* DeferredRendering.swift */; };
 		945F17B02A33D167004FC479 /* NewThumbnailImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 945F17AF2A33D167004FC479 /* NewThumbnailImage.swift */; };
+		945F17B22A33D69B004FC479 /* FavoriteOverlay.swift in Sources */ = {isa = PBXBuildFile; fileRef = 945F17B12A33D69B004FC479 /* FavoriteOverlay.swift */; };
+		945F17B42A33D726004FC479 /* ReviewStateIcon.swift in Sources */ = {isa = PBXBuildFile; fileRef = 945F17B32A33D726004FC479 /* ReviewStateIcon.swift */; };
+		945F17B62A33D7AA004FC479 /* ReviewStateBorder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 945F17B52A33D7AA004FC479 /* ReviewStateBorder.swift */; };
+		945F17B82A33DAC7004FC479 /* ReviewStateSaturation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 945F17B72A33DAC7004FC479 /* ReviewStateSaturation.swift */; };
 		94C5FFF22A33ADD4004ADDF5 /* PHFetchResultCollection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94C5FFF12A33ADD4004ADDF5 /* PHFetchResultCollection.swift */; };
 		94C5FFF62A33B698004ADDF5 /* PHAssetHStack.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94C5FFF52A33B698004ADDF5 /* PHAssetHStack.swift */; };
 		94D2C8B92A320E6F00BEE15B /* ReviewState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94D2C8B82A320E6F00BEE15B /* ReviewState.swift */; };
@@ -50,6 +54,10 @@
 /* Begin PBXFileReference section */
 		940331722A336B5100200C5D /* DeferredRendering.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeferredRendering.swift; sourceTree = "<group>"; };
 		945F17AF2A33D167004FC479 /* NewThumbnailImage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewThumbnailImage.swift; sourceTree = "<group>"; };
+		945F17B12A33D69B004FC479 /* FavoriteOverlay.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FavoriteOverlay.swift; sourceTree = "<group>"; };
+		945F17B32A33D726004FC479 /* ReviewStateIcon.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReviewStateIcon.swift; sourceTree = "<group>"; };
+		945F17B52A33D7AA004FC479 /* ReviewStateBorder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReviewStateBorder.swift; sourceTree = "<group>"; };
+		945F17B72A33DAC7004FC479 /* ReviewStateSaturation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReviewStateSaturation.swift; sourceTree = "<group>"; };
 		94C5FFF12A33ADD4004ADDF5 /* PHFetchResultCollection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PHFetchResultCollection.swift; sourceTree = "<group>"; };
 		94C5FFF52A33B698004ADDF5 /* PHAssetHStack.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PHAssetHStack.swift; sourceTree = "<group>"; };
 		94D2C8B82A320E6F00BEE15B /* ReviewState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReviewState.swift; sourceTree = "<group>"; };
@@ -114,6 +122,10 @@
 			isa = PBXGroup;
 			children = (
 				945F17AF2A33D167004FC479 /* NewThumbnailImage.swift */,
+				945F17B12A33D69B004FC479 /* FavoriteOverlay.swift */,
+				945F17B32A33D726004FC479 /* ReviewStateIcon.swift */,
+				945F17B52A33D7AA004FC479 /* ReviewStateBorder.swift */,
+				945F17B72A33DAC7004FC479 /* ReviewStateSaturation.swift */,
 			);
 			path = Thumbnails;
 			sourceTree = "<group>";
@@ -347,12 +359,16 @@
 				945F17B02A33D167004FC479 /* NewThumbnailImage.swift in Sources */,
 				94D2C8BF2A3299BD00BEE15B /* PhotosLibrary.swift in Sources */,
 				94C5FFF62A33B698004ADDF5 /* PHAssetHStack.swift in Sources */,
+				945F17B22A33D69B004FC479 /* FavoriteOverlay.swift in Sources */,
+				945F17B42A33D726004FC479 /* ReviewStateIcon.swift in Sources */,
+				945F17B62A33D7AA004FC479 /* ReviewStateBorder.swift in Sources */,
 				94D7511E2A31B243005859E7 /* FullSizeImage.swift in Sources */,
 				94C5FFF22A33ADD4004ADDF5 /* PHFetchResultCollection.swift in Sources */,
 				94D750F02A31A796005859E7 /* BlinkReviewerApp.swift in Sources */,
 				940331732A336B5100200C5D /* DeferredRendering.swift in Sources */,
 				94D751202A31B53E005859E7 /* AlbumInfo.swift in Sources */,
 				94D751302A31DC4A005859E7 /* ThumbnailList.swift in Sources */,
+				945F17B82A33DAC7004FC479 /* ReviewStateSaturation.swift in Sources */,
 				94D751222A31BD8E005859E7 /* PhotoReviewer.swift in Sources */,
 				94D2C8B92A320E6F00BEE15B /* ReviewState.swift in Sources */,
 				94F7E39E2A331A9E00763DB9 /* Statistics.swift in Sources */,

BlinkReviewer/BlinkReviewer/Views/Helpers/PHAssetHStack.swift (3751) → BlinkReviewer/BlinkReviewer/Views/Helpers/PHAssetHStack.swift (3751)

diff --git a/BlinkReviewer/BlinkReviewer/Views/Helpers/PHAssetHStack.swift b/BlinkReviewer/BlinkReviewer/Views/Helpers/PHAssetHStack.swift
index 0e8e0d8..7595b8d 100644
--- a/BlinkReviewer/BlinkReviewer/Views/Helpers/PHAssetHStack.swift
+++ b/BlinkReviewer/BlinkReviewer/Views/Helpers/PHAssetHStack.swift
@@ -35,7 +35,7 @@ struct PHAssetHStack<Content: View>: View {
     
     var body: some View {
         ScrollView(.horizontal) {
-            LazyHStack(spacing: 5) {
+            LazyHStack(spacing: 7) {
                 // Implementation note: we use the localIdentifier rather than the
                 // array index as the id here, because the app gets way slower if
                 // you use the PHFetchResult index -- it tries to regenerate a bunch of

BlinkReviewer/BlinkReviewer/Views/PhotoReviewer.swift (8684) → BlinkReviewer/BlinkReviewer/Views/PhotoReviewer.swift (7686)

diff --git a/BlinkReviewer/BlinkReviewer/Views/PhotoReviewer.swift b/BlinkReviewer/BlinkReviewer/Views/PhotoReviewer.swift
index 26f7acb..d3cb34a 100644
--- a/BlinkReviewer/BlinkReviewer/Views/PhotoReviewer.swift
+++ b/BlinkReviewer/BlinkReviewer/Views/PhotoReviewer.swift
@@ -34,25 +34,8 @@ struct PhotoReviewer: View {
             ZStack {
                 VStack {
                     PHAssetHStack(photosLibrary.assets2) { asset, index in
-                        VStack {
-                            
-                            NewThumbnailImage(asset)
-//                                .resizable()
-                                .saturation(photosLibrary.state(for: asset) == .Rejected ? 0.0 : 1.0)
-                                // Note: it's taken several attempts to get this working correctly;
-                                // it behaves differently in the running app to the SwiftUI preview.
-                                //
-                                // Expected properties:
-                                //
-                                //    - Thumbnails are square
-                                //    - Thumbnails are expanded to fill the square, but they prefer
-                                //      to crop rather than stretch the image
-                                //
-                                .scaledToFill()
-                                .frame(width: 70.0, height: 70.0, alignment: .center)
-                                .border(.green)
-//                            Text("\(index) / \(asset.localIdentifier)")
-                        }
+                        NewThumbnailImage(asset, isFocused: index == focusedAssetIndex)
+                            .environmentObject(photosLibrary)
                     }
                 }
 //                

BlinkReviewer/BlinkReviewer/Views/Thumbnails/FavoriteOverlay.swift (0) → BlinkReviewer/BlinkReviewer/Views/Thumbnails/FavoriteOverlay.swift (804)

diff --git a/BlinkReviewer/BlinkReviewer/Views/Thumbnails/FavoriteOverlay.swift b/BlinkReviewer/BlinkReviewer/Views/Thumbnails/FavoriteOverlay.swift
new file mode 100644
index 0000000..ee93bfa
--- /dev/null
+++ b/BlinkReviewer/BlinkReviewer/Views/Thumbnails/FavoriteOverlay.swift
@@ -0,0 +1,30 @@
+import SwiftUI
+import Photos
+
+/// Renders a small heart to indicate a photo is a "Favorite".
+///
+/// This is meant to match the way favorite items are marked in Photos.
+struct FavoriteHeartIcon: ViewModifier {
+    let asset: PHAsset
+    
+    init(_ asset: PHAsset) {
+        self.asset = asset
+    }
+    
+    func body(content: Content) -> some View {
+        content.overlay(alignment: Alignment(horizontal: .leading, vertical: .bottom)) {
+            if asset.isFavorite {
+                Image(systemName: "heart.fill")
+                    .foregroundColor(.white)
+                    .padding(2)
+                    .shadow(radius: 2.0)
+            }
+        }
+    }
+}
+
+extension View {
+    func favoriteHeartIcon(for asset: PHAsset) -> some View {
+        modifier(FavoriteHeartIcon(asset))
+    }
+}

BlinkReviewer/BlinkReviewer/Views/Thumbnails/NewThumbnailImage.swift (492) → BlinkReviewer/BlinkReviewer/Views/Thumbnails/NewThumbnailImage.swift (2012)

diff --git a/BlinkReviewer/BlinkReviewer/Views/Thumbnails/NewThumbnailImage.swift b/BlinkReviewer/BlinkReviewer/Views/Thumbnails/NewThumbnailImage.swift
index a2b74a2..d8d96f7 100644
--- a/BlinkReviewer/BlinkReviewer/Views/Thumbnails/NewThumbnailImage.swift
+++ b/BlinkReviewer/BlinkReviewer/Views/Thumbnails/NewThumbnailImage.swift
@@ -1,23 +1,64 @@
-//
-//  NewThumbnailImage.swift
-//  BlinkReviewer
-//
-//  Created by Alex Chan on 09/06/2023.
-//
-
 import SwiftUI
 import Photos
 
+/// Render a single thumbnail image in the thumbnail picker.
+///
+/// Thumbnails are square, and they expand to fill the square.  This may
+/// mean some information gets cropped out -- that's okay, these are only
+/// small previews, not complete images.
 struct NewThumbnailImage: View {
+    @EnvironmentObject var photosLibrary: PhotosLibrary
+    
     var asset: PHAsset
+    var isFocused: Bool
+    
+    private var size: CGFloat
+    private var cornerRadius: CGFloat
+    
     @ObservedObject var assetImage: PHAssetImage
     
-    init(_ asset: PHAsset) {
+    init(_ asset: PHAsset, isFocused: Bool) {
         self.asset = asset
-        self.assetImage = PHAssetImage(asset, size: CGSize(width: 70.0, height: 70.0), deliveryMode: .fastFormat)
+        self.isFocused = isFocused
+        
+        self.size = isFocused ? 70 : 50
+        self.cornerRadius = isFocused ? 7 : 5
+        
+        self.assetImage = PHAssetImage(
+            asset,
+            size: CGSize(width: self.size, height: self.size),
+            deliveryMode: .fastFormat
+        )
+    }
+    
+    private var state: ReviewState? {
+        photosLibrary.state(for: asset)
     }
     
     var body: some View {
         Image(nsImage: assetImage.image)
+            .resizable()
+            .scaledToFill()
+            .clipped()
+            .frame(width: self.size, height: self.size, alignment: .center)
+            .cornerRadius(cornerRadius)
+            .reviewStateBorder(for: state, with: cornerRadius)
+            .reviewStateIcon(for: state)
+            .reviewStateColor(isRejected: state == .Rejected)
+            .favoriteHeartIcon(for: asset)
+    }
+}
+
+struct NewThumbnailImage_Previews: PreviewProvider {
+    static var asset: PHAsset = PHAsset.fetchAssets(with: nil).firstObject!
+    
+    static var previews: some View {
+        NewThumbnailImage(asset, isFocused: false)
+            .environmentObject(PhotosLibrary())
+            .previewDisplayName("thumbnail, not focused")
+        
+        NewThumbnailImage(asset, isFocused: true)
+            .environmentObject(PhotosLibrary())
+            .previewDisplayName("thumbnail, focused")
     }
 }

BlinkReviewer/BlinkReviewer/Views/Thumbnails/ReviewStateBorder.swift (0) → BlinkReviewer/BlinkReviewer/Views/Thumbnails/ReviewStateBorder.swift (1049)

diff --git a/BlinkReviewer/BlinkReviewer/Views/Thumbnails/ReviewStateBorder.swift b/BlinkReviewer/BlinkReviewer/Views/Thumbnails/ReviewStateBorder.swift
new file mode 100644
index 0000000..a65d4fc
--- /dev/null
+++ b/BlinkReviewer/BlinkReviewer/Views/Thumbnails/ReviewStateBorder.swift
@@ -0,0 +1,34 @@
+import SwiftUI
+
+import SwiftUI
+
+/// Renders a small icon to show the review state, e.g. a green circled tick
+/// for "Approved" images.
+struct ReviewStateBorder: ViewModifier {
+    let state: ReviewState?
+    let cornerRadius: CGFloat
+    
+    init(_ state: ReviewState?, _ cornerRadius: CGFloat) {
+        self.state = state
+        self.cornerRadius = cornerRadius
+    }
+    
+    func body(content: Content) -> some View {
+        content.overlay() {
+            // This technique for drawing a coloured border with rounded corners
+            // comes from an article by Simon Ng:
+            // https://www.appcoda.com/swiftui-border/
+            RoundedRectangle(cornerRadius: cornerRadius)
+                .stroke(
+                    state?.color() ?? .gray.opacity(0.7),
+                    lineWidth: state != nil ? 3.0 : 1.0
+                )
+        }
+    }
+}
+
+extension View {
+    func reviewStateBorder(for state: ReviewState?, with cornerRadius: CGFloat) -> some View {
+        modifier(ReviewStateBorder(state, cornerRadius))
+    }
+}

BlinkReviewer/BlinkReviewer/Views/Thumbnails/ReviewStateIcon.swift (0) → BlinkReviewer/BlinkReviewer/Views/Thumbnails/ReviewStateIcon.swift (893)

diff --git a/BlinkReviewer/BlinkReviewer/Views/Thumbnails/ReviewStateIcon.swift b/BlinkReviewer/BlinkReviewer/Views/Thumbnails/ReviewStateIcon.swift
new file mode 100644
index 0000000..1133501
--- /dev/null
+++ b/BlinkReviewer/BlinkReviewer/Views/Thumbnails/ReviewStateIcon.swift
@@ -0,0 +1,32 @@
+import SwiftUI
+
+/// Renders a small icon to show the review state, e.g. a green circled tick
+/// for "Approved" images.
+struct ReviewStateIcon: ViewModifier {
+    let state: ReviewState?
+    
+    init(_ state: ReviewState?) {
+        self.state = state
+    }
+    
+    func body(content: Content) -> some View {
+        if let thisState = state {
+            content.overlay(alignment: Alignment(horizontal: .leading, vertical: .top)) {
+                thisState.icon()
+                    .foregroundStyle(.white, thisState.color())
+                    .symbolRenderingMode(.palette)
+                    .padding(2)
+                    .font(.title2)
+                    .shadow(radius: 2.0)
+            }
+        } else {
+            content
+        }
+    }
+}
+
+extension View {
+    func reviewStateIcon(for state: ReviewState?) -> some View {
+        modifier(ReviewStateIcon(state))
+    }
+}

BlinkReviewer/BlinkReviewer/Views/Thumbnails/ReviewStateSaturation.swift (0) → BlinkReviewer/BlinkReviewer/Views/Thumbnails/ReviewStateSaturation.swift (502)

diff --git a/BlinkReviewer/BlinkReviewer/Views/Thumbnails/ReviewStateSaturation.swift b/BlinkReviewer/BlinkReviewer/Views/Thumbnails/ReviewStateSaturation.swift
new file mode 100644
index 0000000..1dbfb14
--- /dev/null
+++ b/BlinkReviewer/BlinkReviewer/Views/Thumbnails/ReviewStateSaturation.swift
@@ -0,0 +1,24 @@
+import SwiftUI
+
+/// Desaturates rejected images.
+struct ReviewStateSaturation: ViewModifier {
+    let isRejected: Bool
+    
+    init(_ isRejected: Bool) {
+        self.isRejected = isRejected
+    }
+    
+    func body(content: Content) -> some View {
+        if isRejected {
+            content.saturation(0.0)
+        } else {
+            content
+        }
+    }
+}
+
+extension View {
+    func reviewStateColor(isRejected: Bool) -> some View {
+        modifier(ReviewStateSaturation(isRejected))
+    }
+}