@@ -6,8 +6,7 @@ struct EntityGridView<T: Entity>: View {
66 let contextMenuItems : ( T ) -> [ ContextMenuItem ]
77
88 @State private var hoveredEntityID : UUID ?
9- @State private var isScrolling = false
10-
9+
1110 private let columns = [
1211 GridItem ( . adaptive( minimum: ViewDefaults . gridArtworkSize, maximum: ViewDefaults . gridArtworkSize + 40 ) , spacing: 16 )
1312 ]
@@ -18,30 +17,24 @@ struct EntityGridView<T: Entity>: View {
1817 ForEach ( entities) { entity in
1918 EntityGridItem (
2019 entity: entity,
21- isHovered: isScrolling ? false : ( hoveredEntityID == entity. id) ,
22- isScrolling: isScrolling,
20+ isHovered: hoveredEntityID == entity. id,
2321 onSelect: {
2422 onSelectEntity ( entity)
2523 } ,
2624 onHover: { isHovered in
27- if !isScrolling {
28- hoveredEntityID = isHovered ? entity. id : nil
29- }
25+ hoveredEntityID = isHovered ? entity. id : nil
3026 }
3127 )
3228 . contextMenu {
3329 ForEach ( contextMenuItems ( entity) , id: \. id) { item in
3430 contextMenuItem ( item)
3531 }
3632 }
37- . id ( entity. id)
3833 }
3934 }
4035 . padding ( . horizontal, 14 )
4136 . padding ( . vertical, 6 )
4237 }
43- . coordinateSpace ( name: " scroll " )
44- . modifier ( ScrollDetectionModifier ( isScrolling: $isScrolling, hoveredEntityID: $hoveredEntityID) )
4538 }
4639
4740 @ViewBuilder
@@ -50,106 +43,58 @@ struct EntityGridView<T: Entity>: View {
5043 }
5144}
5245
53- // MARK: - Cross-OS Scroll Detection
46+ // MARK: - Image Cache
5447
55- private struct ScrollDetectionModifier : ViewModifier {
56- @Binding var isScrolling : Bool
57- @Binding var hoveredEntityID : UUID ?
58-
59- func body( content: Content ) -> some View {
60- if #available( macOS 15 . 0 , * ) {
61- content
62- . onScrollPhaseChange { _, newPhase in
63- withAnimation ( . none) {
64- let wasScrolling = isScrolling
65- isScrolling = newPhase == . interacting || newPhase == . decelerating
66-
67- if isScrolling && !wasScrolling {
68- hoveredEntityID = nil
69- }
70- }
71- }
72- } else {
73- content
74- . background (
75- ScrollDetectionView { isDetectedScrolling in
76- if isDetectedScrolling != isScrolling {
77- withAnimation ( . none) {
78- isScrolling = isDetectedScrolling
79- if isScrolling {
80- hoveredEntityID = nil
81- }
82- }
83- }
84- }
85- )
86- }
87- }
88- }
48+ private final class EntityArtworkCache : @unchecked Sendable {
49+ static let shared = EntityArtworkCache ( )
50+ private let cache = NSCache < NSString , NSImage > ( )
51+ private let loadQueue : OperationQueue = {
52+ let queue = OperationQueue ( )
53+ queue. maxConcurrentOperationCount = max ( 2 , ProcessInfo . processInfo. activeProcessorCount / 2 )
54+ queue. qualityOfService = . utility
55+ return queue
56+ } ( )
8957
90- // MARK: - Scroll Detection for macOS 14
58+ private static let pixelSize = Int ( ViewDefaults . gridArtworkSize * 2 )
59+ private static let bytesPerImage = pixelSize * pixelSize * 4
9160
92- private struct ScrollDetectionView : View {
93- let onScrollingChanged : ( Bool ) -> Void
94- @State private var lastOffset : CGFloat = 0
95- @State private var scrollTimer : Timer ?
96-
97- var body : some View {
98- GeometryReader { geometry in
99- Color . clear
100- . preference (
101- key: ScrollOffsetKey . self,
102- value: geometry. frame ( in: . named( " scroll " ) ) . origin. y
103- )
104- }
105- . onPreferenceChange ( ScrollOffsetKey . self) { newOffset in
106- if abs ( newOffset - lastOffset) > 1 {
107- onScrollingChanged ( true )
108- lastOffset = newOffset
109-
110- scrollTimer? . invalidate ( )
111- scrollTimer = Timer . scheduledTimer ( withTimeInterval: 0.15 , repeats: false ) { _ in
112- onScrollingChanged ( false )
113- }
114- }
115- }
61+ init ( ) {
62+ cache. countLimit = 500
63+ cache. totalCostLimit = 80 * 1024 * 1024
11664 }
117- }
11865
119- private struct ScrollOffsetKey : PreferenceKey {
120- static var defaultValue : CGFloat = 0
121- static func reduce( value: inout CGFloat , nextValue: ( ) -> CGFloat ) {
122- value = nextValue ( )
66+ private func cacheKey( for entity: any Entity ) -> NSString {
67+ let artworkSize = entity. artworkData? . count ?? 0
68+ return " \( entity. id. uuidString) - \( artworkSize) -rendered " as NSString
12369 }
124- }
125-
126- // MARK: - Image Cache
12770
128- private class RenderedImageCache {
129- static let shared = RenderedImageCache ( )
130- private let cache = NSCache < NSString , NSImage > ( )
131-
132- init ( ) {
133- cache. countLimit = 1000
71+ func getCachedImage( for entity: any Entity ) -> NSImage ? {
72+ cache. object ( forKey: cacheKey ( for: entity) )
13473 }
135-
136- func getImage( for entity: any Entity ) -> NSImage ? {
137- let artworkHash = entity. artworkData? . hashValue ?? 0
138- let key = " \( entity. id. uuidString) - \( artworkHash) -rendered " as NSString
74+
75+ func loadImage( for entity: any Entity ) async -> NSImage ? {
76+ let key = cacheKey ( for: entity)
13977
14078 if let cached = cache. object ( forKey: key) {
14179 return cached
14280 }
14381
14482 guard let artworkData = entity. artworkData else { return nil }
14583
146- let renderedImage = createRenderedImage ( from: artworkData)
147-
148- if let image = renderedImage {
149- cache. setObject ( image, forKey: key)
84+ return await loadQueue. renderArtwork { [ self ] in
85+ // Re-check cache, another operation may have loaded it while queued
86+ if let cached = cache. object ( forKey: key) {
87+ return cached
88+ }
89+
90+ let renderedImage = createRenderedImage ( from: artworkData)
91+
92+ if let image = renderedImage {
93+ cache. setObject ( image, forKey: key, cost: Self . bytesPerImage)
94+ }
95+
96+ return renderedImage
15097 }
151-
152- return renderedImage
15398 }
15499
155100 private func createRenderedImage( from data: Data ) -> NSImage ? {
@@ -206,7 +151,6 @@ private class RenderedImageCache {
206151private struct EntityGridItem < T: Entity > : View {
207152 let entity : T
208153 let isHovered : Bool
209- let isScrolling : Bool
210154 let onSelect : ( ) -> Void
211155 let onHover : ( Bool ) -> Void
212156
@@ -242,11 +186,8 @@ private struct EntityGridItem<T: Entity>: View {
242186 . shadow ( color: . black. opacity ( 0.2 ) , radius: 10 , x: 0 , y: 5 )
243187 }
244188 }
245- . onAppear {
246- loadArtwork ( )
247- }
248- . onChange ( of: entity. artworkData) {
249- loadArtwork ( )
189+ . task ( id: artworkTaskID) {
190+ await loadArtwork ( )
250191 }
251192
252193 VStack ( alignment: . leading, spacing: 2 ) {
@@ -293,23 +234,27 @@ private struct EntityGridItem<T: Entity>: View {
293234 . background (
294235 RoundedRectangle ( cornerRadius: 10 )
295236 . fill ( isHovered ? Color ( NSColor . selectedContentBackgroundColor) . opacity ( 0.15 ) : Color . clear)
296- . animation (
297- isScrolling ? . none : . easeInOut( duration: 0.08 ) ,
298- value: isHovered
299- )
237+ . animation ( . easeInOut( duration: 0.08 ) , value: isHovered)
300238 )
301239 . contentShape ( Rectangle ( ) )
302240 . onTapGesture ( perform: onSelect)
303241 . onHover ( perform: onHover)
304242 }
305243
306- private func loadArtwork ( ) {
307- DispatchQueue . global ( qos : . userInitiated ) . async {
308- let image = RenderedImageCache . shared . getImage ( for : entity )
244+ private var artworkTaskID : String {
245+ " \( entity . id . uuidString ) - \( entity . artworkData ? . count ?? 0 ) "
246+ }
309247
310- DispatchQueue . main. async {
311- self . renderedImage = image
312- }
248+ private func loadArtwork( ) async {
249+ // Serve cache hits synchronously to avoid placeholder flicker on scroll recycle
250+ if let cached = EntityArtworkCache . shared. getCachedImage ( for: entity) {
251+ renderedImage = cached
252+ return
313253 }
254+
255+ let image = await EntityArtworkCache . shared. loadImage ( for: entity)
256+
257+ guard !Task. isCancelled else { return }
258+ renderedImage = image
314259 }
315260}
0 commit comments