Skip to content

Commit daf2adc

Browse files
graycreateclaude
andcommitted
fix: replace LazyVStack with List to fix bottom overscroll bounce
- Use SwiftUI List control instead of LazyVStack for FeedDetailPage - Fix disclosure indicators by using hidden NavigationLinks with state-based navigation - Update AuthorInfoView to use programmatic navigation pattern - Modify NavigationLinkModifider to prevent List row styling issues - Add isLoadingMore guard to prevent multiple pagination triggers - Clean up List styling with proper background and separator settings 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <[email protected]>
1 parent b7b5903 commit daf2adc

File tree

6 files changed

+132
-64
lines changed

6 files changed

+132
-64
lines changed

V2er/View/FeedDetail/AuthorInfoView.swift

Lines changed: 24 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -44,11 +44,16 @@ struct AuthorInfoView: View {
4444
return result
4545
}
4646

47+
@State private var navigateToTag = false
48+
@State private var navigateToUser = false
49+
4750
var body: some View {
4851
VStack(spacing: 0) {
4952
HStack(alignment: .top) {
50-
AvatarView(url: avatar, size: 38)
51-
.to { UserDetailPage(userId: data?.userName ?? .empty) }
53+
AvatarView(url: avatar, size: 38)
54+
.onTapGesture {
55+
navigateToUser = true
56+
}
5257
VStack(alignment: .leading, spacing: 5) {
5358
Text(userName)
5459
.lineLimit(1)
@@ -57,15 +62,16 @@ struct AuthorInfoView: View {
5762
.font(.caption2)
5863
}
5964
Spacer()
60-
NavigationLink(destination: TagDetailPage(tag: tag, tagId: tagId)) {
61-
Text(tag)
62-
.font(.footnote)
63-
.foregroundColor(.primaryText)
64-
.lineLimit(1)
65-
.padding(.horizontal, 14)
66-
.padding(.vertical, 8)
67-
.background(Color.lightGray)
68-
}
65+
Text(tag)
66+
.font(.footnote)
67+
.foregroundColor(.primaryText)
68+
.lineLimit(1)
69+
.padding(.horizontal, 14)
70+
.padding(.vertical, 8)
71+
.background(Color.lightGray)
72+
.onTapGesture {
73+
navigateToTag = true
74+
}
6975
}
7076
Text(title)
7177
.font(.headline)
@@ -76,6 +82,13 @@ struct AuthorInfoView: View {
7682
}
7783
.padding(10)
7884
.background(Color.itemBg)
85+
.background(
86+
Group {
87+
NavigationLink(destination: TagDetailPage(tag: tag, tagId: tagId), isActive: $navigateToTag) { EmptyView() }
88+
NavigationLink(destination: UserDetailPage(userId: data?.userName ?? .empty), isActive: $navigateToUser) { EmptyView() }
89+
}
90+
.hidden()
91+
)
7992
}
8093
}
8194

V2er/View/FeedDetail/FeedDetailPage.swift

Lines changed: 72 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ struct FeedDetailPage: StateView, KeyboardReadable, InstanceIdentifiable {
2929
}
3030
@State var hideTitleViews = true
3131
@State var isKeyboardVisiable = false
32+
@State private var isLoadingMore = false
3233
@FocusState private var replyIsFocused: Bool
3334
var initData: FeedInfo.Item? = nil
3435
var id: String
@@ -68,37 +69,15 @@ struct FeedDetailPage: StateView, KeyboardReadable, InstanceIdentifiable {
6869

6970
@ViewBuilder
7071
private var contentView: some View {
71-
VStack (spacing: 0) {
72-
VStack(spacing: 0) {
73-
AuthorInfoView(initData: initData, data: state.model.headerInfo)
74-
if !isContentEmpty {
75-
NewsContentView(state.model.contentInfo, rendered: $rendered)
76-
.padding(.horizontal, 10)
77-
}
78-
replayListView
79-
.padding(.top, 8)
80-
}
81-
.background(showProgressView ? .clear : Color.itemBg)
82-
.updatable(autoRefresh: showProgressView, hasMoreData: state.hasMoreData) {
83-
await run(action: FeedDetailActions.FetchData.Start(id: instanceId, feedId: initData?.id))
84-
} loadMore: {
85-
await run(action: FeedDetailActions.LoadMore.Start(id: instanceId, feedId: initData?.id, willLoadPage: state.willLoadPage))
86-
} onScroll: { scrollY in
87-
withAnimation {
88-
hideTitleViews = !(scrollY <= -100)
89-
}
90-
}
91-
.onTapGesture {
92-
replyIsFocused = false
93-
}
72+
VStack(spacing: 0) {
73+
listContentView
9474
replyBar
9575
}
9676
.safeAreaInset(edge: .top, spacing: 0) {
9777
navBar
9878
}
9979
.ignoresSafeArea(.container)
10080
.navigationBarHidden(true)
101-
10281
.onChange(of: state.ignored) { ignored in
10382
if ignored {
10483
dismiss()
@@ -120,6 +99,75 @@ struct FeedDetailPage: StateView, KeyboardReadable, InstanceIdentifiable {
12099
}
121100
}
122101

102+
@ViewBuilder
103+
private var listContentView: some View {
104+
List {
105+
// Header Section
106+
AuthorInfoView(initData: initData, data: state.model.headerInfo)
107+
.listRowInsets(EdgeInsets())
108+
.listRowSeparator(.hidden)
109+
.listRowBackground(Color.itemBg)
110+
111+
// Content Section
112+
if !isContentEmpty {
113+
NewsContentView(state.model.contentInfo, rendered: $rendered)
114+
.padding(.horizontal, 10)
115+
.listRowInsets(EdgeInsets())
116+
.listRowSeparator(.hidden)
117+
.listRowBackground(Color.itemBg)
118+
}
119+
120+
// Reply Section
121+
ForEach(state.model.replyInfo.items) { item in
122+
ReplyItemView(info: item)
123+
.listRowInsets(EdgeInsets())
124+
.listRowSeparator(.hidden)
125+
.listRowBackground(Color.itemBg)
126+
}
127+
128+
// Load More Indicator
129+
if state.hasMoreData {
130+
HStack {
131+
Spacer()
132+
if isLoadingMore {
133+
ProgressView()
134+
}
135+
Spacer()
136+
}
137+
.frame(height: 50)
138+
.listRowInsets(EdgeInsets())
139+
.listRowSeparator(.hidden)
140+
.listRowBackground(Color.itemBg)
141+
.onAppear {
142+
guard !isLoadingMore else { return }
143+
isLoadingMore = true
144+
Task {
145+
await run(action: FeedDetailActions.LoadMore.Start(id: instanceId, feedId: initData?.id, willLoadPage: state.willLoadPage))
146+
await MainActor.run {
147+
isLoadingMore = false
148+
}
149+
}
150+
}
151+
}
152+
}
153+
.listStyle(.plain)
154+
.scrollContentBackground(.hidden)
155+
.background(Color.itemBg)
156+
.environment(\.defaultMinListRowHeight, 1)
157+
.refreshable {
158+
await run(action: FeedDetailActions.FetchData.Start(id: instanceId, feedId: initData?.id))
159+
}
160+
.overlay {
161+
if showProgressView {
162+
ProgressView()
163+
.scaleEffect(1.5)
164+
}
165+
}
166+
.onTapGesture {
167+
replyIsFocused = false
168+
}
169+
}
170+
123171
private var replyBar: some View {
124172
VStack(spacing: 0) {
125173
Divider()
@@ -260,14 +308,4 @@ struct FeedDetailPage: StateView, KeyboardReadable, InstanceIdentifiable {
260308
}
261309
.visualBlur()
262310
}
263-
264-
@ViewBuilder
265-
private var replayListView: some View {
266-
LazyVStack(spacing: 0) {
267-
ForEach(state.model.replyInfo.items) { item in
268-
ReplyItemView(info: item)
269-
}
270-
}
271-
}
272-
273311
}

V2er/View/ViewExtension.swift

Lines changed: 16 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -292,18 +292,25 @@ extension View {
292292
struct NavigationLinkModifider<Destination: View>: ViewModifier {
293293
var `if`: Binding<Bool>?
294294
let destination: Destination
295+
@State private var isActive = false
295296

296297
func body(content: Content) -> some View {
297-
if `if` == nil {
298-
NavigationLink {
299-
destination
300-
} label: {
301-
content
302-
}
298+
if let binding = `if` {
299+
content
300+
.background(
301+
NavigationLink(destination: destination, isActive: binding) { EmptyView() }
302+
.hidden()
303+
)
303304
} else {
304-
NavigationLink(destination: destination, isActive: `if`!) {
305-
EmptyView()
306-
}
305+
content
306+
.contentShape(Rectangle())
307+
.onTapGesture {
308+
isActive = true
309+
}
310+
.background(
311+
NavigationLink(destination: destination, isActive: $isActive) { EmptyView() }
312+
.hidden()
313+
)
307314
}
308315
}
309316
}

V2er/View/Widget/Updatable/HeadIndicatorView.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ struct HeadIndicatorView: View {
1818
@State private var animatedOnlineCount: Int = 0
1919

2020
var offset: CGFloat {
21-
return isRefreshing ? (0 - scrollY) : -height
21+
return isRefreshing ? 0 : -height
2222
}
2323

2424
init(threshold: CGFloat, progress: Binding<CGFloat>, scrollY: CGFloat, isRefreshing: Binding<Bool>, onlineStats: OnlineStatsInfo? = nil) {

V2er/View/Widget/Updatable/LoadmoreIndicatorView.swift

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,9 +26,10 @@ struct LoadmoreIndicatorView: View {
2626
} else if isLoading {
2727
ActivityIndicator()
2828
} else {
29-
// hide
29+
Color.clear
3030
}
3131
}
32+
.frame(height: 20)
3233
.padding()
3334
.greedyWidth()
3435
.background(Color.itemBg)

V2er/View/Widget/Updatable/UpdatableView.swift

Lines changed: 17 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,7 @@ struct UpdatableView<Content: View>: View {
7070
}
7171
}
7272
}
73-
.alignmentGuide(.top, computeValue: { d in (self.isRefreshing ? (-self.threshold + scrollY) : 0.0) })
73+
.alignmentGuide(.top, computeValue: { d in (self.isRefreshing ? -self.threshold : 0.0) })
7474
if refreshable {
7575
HeadIndicatorView(threshold: threshold, progress: $progress, scrollY: scrollY, isRefreshing: $isRefreshing, onlineStats: onlineStats)
7676
}
@@ -110,16 +110,25 @@ struct UpdatableView<Content: View>: View {
110110
}
111111

112112
private func onScroll(point: CGPoint) {
113-
scrollY = point.y
113+
let newScrollY = point.y
114+
115+
// Detect bottom overscroll - skip state updates to prevent LazyVStack jumping
116+
let isBottomOverscroll = boundsDelta > 0 && newScrollY < -boundsDelta - 5
117+
if isBottomOverscroll {
118+
lastScrollY = newScrollY
119+
return
120+
}
121+
122+
scrollY = newScrollY
114123
onScroll?(scrollY)
115124
// log("scrollY: \(scrollY), lastScrollY: \(lastScrollY), isRefreshing: \(isRefreshing), boundsDelta:\(boundsDelta)")
116125
progress = min(1, max(scrollY / threshold, 0))
117-
126+
118127
if progress == 1 && scrollY > lastScrollY && !hapticed {
119128
hapticed = true
120129
hapticFeedback(.soft)
121130
}
122-
131+
123132
if refreshable && !isRefreshing
124133
&& scrollY <= threshold
125134
&& lastScrollY > threshold {
@@ -128,7 +137,7 @@ struct UpdatableView<Content: View>: View {
128137
// Record whether online stats existed before refresh
129138
let hadOnlineStatsBefore = onlineStats != nil
130139
previousOnlineCount = onlineStats?.onlineCount
131-
140+
132141
Task {
133142
await onRefresh?()
134143
// Minimum 800ms delay for refresh animation, 1000ms if online stats exist
@@ -141,7 +150,7 @@ struct UpdatableView<Content: View>: View {
141150
}
142151
}
143152
}
144-
153+
145154
if loadMoreable
146155
&& state.hasMoreData
147156
&& boundsDelta >= 0
@@ -156,8 +165,8 @@ struct UpdatableView<Content: View>: View {
156165
}
157166
}
158167
}
159-
160-
lastScrollY = scrollY
168+
169+
lastScrollY = newScrollY
161170
}
162171

163172
}

0 commit comments

Comments
 (0)