@@ -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
@@ -67,6 +67,9 @@ struct ChatPanelMessages: View {
6767
6868 Spacer ( minLength: 12 )
6969 . id ( bottomID)
70+ . onAppear {
71+ proxy. scrollTo ( bottomID, anchor: . bottom)
72+ }
7073 . task {
7174 proxy. scrollTo ( bottomID, anchor: . bottom)
7275 }
@@ -93,16 +96,23 @@ struct ChatPanelMessages: View {
9396 . listStyle ( . plain)
9497 . coordinateSpace ( name: scrollSpace)
9598 . onPreferenceChange ( ListHeightPreferenceKey . self) { value in
96- Task { @MainActor in
97- listHeight = value
98- updatePinningState ( )
99- }
99+ listHeight = value
100+ updatePinningState ( )
100101 }
101102 . onPreferenceChange ( ScrollViewOffsetPreferenceKey . self) { value in
102- Task { @MainActor in
103- scrollOffset = value
104- updatePinningState ( )
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
105112 }
113+
114+ scrollOffset = value
115+ updatePinningState ( )
106116 }
107117 . overlay ( alignment: . bottom) {
108118 WithViewStore ( chat, observe: \. isReceivingMessage) { viewStore in
@@ -117,49 +127,82 @@ struct ChatPanelMessages: View {
117127 . overlay ( alignment: . bottomTrailing) {
118128 scrollToBottomButton ( proxy: proxy)
119129 }
130+ . background {
131+ PinToBottomHandler ( chat: chat, pinnedToBottom: $isPinnedToBottom) {
132+ proxy. scrollTo ( bottomID, anchor: . bottom)
133+ }
134+ }
120135 }
121136 }
122137 }
123138
139+ @MainActor
124140 func updatePinningState( ) {
125- if scrollOffset > listHeight + 30 + 100 || scrollOffset <= 0 {
126- pinnedToBottom = false
127- } else {
128- pinnedToBottom = true
141+ // where does the 32 come from?
142+ withAnimation {
143+ isScrollToBottomButtonDisplayed = scrollOffset > listHeight + 32 + 20
144+ || scrollOffset <= 0
129145 }
130146 }
131147
132148 @ViewBuilder
133149 func scrollToBottomButton( proxy: ScrollViewProxy ) -> some View {
134- WithViewStore ( chat, observe: \. history. last) { viewStore in
135- Button ( action: {
136- withAnimation ( . easeInOut( duration: 0.1 ) ) {
137- proxy. scrollTo ( bottomID, anchor: . bottom)
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 )
138161 }
139- } ) {
140- Image ( systemName: " arrow.down " )
141- . padding ( 4 )
142- . background {
143- Circle ( )
144- . fill ( . thickMaterial)
145- . shadow ( color: . black. opacity ( 0.2 ) , radius: 2 )
146- }
147- . overlay {
148- Circle ( ) . stroke ( Color ( nsColor: . separatorColor) , lineWidth: 1 )
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+ }
149197 }
150- . foregroundStyle ( . secondary)
151- . padding ( 4 )
152- }
153- . keyboardShortcut ( . downArrow, modifiers: [ . command] )
154- . opacity ( pinnedToBottom ? 0 : 1 )
155- . buttonStyle ( . plain)
156- . onChange ( of: viewStore. state) { _ in
157- if pinnedToBottom || isInitialLoad {
158- if isInitialLoad {
159- isInitialLoad = false
198+ . onChange ( of: viewStore. state. lastMessage) { _ in
199+ if pinnedToBottom || isInitialLoad {
200+ if isInitialLoad {
201+ isInitialLoad = false
202+ }
203+ scrollToBottom ( )
204+ }
160205 }
161- proxy. scrollTo ( bottomID, anchor: . bottom)
162- }
163206 }
164207 }
165208 }
0 commit comments