7
7
import SwiftUI
8
8
import Combine
9
9
10
- struct ScrollViewOffsetPreferenceKey : PreferenceKey {
10
+ /// A preference key that stores the vertical position of the bottom anchor in global space.
11
+ struct BottomAnchorPreferenceKey : PreferenceKey {
11
12
typealias Value = CGFloat
12
- static var defaultValue = CGFloat . zero
13
+ static var defaultValue : CGFloat = 0
13
14
static func reduce( value: inout CGFloat , nextValue: ( ) -> CGFloat ) {
14
15
value = nextValue ( )
15
16
}
@@ -20,12 +21,18 @@ struct ChatView: View {
20
21
@StateObject private var sharedImageDataSource = SharedImageDataSource ( )
21
22
@ObservedObject var backendModel : BackendModel
22
23
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
25
26
26
27
init ( chatId: String , backendModel: BackendModel ) {
27
28
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
+ )
29
36
_sharedImageDataSource = StateObject ( wrappedValue: sharedImageDataSource)
30
37
self . _backendModel = ObservedObject ( wrappedValue: backendModel)
31
38
}
@@ -44,54 +51,117 @@ struct ChatView: View {
44
51
. onAppear ( perform: viewModel. loadInitialData)
45
52
}
46
53
54
+ /// Displays a placeholder if there are no messages.
47
55
private var placeholderView : some View {
48
56
VStack ( alignment: . center) {
49
57
if viewModel. messages. isEmpty {
50
58
Spacer ( )
51
- Text ( viewModel. selectedPlaceholder) . font ( . title2) . foregroundColor ( . secondary)
59
+ Text ( viewModel. selectedPlaceholder)
60
+ . font ( . title2)
61
+ . foregroundColor ( . secondary)
52
62
}
53
63
}
54
64
. textSelection ( . disabled)
55
65
}
56
66
67
+ /// Main scrollable message area with anchor-based detection of the bottom.
57
68
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 ( )
65
91
}
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
+ }
72
101
}
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)
75
107
}
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
82
113
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) )
83
145
}
84
146
}
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
+
90
160
}
91
161
}
92
162
}
93
163
94
-
164
+ /// The message input area at the bottom.
95
165
private var messageBarView : some View {
96
166
MessageBarView (
97
167
chatID: viewModel. chatId,
@@ -103,6 +173,7 @@ struct ChatView: View {
103
173
)
104
174
}
105
175
176
+ /// A toggle to enable or disable streaming mode.
106
177
private var streamingToggle : some View {
107
178
HStack {
108
179
Text ( " Streaming " )
@@ -112,15 +183,10 @@ struct ChatView: View {
112
183
. labelsHidden ( )
113
184
}
114
185
. 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
+ )
116
190
}
117
191
}
118
192
}
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
- }
0 commit comments