@@ -40,12 +40,12 @@ private struct ListHeightPreferenceKey: PreferenceKey {
4040
4141struct ChatPanelMessages : View {
4242 let chat : StoreOf < Chat >
43- @State var pinnedToBottom = true
43+ @State var isScrollToBottomButtonDisplayed = true
44+ @State var isPinnedToBottom = true
4445 @Namespace var bottomID
4546 @Namespace var scrollSpace
4647 @State var scrollOffset : Double = 0
4748 @State var listHeight : Double = 0
48- @State var isInitialLoad = true
4949
5050 var body : some View {
5151 ScrollViewReader { proxy in
@@ -66,19 +66,19 @@ struct ChatPanelMessages: View {
6666 }
6767
6868 Spacer ( minLength: 12 )
69+ . id ( bottomID)
6970 . onAppear {
70- withAnimation {
71- proxy. scrollTo ( bottomID, anchor: . bottom)
72- }
71+ proxy. scrollTo ( bottomID, anchor: . bottom)
72+ }
73+ . task {
74+ proxy. scrollTo ( bottomID, anchor: . bottom)
7375 }
74- . id ( bottomID)
7576 . background ( GeometryReader { geo in
7677 let offset = geo. frame ( in: . named( scrollSpace) ) . minY
77- Color . clear
78- . preference (
79- key: ScrollViewOffsetPreferenceKey . self,
80- value: offset
81- )
78+ Color . clear. preference (
79+ key: ScrollViewOffsetPreferenceKey . self,
80+ value: offset
81+ )
8282 } )
8383 . preference (
8484 key: ListHeightPreferenceKey . self,
@@ -100,6 +100,17 @@ struct ChatPanelMessages: View {
100100 updatePinningState ( )
101101 }
102102 . onPreferenceChange ( ScrollViewOffsetPreferenceKey . self) { value in
103+ /// I don't know if there is a way to detect that a scroll is triggered by user
104+ let scrollUpToThreshold = listHeight > 0 // sometimes it can suddenly become 0
105+ && value > listHeight + 32 + 20 // scroll up to a threshold
106+ && value > scrollOffset // it's scroll up
107+ && value - scrollOffset < 100 // it's not some mystery jump
108+ /// Scroll up too much and the tracker is lost
109+ let checkerOutOfScope = value <= 0
110+ if checkerOutOfScope || scrollUpToThreshold {
111+ isPinnedToBottom = false
112+ }
113+
103114 scrollOffset = value
104115 updatePinningState ( )
105116 }
@@ -114,49 +125,85 @@ struct ChatPanelMessages: View {
114125 }
115126 }
116127 . overlay ( alignment: . bottomTrailing) {
117- WithViewStore ( chat, observe: \. history. last) { viewStore in
118- Button ( action: {
119- withAnimation ( . easeInOut( duration: 0.1 ) ) {
120- proxy. scrollTo ( bottomID, anchor: . bottom)
121- }
122- } ) {
123- Image ( systemName: " arrow.down " )
124- . padding ( 4 )
125- . background {
126- Circle ( )
127- . fill ( . thickMaterial)
128- . shadow ( color: . black. opacity ( 0.2 ) , radius: 2 )
129- }
130- . overlay {
131- Circle ( ) . stroke ( Color ( nsColor: . separatorColor) , lineWidth: 1 )
132- }
133- . foregroundStyle ( . secondary)
134- . padding ( 4 )
135- }
136- . keyboardShortcut ( . downArrow, modifiers: [ . command] )
137- . opacity ( pinnedToBottom ? 0 : 1 )
138- . buttonStyle ( . plain)
139- . onChange ( of: viewStore. state) { _ in
140- if pinnedToBottom || isInitialLoad {
141- if isInitialLoad {
142- isInitialLoad = false
143- }
144- withAnimation {
145- proxy. scrollTo ( bottomID, anchor: . bottom)
146- }
147- }
148- }
128+ scrollToBottomButton ( proxy: proxy)
129+ }
130+ . background {
131+ PinToBottomHandler ( chat: chat, pinnedToBottom: $isPinnedToBottom) {
132+ proxy. scrollTo ( bottomID, anchor: . bottom)
149133 }
150134 }
151135 }
152136 }
153137 }
154138
139+ @MainActor
155140 func updatePinningState( ) {
156- if scrollOffset > listHeight + 24 + 100 || scrollOffset <= 0 {
157- pinnedToBottom = false
158- } else {
159- pinnedToBottom = true
141+ // where does the 32 come from?
142+ withAnimation {
143+ isScrollToBottomButtonDisplayed = scrollOffset > listHeight + 32 + 20
144+ || scrollOffset <= 0
145+ }
146+ }
147+
148+ @ViewBuilder
149+ func scrollToBottomButton( proxy: ScrollViewProxy ) -> some View {
150+ Button ( action: {
151+ withAnimation ( . easeInOut( duration: 0.1 ) ) {
152+ proxy. scrollTo ( bottomID, anchor: . bottom)
153+ }
154+ } ) {
155+ Image ( systemName: " arrow.down " )
156+ . padding ( 4 )
157+ . background {
158+ Circle ( )
159+ . fill ( . thickMaterial)
160+ . shadow ( color: . black. opacity ( 0.2 ) , radius: 2 )
161+ }
162+ . overlay {
163+ Circle ( ) . stroke ( Color ( nsColor: . separatorColor) , lineWidth: 1 )
164+ }
165+ . foregroundStyle ( . secondary)
166+ . padding ( 4 )
167+ }
168+ . keyboardShortcut ( . downArrow, modifiers: [ . command] )
169+ . opacity ( isScrollToBottomButtonDisplayed ? 1 : 0 )
170+ . buttonStyle ( . plain)
171+ }
172+
173+ struct PinToBottomHandler : View {
174+ let chat : StoreOf < Chat >
175+ @Binding var pinnedToBottom : Bool
176+ let scrollToBottom : ( ) -> Void
177+
178+ @State var isInitialLoad = true
179+
180+ struct PinToBottomRelatedState : Equatable {
181+ var isReceivingMessage : Bool
182+ var lastMessage : ChatMessage ?
183+ }
184+
185+ var body : some View {
186+ WithViewStore ( chat, observe: {
187+ PinToBottomRelatedState (
188+ isReceivingMessage: $0. isReceivingMessage,
189+ lastMessage: $0. history. last
190+ )
191+ } ) { viewStore in
192+ EmptyView ( )
193+ . onChange ( of: viewStore. state. isReceivingMessage) { isReceiving in
194+ if isReceiving {
195+ pinnedToBottom = true
196+ }
197+ }
198+ . onChange ( of: viewStore. state. lastMessage) { _ in
199+ if pinnedToBottom || isInitialLoad {
200+ if isInitialLoad {
201+ isInitialLoad = false
202+ }
203+ scrollToBottom ( )
204+ }
205+ }
206+ }
160207 }
161208 }
162209}
0 commit comments