Skip to content

Commit ab97bad

Browse files
committed
Tweak pin to bottom behavior of chat panel
1 parent 18a5b34 commit ab97bad

1 file changed

Lines changed: 82 additions & 39 deletions

File tree

Core/Sources/ChatGPTChatTab/ChatPanel.swift

Lines changed: 82 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -40,12 +40,12 @@ private struct ListHeightPreferenceKey: PreferenceKey {
4040

4141
struct 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

Comments
 (0)