Skip to content

Commit 4974b20

Browse files
committed
Merge branch 'feature/chat-panel-improvement' into develop
2 parents 7dc23dc + ab97bad commit 4974b20

1 file changed

Lines changed: 94 additions & 47 deletions

File tree

Core/Sources/ChatGPTChatTab/ChatPanel.swift

Lines changed: 94 additions & 47 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
@@ -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

Comments
 (0)