Skip to content

Commit 9498218

Browse files
committed
feat: Update chat scroll behavior for improved user experience
- Automatically scrolls to bottom when a new message is added, ensuring real-time visibility. - Maintains anchor-based detection to avoid hijacking user scroll when they intentionally scroll up. - Introduces a short layout delay to prevent flickering, preserving smoothness when adding messages.
1 parent 1f2e065 commit 9498218

File tree

2 files changed

+109
-43
lines changed

2 files changed

+109
-43
lines changed

Amazon Bedrock Client for Mac/Views/ChatView.swift

+108-42
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,10 @@
77
import SwiftUI
88
import Combine
99

10-
struct ScrollViewOffsetPreferenceKey: PreferenceKey {
10+
/// A preference key that stores the vertical position of the bottom anchor in global space.
11+
struct BottomAnchorPreferenceKey: PreferenceKey {
1112
typealias Value = CGFloat
12-
static var defaultValue = CGFloat.zero
13+
static var defaultValue: CGFloat = 0
1314
static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
1415
value = nextValue()
1516
}
@@ -20,12 +21,18 @@ struct ChatView: View {
2021
@StateObject private var sharedImageDataSource = SharedImageDataSource()
2122
@ObservedObject var backendModel: BackendModel
2223

23-
@State private var isUserScrolling = false
24-
@State private var previousOffset: CGFloat = 0
24+
/// Tracks if the user is currently at the bottom of the message list
25+
@State private var isAtBottom: Bool = true
2526

2627
init(chatId: String, backendModel: BackendModel) {
2728
let sharedImageDataSource = SharedImageDataSource()
28-
_viewModel = StateObject(wrappedValue: ChatViewModel(chatId: chatId, backendModel: backendModel, sharedImageDataSource: sharedImageDataSource))
29+
_viewModel = StateObject(
30+
wrappedValue: ChatViewModel(
31+
chatId: chatId,
32+
backendModel: backendModel,
33+
sharedImageDataSource: sharedImageDataSource
34+
)
35+
)
2936
_sharedImageDataSource = StateObject(wrappedValue: sharedImageDataSource)
3037
self._backendModel = ObservedObject(wrappedValue: backendModel)
3138
}
@@ -44,54 +51,117 @@ struct ChatView: View {
4451
.onAppear(perform: viewModel.loadInitialData)
4552
}
4653

54+
/// Displays a placeholder if there are no messages.
4755
private var placeholderView: some View {
4856
VStack(alignment: .center) {
4957
if viewModel.messages.isEmpty {
5058
Spacer()
51-
Text(viewModel.selectedPlaceholder).font(.title2).foregroundColor(.secondary)
59+
Text(viewModel.selectedPlaceholder)
60+
.font(.title2)
61+
.foregroundColor(.secondary)
5262
}
5363
}
5464
.textSelection(.disabled)
5565
}
5666

67+
/// Main scrollable message area with anchor-based detection of the bottom.
5768
private var messageScrollView: some View {
58-
ScrollViewReader { proxy in
59-
ScrollView {
60-
VStack(spacing: 2) {
61-
ForEach(viewModel.messages) { message in
62-
MessageView(message: message)
63-
.id(message.id)
64-
.frame(maxWidth: .infinity)
69+
GeometryReader { outerGeo in
70+
ScrollViewReader { proxy in
71+
ZStack {
72+
ScrollView {
73+
VStack(spacing: 2) {
74+
ForEach(viewModel.messages) { message in
75+
MessageView(message: message)
76+
.id(message.id)
77+
.frame(maxWidth: .infinity)
78+
}
79+
Color.clear
80+
.frame(height: 1)
81+
.id("Bottom")
82+
.anchorPreference(
83+
key: BottomAnchorPreferenceKey.self,
84+
value: .bottom
85+
) { anchor in
86+
// Return the y-coordinate of the bottom anchor in global space
87+
outerGeo[anchor].y
88+
}
89+
}
90+
.padding()
6591
}
66-
// 맨 아래에 보이지 않는 뷰 추가
67-
Color.clear
68-
.frame(height: 1)
69-
.id("Bottom")
70-
.onAppear {
71-
isUserScrolling = false
92+
.onChange(of: viewModel.messages) { _ in
93+
// If the user was at bottom, wait briefly for layout and scroll down again
94+
if isAtBottom {
95+
Task {
96+
try? await Task.sleep(nanoseconds: 50_000_000) // 0.05s
97+
withAnimation {
98+
proxy.scrollTo("Bottom", anchor: .bottom)
99+
}
100+
}
72101
}
73-
.onDisappear {
74-
isUserScrolling = true
102+
}
103+
// Scroll to bottom whenever the count of messages changes
104+
.onChange(of: viewModel.messages.count) { _ in
105+
withAnimation {
106+
proxy.scrollTo("Bottom", anchor: .bottom)
75107
}
76-
}
77-
.padding()
78-
}
79-
.onChange(of: viewModel.messages) { _ in
80-
if !isUserScrolling {
81-
withAnimation {
108+
}
109+
110+
.task {
111+
// Initial delay before scrolling down to let content load
112+
try? await Task.sleep(nanoseconds: 500_000_000) // 0.5s
82113
proxy.scrollTo("Bottom", anchor: .bottom)
114+
isAtBottom = true
115+
}
116+
117+
// A floating scroll-to-bottom button that appears if the user isn't at the bottom
118+
if !isAtBottom {
119+
VStack {
120+
Spacer()
121+
HStack {
122+
Spacer()
123+
Button(action: {
124+
withAnimation {
125+
proxy.scrollTo("Bottom", anchor: .bottom)
126+
isAtBottom = true
127+
}
128+
}) {
129+
Image(systemName: "arrow.down")
130+
.font(.system(size: 16, weight: .medium))
131+
.foregroundColor(.blue)
132+
.frame(width: 36, height: 36)
133+
.background(
134+
Circle()
135+
.fill(Color.white)
136+
.shadow(color: .black.opacity(0.1), radius: 4, x: 0, y: 2)
137+
)
138+
}
139+
.padding(.horizontal, (outerGeo.size.width - 36) / 2)
140+
.padding(.bottom, 16)
141+
.buttonStyle(PlainButtonStyle())
142+
}
143+
}
144+
.transition(.scale.combined(with: .opacity))
83145
}
84146
}
85-
}
86-
.task {
87-
// 초기 로드 시 맨 아래로 스크롤
88-
try? await Task.sleep(nanoseconds: 300_000_000) // 0.3초 대기
89-
proxy.scrollTo("Bottom", anchor: .bottom)
147+
.onPreferenceChange(BottomAnchorPreferenceKey.self) { bottomY in
148+
// Compare bottomY to the visible height of the container.
149+
// If the bottom anchor is within a threshold of the container height, the user is at the bottom.
150+
let visibleHeight = outerGeo.size.height
151+
let threshold: CGFloat = 50
152+
if bottomY <= visibleHeight + threshold {
153+
isAtBottom = true
154+
} else {
155+
isAtBottom = false
156+
}
157+
}
158+
159+
90160
}
91161
}
92162
}
93163

94-
164+
/// The message input area at the bottom.
95165
private var messageBarView: some View {
96166
MessageBarView(
97167
chatID: viewModel.chatId,
@@ -103,6 +173,7 @@ struct ChatView: View {
103173
)
104174
}
105175

176+
/// A toggle to enable or disable streaming mode.
106177
private var streamingToggle: some View {
107178
HStack {
108179
Text("Streaming")
@@ -112,15 +183,10 @@ struct ChatView: View {
112183
.labelsHidden()
113184
}
114185
.onChange(of: viewModel.isStreamingEnabled) { newValue in
115-
UserDefaults.standard.set(newValue, forKey: "isStreamingEnabled_\(viewModel.chatId)")
186+
UserDefaults.standard.set(
187+
newValue,
188+
forKey: "isStreamingEnabled_\(viewModel.chatId)"
189+
)
116190
}
117191
}
118192
}
119-
120-
struct ViewOffsetKey: PreferenceKey {
121-
typealias Value = CGFloat
122-
static var defaultValue: CGFloat = 0
123-
static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
124-
value = nextValue()
125-
}
126-
}

Amazon Bedrock Client for Mac/Views/MessageBarView.swift

+1-1
Original file line numberDiff line numberDiff line change
@@ -96,7 +96,7 @@ struct MessageBarView: View {
9696
private var inputArea: some View {
9797
FirstResponderTextView(
9898
text: $userInput,
99-
isDisabled: $isLoading,
99+
isDisabled: .constant(false),
100100
calculatedHeight: $calculatedHeight,
101101
onCommit: { if !userInput.isEmpty {
102102
Task { await sendMessage() }

0 commit comments

Comments
 (0)