From b2f64194cad09bc768acbc15c6fecee3f9b848a8 Mon Sep 17 00:00:00 2001 From: graycreate Date: Mon, 29 Dec 2025 09:02:45 +0800 Subject: [PATCH] feat: optimize FeedDetailPage list with opacity-based fade-in MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Use opacity animation instead of hiding/showing sections: - Sections remain in layout (no height jumps) - Fade in via opacity 0->1 when ready - Postscripts fade in when content is ready - Replies fade in 150ms after content - Faster easeIn animation (0.15s) This avoids layout recalculation and position jumping. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- V2er/View/FeedDetail/FeedDetailPage.swift | 57 +++++++++++++---------- 1 file changed, 32 insertions(+), 25 deletions(-) diff --git a/V2er/View/FeedDetail/FeedDetailPage.swift b/V2er/View/FeedDetail/FeedDetailPage.swift index a2d4738..1cc5d21 100644 --- a/V2er/View/FeedDetail/FeedDetailPage.swift +++ b/V2er/View/FeedDetail/FeedDetailPage.swift @@ -44,6 +44,7 @@ struct FeedDetailPage: StateView, KeyboardReadable, InstanceIdentifiable { @State var isKeyboardVisiable = false @State private var isLoadingMore = false @State private var contentReady = false + @State private var repliesReady = false @FocusState private var replyIsFocused: Bool var initData: FeedInfo.Item? = nil var id: String @@ -206,8 +207,12 @@ struct FeedDetailPage: StateView, KeyboardReadable, InstanceIdentifiable { // Content Section if !isContentEmpty { NewsContentView(state.model.contentInfo) { - withAnimation { - contentReady = true + contentReady = true + // Show replies after a short delay for smoother transition + DispatchQueue.main.asyncAfter(deadline: .now() + 0.15) { + withAnimation(.easeInOut(duration: 0.2)) { + repliesReady = true + } } } .padding(.horizontal, 10) @@ -216,31 +221,31 @@ struct FeedDetailPage: StateView, KeyboardReadable, InstanceIdentifiable { .listRowBackground(Color.itemBg) } - // Show postscripts and replies only after content is ready - if contentReady || isContentEmpty { - // Postscripts Section (附言) - ForEach(state.model.postscripts) { postscript in - PostscriptItemView(postscript: postscript) - .listRowInsets(EdgeInsets()) - .listRowSeparator(.hidden) - .listRowBackground(Color.itemBg) - } + // Postscripts Section (附言) - always in layout, fade in when ready + ForEach(state.model.postscripts) { postscript in + PostscriptItemView(postscript: postscript) + .listRowInsets(EdgeInsets()) + .listRowSeparator(.hidden) + .listRowBackground(Color.itemBg) + .opacity(contentReady || isContentEmpty ? 1 : 0) + } - // Reply Section Header with Sort Toggle - if !state.model.replyInfo.items.isEmpty { - replySectionHeader - .listRowInsets(EdgeInsets()) - .listRowSeparator(.hidden) - .listRowBackground(Color.itemBg) - } + // Reply Section Header with Sort Toggle + if !state.model.replyInfo.items.isEmpty { + replySectionHeader + .listRowInsets(EdgeInsets()) + .listRowSeparator(.hidden) + .listRowBackground(Color.itemBg) + .opacity(repliesReady || isContentEmpty ? 1 : 0) + } - // Reply Section - ForEach(sortedReplies, id: \.floor) { item in - ReplyItemView(info: item, topicId: id) - .listRowInsets(EdgeInsets()) - .listRowSeparator(.hidden) - .listRowBackground(Color.itemBg) - } + // Reply Section - always in layout, fade in when ready + ForEach(sortedReplies, id: \.floor) { item in + ReplyItemView(info: item, topicId: id) + .listRowInsets(EdgeInsets()) + .listRowSeparator(.hidden) + .listRowBackground(Color.itemBg) + .opacity(repliesReady || isContentEmpty ? 1 : 0) } // Load More Indicator @@ -272,6 +277,8 @@ struct FeedDetailPage: StateView, KeyboardReadable, InstanceIdentifiable { .scrollContentBackground(.hidden) .background(Color.itemBg) .environment(\.defaultMinListRowHeight, 1) + .animation(.easeIn(duration: 0.15), value: contentReady) + .animation(.easeIn(duration: 0.15), value: repliesReady) .refreshable { await run(action: FeedDetailActions.FetchData.Start(id: instanceId, feedId: initData?.id)) }