11import SwiftUI
22
33struct NotificationListView : View {
4+ private static let scrollCoordinateSpace = " notification-list-scroll "
5+ static let downwardContextAnchor = UnitPoint ( x: 0.5 , y: 0.35 )
6+
47 let notifications : [ GitHubNotification ]
58 let selectedNotificationID : String ?
69 let checkedIDs : Set < String >
@@ -24,17 +27,15 @@ struct NotificationListView: View {
2427 }
2528 }
2629
27- enum ScrollPlacement : Equatable {
28- case minimal
29- case top
30- }
31-
3230 struct ScrollRequest : Equatable {
31+ let selectedNotificationID : String
3332 let targetID : String
34- let placement : ScrollPlacement
3533 let visibleIDs : [ String ]
3634 }
3735
36+ @State private var knownRowFrames : [ String : CGRect ] = [ : ]
37+ @State private var previousScrollRequest : ScrollRequest ?
38+
3839 private var listItems : [ ListItem ] {
3940 Self . listItems (
4041 notifications: notifications,
@@ -44,33 +45,55 @@ struct NotificationListView: View {
4445 }
4546
4647 var body : some View {
47- ScrollViewReader { proxy in
48- ScrollView ( . vertical) {
49- LazyVStack ( spacing: 0 ) {
50- ForEach ( listItems, id: \. id) { item in
51- listItemView ( item)
48+ let currentScrollRequest = Self . scrollRequest (
49+ selectedNotificationID: selectedNotificationID,
50+ notifications: notifications,
51+ groupByRepo: groupByRepo
52+ )
53+
54+ GeometryReader { viewportGeometry in
55+ ScrollViewReader { proxy in
56+ ScrollView ( . vertical) {
57+ LazyVStack ( spacing: 0 ) {
58+ ForEach ( listItems, id: \. id) { item in
59+ listItemView ( item)
60+ }
5261 }
5362 }
54- }
55- . task ( id: Self . scrollRequest (
56- selectedNotificationID: selectedNotificationID,
57- notifications: notifications,
58- groupByRepo: groupByRepo
59- ) ) {
60- guard let scrollRequest = Self . scrollRequest (
61- selectedNotificationID: selectedNotificationID,
62- notifications: notifications,
63- groupByRepo: groupByRepo
64- ) else {
65- return
63+ . coordinateSpace ( name: Self . scrollCoordinateSpace)
64+ . onPreferenceChange ( NotificationListRowFramesPreferenceKey . self) { frames in
65+ knownRowFrames. merge ( frames) { _, latest in latest }
6666 }
67+ . task ( id: currentScrollRequest) {
68+ guard let scrollRequest = currentScrollRequest else {
69+ previousScrollRequest = nil
70+ return
71+ }
72+
73+ let priorRequest = previousScrollRequest
74+ let priorRowFrame = priorRequest. flatMap {
75+ knownRowFrames [ $0. selectedNotificationID]
76+ }
77+ previousScrollRequest = scrollRequest
78+
79+ await Task . yield ( )
80+ let currentRowFrame = knownRowFrames [ scrollRequest. selectedNotificationID]
81+ let shouldRevealContext = Self . shouldRevealDownwardContext (
82+ previous: priorRequest,
83+ current: scrollRequest,
84+ previousRowFrame: priorRowFrame,
85+ currentRowFrame: currentRowFrame,
86+ viewportHeight: viewportGeometry. size. height
87+ )
88+ if shouldRevealContext {
89+ proxy. scrollTo (
90+ scrollRequest. selectedNotificationID,
91+ anchor: Self . downwardContextAnchor
92+ )
93+ return
94+ }
6795
68- await Task . yield ( )
69- switch scrollRequest. placement {
70- case . minimal:
7196 proxy. scrollTo ( scrollRequest. targetID)
72- case . top:
73- proxy. scrollTo ( scrollRequest. targetID, anchor: . top)
7497 }
7598 }
7699 }
@@ -95,6 +118,18 @@ struct NotificationListView: View {
95118 onToggleCheck: { onToggleCheck ( notification. id) }
96119 )
97120 . id ( notification. id)
121+ . background {
122+ GeometryReader { geometry in
123+ Color . clear. preference (
124+ key: NotificationListRowFramesPreferenceKey . self,
125+ value: [
126+ notification. id: geometry. frame (
127+ in: . named( Self . scrollCoordinateSpace)
128+ )
129+ ]
130+ )
131+ }
132+ }
98133 . onTapGesture {
99134 Self . handleRowTap (
100135 id: notification. id,
@@ -118,31 +153,48 @@ struct NotificationListView: View {
118153 static func scrollRequest(
119154 selectedNotificationID: String ? ,
120155 notifications: [ GitHubNotification ] ,
121- groupByRepo: Bool
156+ groupByRepo _ : Bool
122157 ) -> ScrollRequest ? {
123158 guard let selectedNotificationID,
124- let selectedIndex = notifications. firstIndex ( where: { $0. id == selectedNotificationID } ) else {
159+ notifications. contains ( where: { $0. id == selectedNotificationID } ) else {
125160 return nil
126161 }
127162
128- let targetID : String
129- let placement : ScrollPlacement
130- if groupByRepo,
131- selectedIndex == 0 || notifications [ selectedIndex - 1 ] . repository != notifications [ selectedIndex] . repository {
132- targetID = " repo: \( notifications [ selectedIndex] . repository) "
133- placement = . top
134- } else {
135- targetID = selectedNotificationID
136- placement = . minimal
137- }
138-
139163 return ScrollRequest (
140- targetID : targetID ,
141- placement : placement ,
164+ selectedNotificationID : selectedNotificationID ,
165+ targetID : selectedNotificationID ,
142166 visibleIDs: notifications. map ( \. id)
143167 )
144168 }
145169
170+ static func shouldRevealDownwardContext(
171+ previous: ScrollRequest ? ,
172+ current: ScrollRequest ,
173+ previousRowFrame: CGRect ? ,
174+ currentRowFrame: CGRect ? ,
175+ viewportHeight: CGFloat
176+ ) -> Bool {
177+ guard let previous,
178+ viewportHeight > 0 ,
179+ let previousIndex = previous. visibleIDs. firstIndex ( of: previous. selectedNotificationID) ,
180+ let currentIndexInPreviousList = previous. visibleIDs. firstIndex ( of: current. selectedNotificationID) ,
181+ currentIndexInPreviousList > previousIndex else {
182+ return false
183+ }
184+
185+ guard let previousRowFrame else { return false }
186+
187+ guard let currentRowFrame else {
188+ return true
189+ }
190+ if currentRowFrame. maxY > viewportHeight {
191+ return true
192+ }
193+
194+ let bottomTolerance = max ( 12 , previousRowFrame. height * 0.5 )
195+ return previousRowFrame. maxY >= viewportHeight - bottomTolerance
196+ }
197+
146198 static func listItems(
147199 notifications: [ GitHubNotification ] ,
148200 selectedNotificationID: String ? ,
@@ -175,3 +227,11 @@ struct NotificationListView: View {
175227 return items
176228 }
177229}
230+
231+ private struct NotificationListRowFramesPreferenceKey : PreferenceKey {
232+ static var defaultValue : [ String : CGRect ] = [ : ]
233+
234+ static func reduce( value: inout [ String : CGRect ] , nextValue: ( ) -> [ String : CGRect ] ) {
235+ value. merge ( nextValue ( ) ) { _, latest in latest }
236+ }
237+ }
0 commit comments