From c6fc886ef364452174b21fc1e2843cb2a17d81e5 Mon Sep 17 00:00:00 2001 From: pgs666 <74764545+pgs666@users.noreply.github.com> Date: Tue, 2 Jun 2026 00:48:04 +0800 Subject: [PATCH 001/216] feat(video): add swipe pager below player --- iosApp/VideoDetailView.swift | 333 ++++++++++++++++++++++------------- 1 file changed, 209 insertions(+), 124 deletions(-) diff --git a/iosApp/VideoDetailView.swift b/iosApp/VideoDetailView.swift index 765a61ad..20527cc4 100644 --- a/iosApp/VideoDetailView.swift +++ b/iosApp/VideoDetailView.swift @@ -15,17 +15,15 @@ struct VideoDetailView: View { /// player at full 16:9 height while playing — only paused state lets the /// scroll-driven shrink behaviour engage. @State private var isPlayerPlaying = false - /// True iff the user is currently driving the bottom ScrollView with - /// a finger (or inertial scroll is still running). Used to gate - /// `onScrollGeometryChange` so that phantom contentOffset reports - /// caused by unrelated layout passes (e.g. tapping to show - /// controls inside the player) don't shrink/grow the player area. - @State private var isUserScrollingBottom = false /// Vertical scroll offset of the inline content area below the player, /// measured from the natural top (>= 0). When the user scrolls UP (so /// the offset grows), and the player is paused, the player shrinks /// proportionally — Bilibili-style "follow finger" collapse. @State private var bottomScrollOffset: CGFloat = 0 + /// Each tab owns an independent vertical ScrollView below the player, so + /// switching between intro / comments preserves their separate scroll + /// positions instead of sharing one outer ScrollView offset. + @State private var bottomScrollOffsetsByTab: [VideoPageTab: CGFloat] = [:] /// Natural size of the loaded video (reported by KSPlayer the first time /// the underlying player gets a non-zero presentation size). Used to /// decide whether fullscreen should lock the device to portrait or @@ -302,136 +300,79 @@ struct VideoDetailView: View { } private func belowPlayerScroll(snapshot: VideoDetailScreenSnapshot, showsRelated: Bool) -> some View { - let scrollContent = ScrollView { - // iOS 16/17 fallback: 0-height GR sentinel as the first child of - // the ScrollView. minY in the named coordinate space tracks the - // scroll content's vertical movement against the ScrollView's - // viewport: scroll up 100pt → sentinel.minY becomes -100. We - // negate so the published value grows positive. - // On iOS 18+ this co-exists with .onScrollGeometryChange below; - // whichever fires first wins, both produce the same result. + VStack(spacing: 0) { + Picker("Content", selection: $selectedTab) { + ForEach(VideoPageTab.allCases) { tab in + Text(tab.title).tag(tab) + } + } + .pickerStyle(.segmented) + .padding(.horizontal, 16) + .padding(.vertical, 8) + .background(.background) + + VideoDetailTabPager(selectedTab: $selectedTab) { + tabScroll(.introduction) { + AndroidStyleIntroduction( + snapshot: snapshot, + videoFeature: videoFeature, + commentFeature: commentFeature, + isArtistActionRunning: viewModel.isActionRunning("artistSubscription"), + onToggleArtistSubscription: { viewModel.toggleArtistSubscription(snapshot: snapshot) }, + onToggleFavorite: { viewModel.toggleFavorite(snapshot: snapshot) }, + onToggleWatchLater: { viewModel.toggleWatchLater(snapshot: snapshot) }, + onSetMyListItem: { item, isSelected in viewModel.setMyListItem(snapshot: snapshot, item: item, isSelected: isSelected) }, + onShowMessage: { viewModel.showActionMessage($0) }, + showsRelated: showsRelated + ) + .padding(.top, 16) + } + } comments: { + tabScroll(.comments) { + CommentView(videoCode: videoCode, commentFeature: commentFeature) + .padding(.top, 16) + } + } + .frame(maxHeight: .infinity) + } + .frame(maxHeight: .infinity) + .onPreferenceChange(BottomScrollOffsetPreferenceKey.self) { offsets in + for (tab, offset) in offsets { + bottomScrollOffsetsByTab[tab] = max(0, offset) + } + bottomScrollOffset = bottomScrollOffsetsByTab[selectedTab] ?? 0 + } + .onValueChange(of: selectedTab) { newTab in + bottomScrollOffset = bottomScrollOffsetsByTab[newTab] ?? 0 + } + } + + private func tabScroll( + _ tab: VideoPageTab, + @ViewBuilder content: @escaping () -> Content + ) -> some View { + ScrollView { GeometryReader { proxy in Color.clear.preference( key: BottomScrollOffsetPreferenceKey.self, - value: -proxy.frame(in: .named("bottomScroll")).minY + value: [tab: -proxy.frame(in: .named(tab.scrollCoordinateSpaceName)).minY] ) } .frame(height: 0) - LazyVStack(alignment: .leading, spacing: 16, pinnedViews: [.sectionHeaders]) { - Section { - // Wrap the per-tab content in a Group with .id(selectedTab) - // so SwiftUI treats the two branches as distinct view - // identities. Without this, switching introduction → - // comments inside a LazyVStack section sometimes - // recycles the row and leaves contentSize stale, which - // showed as a blank page until the user nudged the - // ScrollView (re-laying out and refreshing the size). - Group { - switch selectedTab { - case .introduction: - AndroidStyleIntroduction( - snapshot: snapshot, - videoFeature: videoFeature, - commentFeature: commentFeature, - isArtistActionRunning: viewModel.isActionRunning("artistSubscription"), - onToggleArtistSubscription: { viewModel.toggleArtistSubscription(snapshot: snapshot) }, - onToggleFavorite: { viewModel.toggleFavorite(snapshot: snapshot) }, - onToggleWatchLater: { viewModel.toggleWatchLater(snapshot: snapshot) }, - onSetMyListItem: { item, isSelected in viewModel.setMyListItem(snapshot: snapshot, item: item, isSelected: isSelected) }, - onShowMessage: { viewModel.showActionMessage($0) }, - showsRelated: showsRelated - ) - case .comments: - CommentView(videoCode: videoCode, commentFeature: commentFeature) - } - } - .id(selectedTab) - // Horizontal swipe to switch between introduction / - // comments. Use plain .gesture (NOT simultaneous / - // highPriority) so any nested horizontal-scroll - // ScrollView (系列影片 / 相关影片 grids) gets to - // claim the gesture first; SwiftUI only falls - // through to this DragGesture for the area above - // the horizontal strips. We require horizontal - // dominance + 60pt minimum, AND a 24pt left/right - // start-edge deadzone so the iOS swipe-back gesture - // (and any future right-edge system gesture) wins. - .gesture( - DragGesture(minimumDistance: 30, coordinateSpace: .local) - .onEnded { value in - let dx = value.translation.width - let dy = value.translation.height - guard abs(dx) > abs(dy) * 1.5, abs(dx) > 60 else { return } - guard value.startLocation.x > 24 else { return } - withAnimation(.easeInOut(duration: 0.2)) { - if dx < 0, selectedTab == .introduction { - selectedTab = .comments - } else if dx > 0, selectedTab == .comments { - selectedTab = .introduction - } - } - } - ) - } header: { - Picker("Content", selection: $selectedTab) { - ForEach(VideoPageTab.allCases) { tab in - Text(tab.title).tag(tab) - } - } - .pickerStyle(.segmented) - .padding(.horizontal, 16) - .padding(.vertical, 8) - .background(.background) - } - } + content() .padding(.bottom, 24) } - .coordinateSpace(name: "bottomScroll") - .onPreferenceChange(BottomScrollOffsetPreferenceKey.self) { value in - bottomScrollOffset = max(0, value) - } - - // iOS 18+ explicit scroll-offset reporting via Apple's public API. - // More reliable than GeometryReader-on-Lazy* containers, which can - // sometimes skip preference updates during inertial scrolling. - if #available(iOS 18.0, *) { - return AnyView( - scrollContent - .onScrollPhaseChange { _, newPhase in - // .idle and .animating are "not currently being - // driven by the user". We only treat .tracking - // (finger down + moving) and .decelerating - // (inertial after release) and .interacting as - // legitimate scroll signals. This prevents tap- - // -to-show-controls inside the player from - // accidentally pulsing bottomScrollOffset and - // resizing the player area. - isUserScrollingBottom = (newPhase == .tracking - || newPhase == .decelerating - || newPhase == .interacting) - } - .onScrollGeometryChange(for: CGFloat.self) { geom in - geom.contentOffset.y - } action: { _, newOffset in - guard isUserScrollingBottom else { return } - bottomScrollOffset = max(0, newOffset) - } - ) - } else { - return AnyView(scrollContent) - } + .coordinateSpace(name: tab.scrollCoordinateSpaceName) } } -/// Reports the inline-content ScrollView's vertical offset from its top so -/// the player area can shrink (B-station-style) when the user scrolls up -/// while paused. Reduce policy: keep the largest reported value of a single -/// pass — there's only one ScrollView publishing into this key. +/// Reports each tab-owned ScrollView's vertical offset from its top so the +/// player area can shrink (B-station-style) based only on the active tab. private struct BottomScrollOffsetPreferenceKey: PreferenceKey { - static var defaultValue: CGFloat = 0 - static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) { - value = nextValue() + static var defaultValue: [VideoPageTab: CGFloat] = [:] + static func reduce(value: inout [VideoPageTab: CGFloat], nextValue: () -> [VideoPageTab: CGFloat]) { + value.merge(nextValue()) { _, new in new } } } @@ -441,6 +382,21 @@ private enum VideoPageTab: String, CaseIterable, Identifiable { var id: String { rawValue } + var pageIndex: Int { + switch self { + case .introduction: return 0 + case .comments: return 1 + } + } + + static func page(at index: Int) -> VideoPageTab { + index <= 0 ? .introduction : .comments + } + + var scrollCoordinateSpaceName: String { + "bottomScroll-\(rawValue)" + } + var title: String { switch self { case .introduction: @@ -451,6 +407,135 @@ private enum VideoPageTab: String, CaseIterable, Identifiable { } } +private struct VideoDetailTabPager: View { + @Binding var selectedTab: VideoPageTab + let introduction: () -> Introduction + let comments: () -> Comments + + @State private var dragTranslation: CGFloat = 0 + + init( + selectedTab: Binding, + @ViewBuilder introduction: @escaping () -> Introduction, + @ViewBuilder comments: @escaping () -> Comments + ) { + _selectedTab = selectedTab + self.introduction = introduction + self.comments = comments + } + + var body: some View { + VideoDetailPagerLayout( + selectedIndex: selectedTab.pageIndex, + dragTranslation: dragTranslation + ) { + introduction() + comments() + } + .animation(.interactiveSpring(response: 0.28, dampingFraction: 0.86), value: selectedTab) + .animation(.interactiveSpring(response: 0.24, dampingFraction: 0.9), value: dragTranslation) + .contentShape(Rectangle()) + .clipped() + .gesture(horizontalPagingGesture) + } + + private var horizontalPagingGesture: some Gesture { + DragGesture(minimumDistance: 18, coordinateSpace: .local) + .onChanged { value in + guard isHorizontalPagingDrag(value) else { + dragTranslation = 0 + return + } + dragTranslation = rubberBandedTranslation(value.translation.width) + } + .onEnded { value in + defer { + withAnimation(.interactiveSpring(response: 0.28, dampingFraction: 0.86)) { + dragTranslation = 0 + } + } + + guard isHorizontalPagingDrag(value) else { return } + let threshold: CGFloat = 72 + let projected = value.predictedEndTranslation.width + let currentIndex = selectedTab.pageIndex + let targetIndex: Int + + if projected < -threshold { + targetIndex = min(currentIndex + 1, VideoPageTab.allCases.count - 1) + } else if projected > threshold { + targetIndex = max(currentIndex - 1, 0) + } else { + targetIndex = currentIndex + } + + guard targetIndex != currentIndex else { return } + withAnimation(.interactiveSpring(response: 0.28, dampingFraction: 0.86)) { + selectedTab = .page(at: targetIndex) + } + } + } + + private func isHorizontalPagingDrag(_ value: DragGesture.Value) -> Bool { + let dx = value.translation.width + let dy = value.translation.height + guard value.startLocation.x > 24 else { return false } + return abs(dx) > 24 && abs(dx) > abs(dy) * 1.35 + } + + private func rubberBandedTranslation(_ translation: CGFloat) -> CGFloat { + let index = selectedTab.pageIndex + let isPullingBeforeFirst = index == 0 && translation > 0 + let isPullingAfterLast = index == VideoPageTab.allCases.count - 1 && translation < 0 + if isPullingBeforeFirst || isPullingAfterLast { + return translation * 0.28 + } + return translation + } +} + +/// Horizontally pages two tab bodies while reporting only ONE page's width to +/// the parent vertical ScrollView. A plain HStack exposes its full 2× width +/// during sizeThatFits, which can make nested lazy grids compute enormous +/// minor geometry and eventually abort on allocation failure. +private struct VideoDetailPagerLayout: Layout { + let selectedIndex: Int + let dragTranslation: CGFloat + + func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize { + let width = resolvedWidth(proposal: proposal, subviews: subviews) + let pageProposal = ProposedViewSize(width: width, height: proposal.height) + let height: CGFloat + if let proposedHeight = proposal.height, proposedHeight.isFinite, proposedHeight > 0 { + height = proposedHeight + } else { + height = subviews.map { $0.sizeThatFits(pageProposal).height }.max() ?? 0 + } + return CGSize(width: width, height: height) + } + + func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) { + let width = bounds.width + let pageProposal = ProposedViewSize(width: width, height: bounds.height) + let originX = bounds.minX - CGFloat(selectedIndex) * width + dragTranslation + + for (index, subview) in subviews.enumerated() { + subview.place( + at: CGPoint(x: originX + CGFloat(index) * width, y: bounds.minY), + anchor: .topLeading, + proposal: pageProposal + ) + } + } + + private func resolvedWidth(proposal: ProposedViewSize, subviews: Subviews) -> CGFloat { + if let width = proposal.width, width.isFinite, width > 0 { + return width + } + return subviews.map { $0.sizeThatFits(.unspecified).width }.max() ?? 0 + } +} + private struct AndroidStyleIntroduction: View { let snapshot: VideoDetailScreenSnapshot let videoFeature: VideoFeature @@ -464,7 +549,7 @@ private struct AndroidStyleIntroduction: View { let showsRelated: Bool var body: some View { - LazyVStack(alignment: .leading, spacing: 16) { + VStack(alignment: .leading, spacing: 16) { if let artist = snapshot.artist { ArtistCard( artist: artist, From d53015b0e187c42bf4f22e90ad37e181d53dbd6d Mon Sep 17 00:00:00 2001 From: pgs666 <74764545+pgs666@users.noreply.github.com> Date: Tue, 2 Jun 2026 02:17:25 +0800 Subject: [PATCH 002/216] fix(video): restore pager drag tracking --- iosApp/VideoDetailView.swift | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/iosApp/VideoDetailView.swift b/iosApp/VideoDetailView.swift index 20527cc4..e82308a6 100644 --- a/iosApp/VideoDetailView.swift +++ b/iosApp/VideoDetailView.swift @@ -211,13 +211,14 @@ struct VideoDetailView: View { HStack(alignment: .top, spacing: 0) { VStack(spacing: 0) { + let currentPlayerHeight = playerHeight( + panelWidth: leftWidth, + parentHeight: proxy.size.height + ) playerArea(snapshot: snapshot) .frame( width: leftWidth, - height: playerHeight( - panelWidth: leftWidth, - parentHeight: proxy.size.height - ) + height: currentPlayerHeight ) if !isPlayerFullscreen { @@ -225,6 +226,7 @@ struct VideoDetailView: View { // dedicated right sidebar already shows related videos — // duplicating them in the bottom scroll would be redundant. belowPlayerScroll(snapshot: snapshot, showsRelated: !isWide) + .frame(height: max(0, proxy.size.height - currentPlayerHeight)) } } .frame(width: leftWidth) @@ -364,6 +366,7 @@ struct VideoDetailView: View { .padding(.bottom, 24) } .coordinateSpace(name: tab.scrollCoordinateSpaceName) + .id(tab) } } @@ -433,10 +436,9 @@ private struct VideoDetailTabPager: View { comments() } .animation(.interactiveSpring(response: 0.28, dampingFraction: 0.86), value: selectedTab) - .animation(.interactiveSpring(response: 0.24, dampingFraction: 0.9), value: dragTranslation) .contentShape(Rectangle()) .clipped() - .gesture(horizontalPagingGesture) + .simultaneousGesture(horizontalPagingGesture) } private var horizontalPagingGesture: some Gesture { @@ -446,7 +448,11 @@ private struct VideoDetailTabPager: View { dragTranslation = 0 return } - dragTranslation = rubberBandedTranslation(value.translation.width) + var transaction = Transaction() + transaction.disablesAnimations = true + withTransaction(transaction) { + dragTranslation = rubberBandedTranslation(value.translation.width) + } } .onEnded { value in defer { From fd2da4541669bece93f5ab63a4cea0917b10287f Mon Sep 17 00:00:00 2001 From: pgs666 <74764545+pgs666@users.noreply.github.com> Date: Tue, 2 Jun 2026 02:34:28 +0800 Subject: [PATCH 003/216] fix(video): ignore pager drag in horizontal sections --- iosApp/VideoDetailView.swift | 39 +++++++++++++++++++++++++++++++++++- 1 file changed, 38 insertions(+), 1 deletion(-) diff --git a/iosApp/VideoDetailView.swift b/iosApp/VideoDetailView.swift index e82308a6..b8a41d71 100644 --- a/iosApp/VideoDetailView.swift +++ b/iosApp/VideoDetailView.swift @@ -10,6 +10,7 @@ struct VideoDetailView: View { @State private var selectedTab = VideoPageTab.introduction @State private var isPlayerFullscreen = false @State private var isPlayerCollapsed = false + @State private var horizontalPagerExclusionFrames: [CGRect] = [] /// True iff the player is currently playing (not paused / buffering). /// Driven from KSPlayerView via the @Binding below. Used to lock the /// player at full 16:9 height while playing — only paused state lets the @@ -313,7 +314,10 @@ struct VideoDetailView: View { .padding(.vertical, 8) .background(.background) - VideoDetailTabPager(selectedTab: $selectedTab) { + VideoDetailTabPager( + selectedTab: $selectedTab, + excludedDragStartFrames: horizontalPagerExclusionFrames + ) { tabScroll(.introduction) { AndroidStyleIntroduction( snapshot: snapshot, @@ -347,6 +351,9 @@ struct VideoDetailView: View { .onValueChange(of: selectedTab) { newTab in bottomScrollOffset = bottomScrollOffsetsByTab[newTab] ?? 0 } + .onPreferenceChange(HorizontalPagerExclusionFramePreferenceKey.self) { frames in + horizontalPagerExclusionFrames = frames + } } private func tabScroll( @@ -412,6 +419,7 @@ private enum VideoPageTab: String, CaseIterable, Identifiable { private struct VideoDetailTabPager: View { @Binding var selectedTab: VideoPageTab + let excludedDragStartFrames: [CGRect] let introduction: () -> Introduction let comments: () -> Comments @@ -419,10 +427,12 @@ private struct VideoDetailTabPager: View { init( selectedTab: Binding, + excludedDragStartFrames: [CGRect], @ViewBuilder introduction: @escaping () -> Introduction, @ViewBuilder comments: @escaping () -> Comments ) { _selectedTab = selectedTab + self.excludedDragStartFrames = excludedDragStartFrames self.introduction = introduction self.comments = comments } @@ -438,6 +448,7 @@ private struct VideoDetailTabPager: View { .animation(.interactiveSpring(response: 0.28, dampingFraction: 0.86), value: selectedTab) .contentShape(Rectangle()) .clipped() + .coordinateSpace(name: VideoDetailPagerCoordinateSpace.name) .simultaneousGesture(horizontalPagingGesture) } @@ -486,6 +497,9 @@ private struct VideoDetailTabPager: View { let dx = value.translation.width let dy = value.translation.height guard value.startLocation.x > 24 else { return false } + guard !excludedDragStartFrames.contains(where: { $0.contains(value.startLocation) }) else { + return false + } return abs(dx) > 24 && abs(dx) > abs(dy) * 1.35 } @@ -500,6 +514,28 @@ private struct VideoDetailTabPager: View { } } +private enum VideoDetailPagerCoordinateSpace { + static let name = "videoDetailPager" +} + +private struct HorizontalPagerExclusionFramePreferenceKey: PreferenceKey { + static var defaultValue: [CGRect] = [] + static func reduce(value: inout [CGRect], nextValue: () -> [CGRect]) { + value.append(contentsOf: nextValue()) + } +} + +private struct HorizontalPagerExclusionFrameReader: View { + var body: some View { + GeometryReader { proxy in + Color.clear.preference( + key: HorizontalPagerExclusionFramePreferenceKey.self, + value: [proxy.frame(in: .named(VideoDetailPagerCoordinateSpace.name))] + ) + } + } +} + /// Horizontally pages two tab bodies while reporting only ONE page's width to /// the parent vertical ScrollView. A plain HStack exposes its full 2× width /// during sizeThatFits, which can make nested lazy grids compute enormous @@ -608,6 +644,7 @@ private struct AndroidStyleIntroduction: View { commentFeature: commentFeature, showPlaying: true ) + .background(HorizontalPagerExclusionFrameReader()) } if showsRelated && !snapshot.relatedVideos.isEmpty { From fa01866c9884295057a2c68c169abed6738e64ae Mon Sep 17 00:00:00 2001 From: pgs666 <74764545+pgs666@users.noreply.github.com> Date: Tue, 2 Jun 2026 02:55:41 +0800 Subject: [PATCH 004/216] fix(video): prevent card navigation after drag --- iosApp/RelatedVideoComponents.swift | 163 ++++++++++++++++++++++++---- 1 file changed, 143 insertions(+), 20 deletions(-) diff --git a/iosApp/RelatedVideoComponents.swift b/iosApp/RelatedVideoComponents.swift index 132830c7..1b400e69 100644 --- a/iosApp/RelatedVideoComponents.swift +++ b/iosApp/RelatedVideoComponents.swift @@ -9,6 +9,8 @@ struct HorizontalVideoSection: View { let commentFeature: CommentFeature let showPlaying: Bool + @State private var selectedVideo: VideoRelatedRow? + var body: some View { VStack(alignment: .leading, spacing: 10) { HStack { @@ -39,16 +41,29 @@ struct HorizontalVideoSection: View { ScrollView(.horizontal, showsIndicators: false) { LazyHStack(alignment: .top, spacing: 12) { ForEach(videos) { video in - NavigationLink { - VideoDetailView(videoCode: video.videoCode, videoFeature: videoFeature, commentFeature: commentFeature) + DragAwareNavigationButton { + selectedVideo = video } label: { - RelatedVideoCard(video: video, showPlaying: showPlaying) + RelatedVideoCard(video: video, showPlaying: showPlaying, width: 172) } - .buttonStyle(.plain) } } } } + .navigationDestination( + isPresented: Binding( + get: { selectedVideo != nil }, + set: { if !$0 { selectedVideo = nil } } + ) + ) { + if let selectedVideo { + VideoDetailView( + videoCode: selectedVideo.videoCode, + videoFeature: videoFeature, + commentFeature: commentFeature + ) + } + } } } @@ -92,6 +107,8 @@ struct RelatedVideoGrid: View { let videoFeature: VideoFeature let commentFeature: CommentFeature + @State private var selectedVideo: VideoRelatedRow? + var body: some View { VStack(alignment: .leading, spacing: 10) { Text("相关影片") @@ -99,15 +116,63 @@ struct RelatedVideoGrid: View { LazyVGrid(columns: [GridItem(.adaptive(minimum: 156), spacing: 12)], spacing: 12) { ForEach(videos) { video in - NavigationLink { - VideoDetailView(videoCode: video.videoCode, videoFeature: videoFeature, commentFeature: commentFeature) + DragAwareNavigationButton { + selectedVideo = video } label: { RelatedVideoCard(video: video, showPlaying: false) } - .buttonStyle(.plain) } } } + .navigationDestination( + isPresented: Binding( + get: { selectedVideo != nil }, + set: { if !$0 { selectedVideo = nil } } + ) + ) { + if let selectedVideo { + VideoDetailView( + videoCode: selectedVideo.videoCode, + videoFeature: videoFeature, + commentFeature: commentFeature + ) + } + } + } +} + +private struct DragAwareNavigationButton: View { + let action: () -> Void + let label: () -> Label + + @State private var suppressTap = false + + init(action: @escaping () -> Void, @ViewBuilder label: @escaping () -> Label) { + self.action = action + self.label = label + } + + var body: some View { + Button { + guard !suppressTap else { return } + action() + } label: { + label() + } + .buttonStyle(.plain) + .simultaneousGesture( + DragGesture(minimumDistance: 4, coordinateSpace: .local) + .onChanged { value in + if abs(value.translation.width) > 4 || abs(value.translation.height) > 4 { + suppressTap = true + } + } + .onEnded { _ in + DispatchQueue.main.asyncAfter(deadline: .now() + 0.20) { + suppressTap = false + } + } + ) } } @@ -188,38 +253,96 @@ struct TabletRelatedVideoRow: View { struct RelatedVideoCard: View { let video: VideoRelatedRow let showPlaying: Bool + let width: CGFloat? + + init(video: VideoRelatedRow, showPlaying: Bool, width: CGFloat? = nil) { + self.video = video + self.showPlaying = showPlaying + self.width = width + } var body: some View { VStack(alignment: .leading, spacing: 6) { - ZStack(alignment: .bottomLeading) { + ZStack(alignment: .bottom) { CachedRemoteImage(urlString: video.coverUrl, resizeWidth: 172) - .frame(height: 96) - .clipped() + .aspectRatio(16.0 / 9.0, contentMode: .fit) + + LinearGradient( + colors: [ + .clear, + Color(.secondarySystemBackground).opacity(0.94) + ], + startPoint: .top, + endPoint: .bottom + ) + .frame(height: 36) + + HStack(spacing: 5) { + if let views = video.views, !views.isEmpty { + Label(views, systemImage: "play.circle") + .labelStyle(.titleAndIcon) + } + + Spacer(minLength: 8) + + if let duration = video.duration, !duration.isEmpty { + Label(duration, systemImage: "clock") + .labelStyle(.titleAndIcon) + } + } + .font(.caption2) + .foregroundStyle(.secondary) + .lineLimit(1) + .padding(.horizontal, 7) + .padding(.bottom, 5) if showPlaying && video.isPlaying { Text("正在播放") .font(.caption2.weight(.bold)) + .foregroundStyle(.primary) .padding(.horizontal, 8) .padding(.vertical, 4) .background(.regularMaterial, in: Capsule()) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) .padding(6) } } .clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous)) - Text(video.title) - .font(.subheadline.weight(.semibold)) - .foregroundStyle(.primary) - .lineLimit(2) + Group { + if #available(iOS 17.0, *) { + Text(video.title) + .lineLimit(2, reservesSpace: true) + } else { + Text(video.title) + .lineLimit(2) + } + } + .font(.subheadline.weight(.semibold)) + .foregroundStyle(.primary) + .frame(maxWidth: .infinity, alignment: .leading) - if !video.metadata.isEmpty { - Text(video.metadata) - .font(.caption) - .foregroundStyle(.secondary) - .lineLimit(2) + HStack(spacing: 6) { + MarqueeText(text: video.artistLabel) + if let uploadTime = video.uploadTime, !uploadTime.isEmpty { + Text(uploadTime) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + .layoutPriority(1) + } } } - .frame(width: 172, alignment: .leading) + .frame(width: width, alignment: .leading) + } +} + +private extension VideoRelatedRow { + var artistLabel: String { + guard let artist, !artist.isEmpty else { + return String(localized: "common.artist") + } + return artist } } From ad37c317e3ad0b5e8a8b9cc431e8a2567806db1d Mon Sep 17 00:00:00 2001 From: pgs666 <74764545+pgs666@users.noreply.github.com> Date: Tue, 2 Jun 2026 03:05:10 +0800 Subject: [PATCH 005/216] fix(video): keep card scrolling responsive --- iosApp/RelatedVideoComponents.swift | 34 ++++++++--------------------- 1 file changed, 9 insertions(+), 25 deletions(-) diff --git a/iosApp/RelatedVideoComponents.swift b/iosApp/RelatedVideoComponents.swift index 1b400e69..f924c53e 100644 --- a/iosApp/RelatedVideoComponents.swift +++ b/iosApp/RelatedVideoComponents.swift @@ -41,7 +41,7 @@ struct HorizontalVideoSection: View { ScrollView(.horizontal, showsIndicators: false) { LazyHStack(alignment: .top, spacing: 12) { ForEach(videos) { video in - DragAwareNavigationButton { + ManualNavigationCard { selectedVideo = video } label: { RelatedVideoCard(video: video, showPlaying: showPlaying, width: 172) @@ -116,7 +116,7 @@ struct RelatedVideoGrid: View { LazyVGrid(columns: [GridItem(.adaptive(minimum: 156), spacing: 12)], spacing: 12) { ForEach(videos) { video in - DragAwareNavigationButton { + ManualNavigationCard { selectedVideo = video } label: { RelatedVideoCard(video: video, showPlaying: false) @@ -141,38 +141,19 @@ struct RelatedVideoGrid: View { } } -private struct DragAwareNavigationButton: View { +private struct ManualNavigationCard: View { let action: () -> Void let label: () -> Label - @State private var suppressTap = false - init(action: @escaping () -> Void, @ViewBuilder label: @escaping () -> Label) { self.action = action self.label = label } var body: some View { - Button { - guard !suppressTap else { return } - action() - } label: { - label() - } - .buttonStyle(.plain) - .simultaneousGesture( - DragGesture(minimumDistance: 4, coordinateSpace: .local) - .onChanged { value in - if abs(value.translation.width) > 4 || abs(value.translation.height) > 4 { - suppressTap = true - } - } - .onEnded { _ in - DispatchQueue.main.asyncAfter(deadline: .now() + 0.20) { - suppressTap = false - } - } - ) + label() + .contentShape(Rectangle()) + .onTapGesture(perform: action) } } @@ -334,6 +315,9 @@ struct RelatedVideoCard: View { } } .frame(width: width, alignment: .leading) + .padding(8) + .background(Color(.systemBackground)) + .clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous)) } } From cd86f1d4a9a7b50e7edc216eb1d13c947b12b0e6 Mon Sep 17 00:00:00 2001 From: pgs666 <74764545+pgs666@users.noreply.github.com> Date: Tue, 2 Jun 2026 03:09:56 +0800 Subject: [PATCH 006/216] fix(player): prevent swipe volume HUD from sticking --- iosApp/KSPlayerView.swift | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/iosApp/KSPlayerView.swift b/iosApp/KSPlayerView.swift index 965452f4..e67075ec 100644 --- a/iosApp/KSPlayerView.swift +++ b/iosApp/KSPlayerView.swift @@ -113,6 +113,7 @@ struct KSPlayerView: View { @State private var dragCurrentBrightness: CGFloat = 0 @State private var dragStartVolume: Float = 0 @State private var dragCurrentVolume: Float = 0 + @State private var swipeHUDCleanupTask: Task? /// 长按 timer。finger 落下后启动;移动 > 12pt 或 finger 抬起时 cancel。 @State private var longPressTask: Task? /// 当前手势是否已经决定走 swipe 路径(以避免长按 timer 重复 schedule)。 @@ -382,6 +383,7 @@ struct KSPlayerView: View { isPinching = true longPressTask?.cancel() longPressTask = nil + resetSwipeHUDState() if isBoosted { endBoost() } } .onEnded { value in @@ -450,6 +452,8 @@ struct KSPlayerView: View { } .onAppear { scheduleAutoHide() + physicalVolumeHUDActive = false + resetSwipeHUDState() // Mount the hidden MPVolumeView so swipe-volume can write the // system output volume. Released on disappear so iOS's own // volume HUD works everywhere else in the app. @@ -476,6 +480,10 @@ struct KSPlayerView: View { volumeObserver.stop() physicalVolumeHUDHideTask?.cancel() physicalVolumeHUDHideTask = nil + physicalVolumeHUDActive = false + swipeHUDCleanupTask?.cancel() + swipeHUDCleanupTask = nil + resetSwipeHUDState() speedSampleTask?.cancel() speedSampleTask = nil // Pause the player when this view is no longer on-screen. @@ -1127,6 +1135,7 @@ struct KSPlayerView: View { /// based on dominant axis & start location. Subsequent calls update the /// active dimension only. private func handleSwipeChanged(_ value: DragGesture.Value, in size: CGSize) { + scheduleSwipeHUDCleanup() if dragState == .none { // Decide direction: vertical vs horizontal based on dominant axis. let dx = value.translation.width @@ -1188,12 +1197,37 @@ struct KSPlayerView: View { sliderValue = dragTargetProgressSeconds } // Hide HUD with a small fade. + swipeHUDCleanupTask?.cancel() + swipeHUDCleanupTask = nil withAnimation(.easeOut(duration: 0.2)) { dragState = .none } scheduleAutoHide() } + private func scheduleSwipeHUDCleanup() { + swipeHUDCleanupTask?.cancel() + swipeHUDCleanupTask = Task { @MainActor in + try? await Task.sleep(nanoseconds: 3_000_000_000) + guard !Task.isCancelled else { return } + resetSwipeHUDState() + scheduleAutoHide() + } + } + + private func resetSwipeHUDState() { + swipeHUDCleanupTask?.cancel() + swipeHUDCleanupTask = nil + longPressTask?.cancel() + longPressTask = nil + hasMovedToSwipe = false + if dragState != .none { + withAnimation(.easeOut(duration: 0.18)) { + dragState = .none + } + } + } + private func togglePlayPause() { guard let layer = coordinator.playerLayer else { return } AppLogger.log("gesture: toggle play/pause was=\(isPlaying ? "playing" : "paused")") From 0e475c0ac5b518e78246437170dd58c0459f6a39 Mon Sep 17 00:00:00 2001 From: pgs666 <74764545+pgs666@users.noreply.github.com> Date: Tue, 2 Jun 2026 03:16:52 +0800 Subject: [PATCH 007/216] fix(player): reset swipe HUD on gesture cancellation --- iosApp/KSPlayerView.swift | 33 +++++++++++++++------------------ 1 file changed, 15 insertions(+), 18 deletions(-) diff --git a/iosApp/KSPlayerView.swift b/iosApp/KSPlayerView.swift index e67075ec..8ffadbd1 100644 --- a/iosApp/KSPlayerView.swift +++ b/iosApp/KSPlayerView.swift @@ -113,7 +113,7 @@ struct KSPlayerView: View { @State private var dragCurrentBrightness: CGFloat = 0 @State private var dragStartVolume: Float = 0 @State private var dragCurrentVolume: Float = 0 - @State private var swipeHUDCleanupTask: Task? + @GestureState private var isPlayerDragGestureActive = false /// 长按 timer。finger 落下后启动;移动 > 12pt 或 finger 抬起时 cancel。 @State private var longPressTask: Task? /// 当前手势是否已经决定走 swipe 路径(以避免长按 timer 重复 schedule)。 @@ -417,6 +417,9 @@ struct KSPlayerView: View { // the tap / double-tap / pinch gestures. .simultaneousGesture( DragGesture(minimumDistance: 0) + .updating($isPlayerDragGestureActive) { _, isActive, _ in + isActive = true + } .onChanged { value in handlePressOrSwipe(value, in: proxy.size) } @@ -481,8 +484,6 @@ struct KSPlayerView: View { physicalVolumeHUDHideTask?.cancel() physicalVolumeHUDHideTask = nil physicalVolumeHUDActive = false - swipeHUDCleanupTask?.cancel() - swipeHUDCleanupTask = nil resetSwipeHUDState() speedSampleTask?.cancel() speedSampleTask = nil @@ -505,6 +506,14 @@ struct KSPlayerView: View { onControlsVisibilityChanged(false) hideControlsTask?.cancel() } + .onValueChange(of: isPlayerDragGestureActive) { isActive in + guard !isActive else { return } + let shouldResumeAutoHide = hasMovedToSwipe || dragState != .none || isBoosted + resetSwipeHUDState() + if shouldResumeAutoHide { + scheduleAutoHide() + } + } .onValueChange(of: statusObserver.isWaitingForPlayback) { waiting in // Speed sampler only runs while the player is genuinely waiting // for data. Covers both initial asset-loading (currentItem @@ -1135,7 +1144,6 @@ struct KSPlayerView: View { /// based on dominant axis & start location. Subsequent calls update the /// active dimension only. private func handleSwipeChanged(_ value: DragGesture.Value, in size: CGSize) { - scheduleSwipeHUDCleanup() if dragState == .none { // Decide direction: vertical vs horizontal based on dominant axis. let dx = value.translation.width @@ -1197,30 +1205,19 @@ struct KSPlayerView: View { sliderValue = dragTargetProgressSeconds } // Hide HUD with a small fade. - swipeHUDCleanupTask?.cancel() - swipeHUDCleanupTask = nil withAnimation(.easeOut(duration: 0.2)) { dragState = .none } scheduleAutoHide() } - private func scheduleSwipeHUDCleanup() { - swipeHUDCleanupTask?.cancel() - swipeHUDCleanupTask = Task { @MainActor in - try? await Task.sleep(nanoseconds: 3_000_000_000) - guard !Task.isCancelled else { return } - resetSwipeHUDState() - scheduleAutoHide() - } - } - private func resetSwipeHUDState() { - swipeHUDCleanupTask?.cancel() - swipeHUDCleanupTask = nil longPressTask?.cancel() longPressTask = nil hasMovedToSwipe = false + if isBoosted { + endBoost() + } if dragState != .none { withAnimation(.easeOut(duration: 0.18)) { dragState = .none From 15392cbb090623e88ac145a832f7081b2e93ffaa Mon Sep 17 00:00:00 2001 From: pgs666 <74764545+pgs666@users.noreply.github.com> Date: Tue, 2 Jun 2026 03:22:26 +0800 Subject: [PATCH 008/216] fix(player): debounce physical volume HUD events --- iosApp/KSPlayerView.swift | 39 ++++++++++++++++++++++++++------------- 1 file changed, 26 insertions(+), 13 deletions(-) diff --git a/iosApp/KSPlayerView.swift b/iosApp/KSPlayerView.swift index 8ffadbd1..57980022 100644 --- a/iosApp/KSPlayerView.swift +++ b/iosApp/KSPlayerView.swift @@ -134,7 +134,9 @@ struct KSPlayerView: View { /// which already shows the same bar via swipeHUD). @StateObject private var volumeObserver = SystemVolumeObserver() @State private var physicalVolumeHUDActive = false - @State private var physicalVolumeHUDHideTask: Task? + @State private var physicalVolumeHUDGeneration: UInt64 = 0 + @State private var lastHandledPhysicalVolumeTick: UInt64 = 0 + @State private var suppressPhysicalVolumeHUDUntil = Date.distantPast // MARK: - Buffering / loading feedback /// Observes the underlying AVPlayer's `timeControlStatus` — the @@ -481,8 +483,7 @@ struct KSPlayerView: View { hideControlsTask?.cancel() SystemVolumeController.release() volumeObserver.stop() - physicalVolumeHUDHideTask?.cancel() - physicalVolumeHUDHideTask = nil + physicalVolumeHUDGeneration &+= 1 physicalVolumeHUDActive = false resetSwipeHUDState() speedSampleTask?.cancel() @@ -528,20 +529,31 @@ struct KSPlayerView: View { currentSpeedText = nil } } - .onReceive(volumeObserver.$changeTick) { _ in + .onReceive(volumeObserver.$changeTick) { tick in + // @Published emits its current value immediately on subscription. + // Tick 0 is not a hardware-key event; showing HUD for it can keep + // recreating the hide timer during normal SwiftUI re-subscription. + guard tick > 0 else { return } + guard tick != lastHandledPhysicalVolumeTick else { return } + lastHandledPhysicalVolumeTick = tick // Skip if the change came from our swipe-volume gesture — // swipeHUD is already visible for that case. Otherwise pop // the physical-key HUD and auto-hide after 1.5s. guard dragState != .volume else { return } - physicalVolumeHUDActive = true - physicalVolumeHUDHideTask?.cancel() - physicalVolumeHUDHideTask = Task { @MainActor in - try? await Task.sleep(nanoseconds: 1_500_000_000) - if !Task.isCancelled { - withAnimation(.easeInOut(duration: 0.18)) { - physicalVolumeHUDActive = false - } - } + guard Date() >= suppressPhysicalVolumeHUDUntil else { return } + showPhysicalVolumeHUD() + } + } + + private func showPhysicalVolumeHUD() { + physicalVolumeHUDGeneration &+= 1 + let generation = physicalVolumeHUDGeneration + physicalVolumeHUDActive = true + Task { @MainActor in + try? await Task.sleep(nanoseconds: 1_500_000_000) + guard generation == physicalVolumeHUDGeneration else { return } + withAnimation(.easeInOut(duration: 0.18)) { + physicalVolumeHUDActive = false } } } @@ -1190,6 +1202,7 @@ struct KSPlayerView: View { guard size.height > 0 else { return } let fraction = -Float(value.translation.height / size.height) dragCurrentVolume = max(0, min(1, dragStartVolume + fraction)) + suppressPhysicalVolumeHUDUntil = Date().addingTimeInterval(0.8) SystemVolumeController.setVolume(dragCurrentVolume) case .none: break From ef09a4434f320473ca8b80aa6fa5186b05e9da4f Mon Sep 17 00:00:00 2001 From: pgs666 <74764545+pgs666@users.noreply.github.com> Date: Tue, 2 Jun 2026 03:37:56 +0800 Subject: [PATCH 009/216] fix(video): preserve player collapse across tab switches --- iosApp/VideoDetailView.swift | 60 +++++++++++++++++++++++++++++++----- 1 file changed, 53 insertions(+), 7 deletions(-) diff --git a/iosApp/VideoDetailView.swift b/iosApp/VideoDetailView.swift index b8a41d71..62dcface 100644 --- a/iosApp/VideoDetailView.swift +++ b/iosApp/VideoDetailView.swift @@ -16,15 +16,16 @@ struct VideoDetailView: View { /// player at full 16:9 height while playing — only paused state lets the /// scroll-driven shrink behaviour engage. @State private var isPlayerPlaying = false - /// Vertical scroll offset of the inline content area below the player, - /// measured from the natural top (>= 0). When the user scrolls UP (so - /// the offset grows), and the player is paused, the player shrinks - /// proportionally — Bilibili-style "follow finger" collapse. + /// Global collapse offset for the inline player, decoupled from any + /// single tab's ScrollView offset. If one tab has already collapsed the + /// player, switching to another tab must not snap it back open just + /// because that tab's own content is still at the top. @State private var bottomScrollOffset: CGFloat = 0 /// Each tab owns an independent vertical ScrollView below the player, so /// switching between intro / comments preserves their separate scroll /// positions instead of sharing one outer ScrollView offset. @State private var bottomScrollOffsetsByTab: [VideoPageTab: CGFloat] = [:] + @State private var lastSelectedTabChangeAt = Date.distantPast /// Natural size of the loaded video (reported by KSPlayer the first time /// the underlying player gets a non-zero presentation size). Used to /// decide whether fullscreen should lock the device to portrait or @@ -263,7 +264,13 @@ struct VideoDetailView: View { // below playerCollapsedFollowMinHeight so its overlay controls // remain at least partly visible. let minHeight: CGFloat = max(baseHeight * 0.32, 80) - let shrink = max(0, min(baseHeight - minHeight, bottomScrollOffset)) + let collapseDistance = max(baseHeight - minHeight, 1) + let progress = max(0, min(1, bottomScrollOffset / collapseDistance)) + // Ease out instead of subtracting scroll offset linearly. This keeps + // the player responsive at the start while avoiding a rigid + // one-pixel-scroll == one-pixel-height feedback loop near collapse. + let easedProgress = progress * (2 - progress) + let shrink = collapseDistance * easedProgress return baseHeight - shrink } @@ -343,19 +350,58 @@ struct VideoDetailView: View { } .frame(maxHeight: .infinity) .onPreferenceChange(BottomScrollOffsetPreferenceKey.self) { offsets in + let previousActiveOffset = bottomScrollOffsetsByTab[selectedTab] for (tab, offset) in offsets { bottomScrollOffsetsByTab[tab] = max(0, offset) } - bottomScrollOffset = bottomScrollOffsetsByTab[selectedTab] ?? 0 + let activeOffset = bottomScrollOffsetsByTab[selectedTab] ?? 0 + updatePlayerCollapseOffset( + activeTabOffset: activeOffset, + previousActiveTabOffset: previousActiveOffset + ) } .onValueChange(of: selectedTab) { newTab in - bottomScrollOffset = bottomScrollOffsetsByTab[newTab] ?? 0 + lastSelectedTabChangeAt = Date() + let newTabOffset = bottomScrollOffsetsByTab[newTab] ?? 0 + // Keep the player collapsed across horizontal tab switches. If + // the destination tab is already scrolled farther, collapse more; + // never expand merely because the destination tab is at offset 0. + let targetOffset = max(bottomScrollOffset, newTabOffset) + if targetOffset != bottomScrollOffset { + withAnimation(.interactiveSpring(response: 0.28, dampingFraction: 0.86)) { + bottomScrollOffset = targetOffset + } + } } .onPreferenceChange(HorizontalPagerExclusionFramePreferenceKey.self) { frames in horizontalPagerExclusionFrames = frames } } + private func updatePlayerCollapseOffset( + activeTabOffset: CGFloat, + previousActiveTabOffset: CGFloat? + ) { + let tabSwitchGracePeriod: TimeInterval = 0.35 + let justSwitchedTabs = Date().timeIntervalSince(lastSelectedTabChangeAt) < tabSwitchGracePeriod + guard let previousActiveTabOffset else { + bottomScrollOffset = max(bottomScrollOffset, activeTabOffset) + return + } + + if activeTabOffset >= previousActiveTabOffset { + // User is scrolling upward in the active tab. Collapse more only + // after this tab catches up to the current global collapse amount; + // do not expand a previously collapsed player just because the + // destination tab starts near offset 0. + bottomScrollOffset = max(bottomScrollOffset, activeTabOffset) + } else if !justSwitchedTabs { + // User is scrolling downward in the active tab. This is an + // intentional expansion gesture, so let the player follow it. + bottomScrollOffset = activeTabOffset + } + } + private func tabScroll( _ tab: VideoPageTab, @ViewBuilder content: @escaping () -> Content From e63e9fa42229c85043351a19c6974b29673d3440 Mon Sep 17 00:00:00 2001 From: pgs666 <74764545+pgs666@users.noreply.github.com> Date: Tue, 2 Jun 2026 03:41:40 +0800 Subject: [PATCH 010/216] fix(video): smooth shared player collapse across tabs --- iosApp/VideoDetailView.swift | 56 +++++++++++++++++++++++++++--------- 1 file changed, 42 insertions(+), 14 deletions(-) diff --git a/iosApp/VideoDetailView.swift b/iosApp/VideoDetailView.swift index 62dcface..f9c91125 100644 --- a/iosApp/VideoDetailView.swift +++ b/iosApp/VideoDetailView.swift @@ -213,6 +213,7 @@ struct VideoDetailView: View { HStack(alignment: .top, spacing: 0) { VStack(spacing: 0) { + let currentPlayerCollapseDistance = playerCollapseDistance(panelWidth: leftWidth) let currentPlayerHeight = playerHeight( panelWidth: leftWidth, parentHeight: proxy.size.height @@ -227,7 +228,11 @@ struct VideoDetailView: View { // showsRelated=false on iPad regular landscape because the // dedicated right sidebar already shows related videos — // duplicating them in the bottom scroll would be redundant. - belowPlayerScroll(snapshot: snapshot, showsRelated: !isWide) + belowPlayerScroll( + snapshot: snapshot, + showsRelated: !isWide, + collapseDistance: currentPlayerCollapseDistance + ) .frame(height: max(0, proxy.size.height - currentPlayerHeight)) } } @@ -263,8 +268,7 @@ struct VideoDetailView: View { // (content scrolled up), the player shrinks proportionally, never // below playerCollapsedFollowMinHeight so its overlay controls // remain at least partly visible. - let minHeight: CGFloat = max(baseHeight * 0.32, 80) - let collapseDistance = max(baseHeight - minHeight, 1) + let collapseDistance = playerCollapseDistance(panelWidth: panelWidth) let progress = max(0, min(1, bottomScrollOffset / collapseDistance)) // Ease out instead of subtracting scroll offset linearly. This keeps // the player responsive at the start while avoiding a rigid @@ -274,6 +278,12 @@ struct VideoDetailView: View { return baseHeight - shrink } + private func playerCollapseDistance(panelWidth: CGFloat) -> CGFloat { + let baseHeight = panelWidth * 9 / 16 + let minHeight: CGFloat = max(baseHeight * 0.32, 80) + return max(baseHeight - minHeight, 1) + } + private func playerArea(snapshot: VideoDetailScreenSnapshot) -> some View { // Shrunken iff the follow-finger collapse has actually engaged // (paused, not fullscreen / strip-collapsed, and the user has @@ -309,7 +319,11 @@ struct VideoDetailView: View { ) } - private func belowPlayerScroll(snapshot: VideoDetailScreenSnapshot, showsRelated: Bool) -> some View { + private func belowPlayerScroll( + snapshot: VideoDetailScreenSnapshot, + showsRelated: Bool, + collapseDistance: CGFloat + ) -> some View { VStack(spacing: 0) { Picker("Content", selection: $selectedTab) { ForEach(VideoPageTab.allCases) { tab in @@ -352,21 +366,22 @@ struct VideoDetailView: View { .onPreferenceChange(BottomScrollOffsetPreferenceKey.self) { offsets in let previousActiveOffset = bottomScrollOffsetsByTab[selectedTab] for (tab, offset) in offsets { - bottomScrollOffsetsByTab[tab] = max(0, offset) + bottomScrollOffsetsByTab[tab] = offset } let activeOffset = bottomScrollOffsetsByTab[selectedTab] ?? 0 updatePlayerCollapseOffset( activeTabOffset: activeOffset, - previousActiveTabOffset: previousActiveOffset + previousActiveTabOffset: previousActiveOffset, + collapseDistance: collapseDistance ) } .onValueChange(of: selectedTab) { newTab in lastSelectedTabChangeAt = Date() - let newTabOffset = bottomScrollOffsetsByTab[newTab] ?? 0 + let newTabOffset = max(0, bottomScrollOffsetsByTab[newTab] ?? 0) // Keep the player collapsed across horizontal tab switches. If // the destination tab is already scrolled farther, collapse more; // never expand merely because the destination tab is at offset 0. - let targetOffset = max(bottomScrollOffset, newTabOffset) + let targetOffset = min(collapseDistance, max(bottomScrollOffset, newTabOffset)) if targetOffset != bottomScrollOffset { withAnimation(.interactiveSpring(response: 0.28, dampingFraction: 0.86)) { bottomScrollOffset = targetOffset @@ -380,25 +395,38 @@ struct VideoDetailView: View { private func updatePlayerCollapseOffset( activeTabOffset: CGFloat, - previousActiveTabOffset: CGFloat? + previousActiveTabOffset: CGFloat?, + collapseDistance: CGFloat ) { let tabSwitchGracePeriod: TimeInterval = 0.35 let justSwitchedTabs = Date().timeIntervalSince(lastSelectedTabChangeAt) < tabSwitchGracePeriod + let currentCollapseOffset = min(max(bottomScrollOffset, 0), collapseDistance) + let nonnegativeActiveOffset = max(0, activeTabOffset) + guard let previousActiveTabOffset else { - bottomScrollOffset = max(bottomScrollOffset, activeTabOffset) + bottomScrollOffset = min(collapseDistance, max(currentCollapseOffset, nonnegativeActiveOffset)) return } - if activeTabOffset >= previousActiveTabOffset { + let delta = activeTabOffset - previousActiveTabOffset + if delta > 0.5 { // User is scrolling upward in the active tab. Collapse more only // after this tab catches up to the current global collapse amount; // do not expand a previously collapsed player just because the // destination tab starts near offset 0. - bottomScrollOffset = max(bottomScrollOffset, activeTabOffset) - } else if !justSwitchedTabs { + if nonnegativeActiveOffset >= currentCollapseOffset { + bottomScrollOffset = min(collapseDistance, nonnegativeActiveOffset) + } + } else if delta < -0.5 && activeTabOffset <= 0 { + // At the top of a newly selected tab, a downward rubber-band drag + // should still expand the shared player instead of feeling stuck. + bottomScrollOffset = min(collapseDistance, max(0, currentCollapseOffset + delta)) + } else if delta < -0.5 && !justSwitchedTabs { // User is scrolling downward in the active tab. This is an // intentional expansion gesture, so let the player follow it. - bottomScrollOffset = activeTabOffset + bottomScrollOffset = min(collapseDistance, nonnegativeActiveOffset) + } else if currentCollapseOffset != bottomScrollOffset { + bottomScrollOffset = currentCollapseOffset } } From b0f33b42e5d0daf8b2b14c0d10b3e3d28437cf6c Mon Sep 17 00:00:00 2001 From: pgs666 <74764545+pgs666@users.noreply.github.com> Date: Tue, 2 Jun 2026 03:44:36 +0800 Subject: [PATCH 011/216] refactor(video): isolate player collapse state model --- iosApp/VideoDetailView.swift | 76 ++++++++++++++++++++++-------------- 1 file changed, 46 insertions(+), 30 deletions(-) diff --git a/iosApp/VideoDetailView.swift b/iosApp/VideoDetailView.swift index f9c91125..1944af9c 100644 --- a/iosApp/VideoDetailView.swift +++ b/iosApp/VideoDetailView.swift @@ -398,36 +398,13 @@ struct VideoDetailView: View { previousActiveTabOffset: CGFloat?, collapseDistance: CGFloat ) { - let tabSwitchGracePeriod: TimeInterval = 0.35 - let justSwitchedTabs = Date().timeIntervalSince(lastSelectedTabChangeAt) < tabSwitchGracePeriod - let currentCollapseOffset = min(max(bottomScrollOffset, 0), collapseDistance) - let nonnegativeActiveOffset = max(0, activeTabOffset) - - guard let previousActiveTabOffset else { - bottomScrollOffset = min(collapseDistance, max(currentCollapseOffset, nonnegativeActiveOffset)) - return - } - - let delta = activeTabOffset - previousActiveTabOffset - if delta > 0.5 { - // User is scrolling upward in the active tab. Collapse more only - // after this tab catches up to the current global collapse amount; - // do not expand a previously collapsed player just because the - // destination tab starts near offset 0. - if nonnegativeActiveOffset >= currentCollapseOffset { - bottomScrollOffset = min(collapseDistance, nonnegativeActiveOffset) - } - } else if delta < -0.5 && activeTabOffset <= 0 { - // At the top of a newly selected tab, a downward rubber-band drag - // should still expand the shared player instead of feeling stuck. - bottomScrollOffset = min(collapseDistance, max(0, currentCollapseOffset + delta)) - } else if delta < -0.5 && !justSwitchedTabs { - // User is scrolling downward in the active tab. This is an - // intentional expansion gesture, so let the player follow it. - bottomScrollOffset = min(collapseDistance, nonnegativeActiveOffset) - } else if currentCollapseOffset != bottomScrollOffset { - bottomScrollOffset = currentCollapseOffset - } + bottomScrollOffset = VideoPlayerCollapseModel.nextCollapseOffset( + currentCollapseOffset: bottomScrollOffset, + activeTabOffset: activeTabOffset, + previousActiveTabOffset: previousActiveTabOffset, + collapseDistance: collapseDistance, + switchedTabsRecently: Date().timeIntervalSince(lastSelectedTabChangeAt) < 0.35 + ) } private func tabScroll( @@ -460,6 +437,45 @@ private struct BottomScrollOffsetPreferenceKey: PreferenceKey { } } +private enum VideoPlayerCollapseModel { + static func nextCollapseOffset( + currentCollapseOffset: CGFloat, + activeTabOffset: CGFloat, + previousActiveTabOffset: CGFloat?, + collapseDistance: CGFloat, + switchedTabsRecently: Bool + ) -> CGFloat { + let clampedCurrent = clamp(currentCollapseOffset, upperBound: collapseDistance) + let nonnegativeActiveOffset = max(0, activeTabOffset) + + guard let previousActiveTabOffset else { + return min(collapseDistance, max(clampedCurrent, nonnegativeActiveOffset)) + } + + let delta = activeTabOffset - previousActiveTabOffset + if delta > 0.5 { + if nonnegativeActiveOffset >= clampedCurrent { + return min(collapseDistance, nonnegativeActiveOffset) + } + return clampedCurrent + } + + if delta < -0.5 && activeTabOffset <= 0 { + return min(collapseDistance, max(0, clampedCurrent + delta)) + } + + if delta < -0.5 && !switchedTabsRecently { + return min(collapseDistance, nonnegativeActiveOffset) + } + + return clampedCurrent + } + + private static func clamp(_ value: CGFloat, upperBound: CGFloat) -> CGFloat { + min(max(value, 0), max(upperBound, 0)) + } +} + private enum VideoPageTab: String, CaseIterable, Identifiable { case introduction case comments From 5a8c0e0f550cc405b5231ec3ccdf9d04b09ddaa2 Mon Sep 17 00:00:00 2001 From: pgs666 <74764545+pgs666@users.noreply.github.com> Date: Tue, 2 Jun 2026 03:53:23 +0800 Subject: [PATCH 012/216] fix(video): stabilize pager viewport during player collapse --- iosApp/VideoDetailView.swift | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/iosApp/VideoDetailView.swift b/iosApp/VideoDetailView.swift index 1944af9c..5eea00ef 100644 --- a/iosApp/VideoDetailView.swift +++ b/iosApp/VideoDetailView.swift @@ -212,7 +212,7 @@ struct VideoDetailView: View { : proxy.size.width HStack(alignment: .top, spacing: 0) { - VStack(spacing: 0) { + ZStack(alignment: .top) { let currentPlayerCollapseDistance = playerCollapseDistance(panelWidth: leftWidth) let currentPlayerHeight = playerHeight( panelWidth: leftWidth, @@ -233,10 +233,12 @@ struct VideoDetailView: View { showsRelated: !isWide, collapseDistance: currentPlayerCollapseDistance ) - .frame(height: max(0, proxy.size.height - currentPlayerHeight)) + .frame(height: max(0, proxy.size.height - playerMinimumHeight(panelWidth: leftWidth))) + .offset(y: currentPlayerHeight) } } - .frame(width: leftWidth) + .frame(width: leftWidth, height: proxy.size.height, alignment: .top) + .clipped() if isWide { Divider() @@ -280,8 +282,11 @@ struct VideoDetailView: View { private func playerCollapseDistance(panelWidth: CGFloat) -> CGFloat { let baseHeight = panelWidth * 9 / 16 - let minHeight: CGFloat = max(baseHeight * 0.32, 80) - return max(baseHeight - minHeight, 1) + return max(baseHeight - playerMinimumHeight(panelWidth: panelWidth), 1) + } + + private func playerMinimumHeight(panelWidth: CGFloat) -> CGFloat { + max((panelWidth * 9 / 16) * 0.32, 80) } private func playerArea(snapshot: VideoDetailScreenSnapshot) -> some View { From b1c1e7059eb4ff562ffebd6bbea004406edbfc07 Mon Sep 17 00:00:00 2001 From: pgs666 <74764545+pgs666@users.noreply.github.com> Date: Tue, 2 Jun 2026 04:14:19 +0800 Subject: [PATCH 013/216] fix(video): make player collapse follow scroll directly --- iosApp/VideoDetailView.swift | 35 +++++++++++++++++++++-------------- 1 file changed, 21 insertions(+), 14 deletions(-) diff --git a/iosApp/VideoDetailView.swift b/iosApp/VideoDetailView.swift index 5eea00ef..14c06127 100644 --- a/iosApp/VideoDetailView.swift +++ b/iosApp/VideoDetailView.swift @@ -218,6 +218,7 @@ struct VideoDetailView: View { panelWidth: leftWidth, parentHeight: proxy.size.height ) + let currentPlayerShrink = playerVisualShrink(panelWidth: leftWidth) playerArea(snapshot: snapshot) .frame( width: leftWidth, @@ -231,7 +232,8 @@ struct VideoDetailView: View { belowPlayerScroll( snapshot: snapshot, showsRelated: !isWide, - collapseDistance: currentPlayerCollapseDistance + collapseDistance: currentPlayerCollapseDistance, + collapseCompensation: currentPlayerShrink ) .frame(height: max(0, proxy.size.height - playerMinimumHeight(panelWidth: leftWidth))) .offset(y: currentPlayerHeight) @@ -270,14 +272,7 @@ struct VideoDetailView: View { // (content scrolled up), the player shrinks proportionally, never // below playerCollapsedFollowMinHeight so its overlay controls // remain at least partly visible. - let collapseDistance = playerCollapseDistance(panelWidth: panelWidth) - let progress = max(0, min(1, bottomScrollOffset / collapseDistance)) - // Ease out instead of subtracting scroll offset linearly. This keeps - // the player responsive at the start while avoiding a rigid - // one-pixel-scroll == one-pixel-height feedback loop near collapse. - let easedProgress = progress * (2 - progress) - let shrink = collapseDistance * easedProgress - return baseHeight - shrink + return baseHeight - playerVisualShrink(panelWidth: panelWidth) } private func playerCollapseDistance(panelWidth: CGFloat) -> CGFloat { @@ -289,6 +284,10 @@ struct VideoDetailView: View { max((panelWidth * 9 / 16) * 0.32, 80) } + private func playerVisualShrink(panelWidth: CGFloat) -> CGFloat { + min(max(bottomScrollOffset, 0), playerCollapseDistance(panelWidth: panelWidth)) + } + private func playerArea(snapshot: VideoDetailScreenSnapshot) -> some View { // Shrunken iff the follow-finger collapse has actually engaged // (paused, not fullscreen / strip-collapsed, and the user has @@ -327,7 +326,8 @@ struct VideoDetailView: View { private func belowPlayerScroll( snapshot: VideoDetailScreenSnapshot, showsRelated: Bool, - collapseDistance: CGFloat + collapseDistance: CGFloat, + collapseCompensation: CGFloat ) -> some View { VStack(spacing: 0) { Picker("Content", selection: $selectedTab) { @@ -358,11 +358,15 @@ struct VideoDetailView: View { showsRelated: showsRelated ) .padding(.top, 16) + } collapseCompensation: { + collapseCompensation } } comments: { tabScroll(.comments) { CommentView(videoCode: videoCode, commentFeature: commentFeature) .padding(.top, 16) + } collapseCompensation: { + collapseCompensation } } .frame(maxHeight: .infinity) @@ -414,7 +418,8 @@ struct VideoDetailView: View { private func tabScroll( _ tab: VideoPageTab, - @ViewBuilder content: @escaping () -> Content + @ViewBuilder content: @escaping () -> Content, + collapseCompensation: @escaping () -> CGFloat = { 0 } ) -> some View { ScrollView { GeometryReader { proxy in @@ -426,7 +431,9 @@ struct VideoDetailView: View { .frame(height: 0) content() - .padding(.bottom, 24) + .padding(.bottom, 24) + .offset(y: collapseCompensation()) + .padding(.bottom, collapseCompensation()) } .coordinateSpace(name: tab.scrollCoordinateSpaceName) .id(tab) @@ -548,7 +555,7 @@ private struct VideoDetailTabPager: View { } private var horizontalPagingGesture: some Gesture { - DragGesture(minimumDistance: 18, coordinateSpace: .local) + DragGesture(minimumDistance: 28, coordinateSpace: .local) .onChanged { value in guard isHorizontalPagingDrag(value) else { dragTranslation = 0 @@ -595,7 +602,7 @@ private struct VideoDetailTabPager: View { guard !excludedDragStartFrames.contains(where: { $0.contains(value.startLocation) }) else { return false } - return abs(dx) > 24 && abs(dx) > abs(dy) * 1.35 + return abs(dx) > 36 && abs(dx) > abs(dy) * 2.0 } private func rubberBandedTranslation(_ translation: CGFloat) -> CGFloat { From 4f1e98ec7e2aeef11d044fe7530227f6d8c613eb Mon Sep 17 00:00:00 2001 From: pgs666 <74764545+pgs666@users.noreply.github.com> Date: Tue, 2 Jun 2026 05:29:25 +0800 Subject: [PATCH 014/216] fix(video): stabilize refresh and tab collapse --- iosApp/CommentView.swift | 11 +-- iosApp/VideoDetailView.swift | 168 +++++++++++++++++++++++++++-------- 2 files changed, 136 insertions(+), 43 deletions(-) diff --git a/iosApp/CommentView.swift b/iosApp/CommentView.swift index 858d5727..a75015cc 100644 --- a/iosApp/CommentView.swift +++ b/iosApp/CommentView.swift @@ -2,7 +2,7 @@ import SwiftUI import Han1meShared struct CommentView: View { - @StateObject private var viewModel: CommentViewModel + @ObservedObject private var viewModel: CommentViewModel @State private var composeText = "" @State private var replyTarget: CommentRow? @State private var replyText = "" @@ -10,10 +10,8 @@ struct CommentView: View { @State private var repliesTarget: CommentRow? @State private var isShowingComposer = false - init(videoCode: String, commentFeature: CommentFeature) { - _viewModel = StateObject( - wrappedValue: CommentViewModel(feature: commentFeature, videoCode: videoCode) - ) + init(viewModel: CommentViewModel) { + _viewModel = ObservedObject(wrappedValue: viewModel) } var body: some View { @@ -22,9 +20,6 @@ struct CommentView: View { content } .padding(.horizontal, 16) - .refreshable { - await viewModel.refresh() - } .task { viewModel.loadIfNeeded() } diff --git a/iosApp/VideoDetailView.swift b/iosApp/VideoDetailView.swift index 14c06127..e0876166 100644 --- a/iosApp/VideoDetailView.swift +++ b/iosApp/VideoDetailView.swift @@ -7,10 +7,12 @@ struct VideoDetailView: View { private let videoFeature: VideoFeature private let commentFeature: CommentFeature @StateObject private var viewModel: VideoDetailViewModel + @StateObject private var commentViewModel: CommentViewModel @State private var selectedTab = VideoPageTab.introduction @State private var isPlayerFullscreen = false @State private var isPlayerCollapsed = false @State private var horizontalPagerExclusionFrames: [CGRect] = [] + @State private var gestureCoordinator = VideoDetailGestureCoordinator() /// True iff the player is currently playing (not paused / buffering). /// Driven from KSPlayerView via the @Binding below. Used to lock the /// player at full 16:9 height while playing — only paused state lets the @@ -45,6 +47,9 @@ struct VideoDetailView: View { self.videoFeature = videoFeature self.commentFeature = commentFeature _viewModel = StateObject(wrappedValue: VideoDetailViewModel(videoFeature: videoFeature)) + _commentViewModel = StateObject( + wrappedValue: CommentViewModel(feature: commentFeature, videoCode: videoCode) + ) } var body: some View { @@ -75,13 +80,6 @@ struct VideoDetailView: View { .task { viewModel.loadIfNeeded(videoCode: videoCode) } - .refreshable { - // Refresh without the full-screen .loading spinner flash: - // keep the current content visible while re-fetching. The - // player is rebuilt on success (acceptable for an explicit - // refresh); this just removes the abrupt blank-out. - await viewModel.refresh(videoCode: videoCode) - } .onDisappear { // KSPlayer pauses itself in its own .onDisappear; the // detail VM no longer owns a player. @@ -342,6 +340,7 @@ struct VideoDetailView: View { VideoDetailTabPager( selectedTab: $selectedTab, + gestureCoordinator: gestureCoordinator, excludedDragStartFrames: horizontalPagerExclusionFrames ) { tabScroll(.introduction) { @@ -359,14 +358,24 @@ struct VideoDetailView: View { ) .padding(.top, 16) } collapseCompensation: { - collapseCompensation + tabCollapseCompensation(for: .introduction, collapseCompensation: collapseCompensation) + } collapseDistance: { + collapseDistance + } refreshAction: { + guard !gestureCoordinator.isHorizontalPagingActive else { return } + await viewModel.refresh(videoCode: videoCode) } } comments: { tabScroll(.comments) { - CommentView(videoCode: videoCode, commentFeature: commentFeature) + CommentView(viewModel: commentViewModel) .padding(.top, 16) } collapseCompensation: { - collapseCompensation + tabCollapseCompensation(for: .comments, collapseCompensation: collapseCompensation) + } collapseDistance: { + collapseDistance + } refreshAction: { + guard !gestureCoordinator.isHorizontalPagingActive else { return } + await commentViewModel.refresh() } } .frame(maxHeight: .infinity) @@ -377,6 +386,9 @@ struct VideoDetailView: View { for (tab, offset) in offsets { bottomScrollOffsetsByTab[tab] = offset } + guard !gestureCoordinator.isHorizontalPagingActive else { + return + } let activeOffset = bottomScrollOffsetsByTab[selectedTab] ?? 0 updatePlayerCollapseOffset( activeTabOffset: activeOffset, @@ -384,18 +396,9 @@ struct VideoDetailView: View { collapseDistance: collapseDistance ) } - .onValueChange(of: selectedTab) { newTab in + .onValueChange(of: selectedTab) { _ in lastSelectedTabChangeAt = Date() - let newTabOffset = max(0, bottomScrollOffsetsByTab[newTab] ?? 0) - // Keep the player collapsed across horizontal tab switches. If - // the destination tab is already scrolled farther, collapse more; - // never expand merely because the destination tab is at offset 0. - let targetOffset = min(collapseDistance, max(bottomScrollOffset, newTabOffset)) - if targetOffset != bottomScrollOffset { - withAnimation(.interactiveSpring(response: 0.28, dampingFraction: 0.86)) { - bottomScrollOffset = targetOffset - } - } + bottomScrollOffset = min(max(bottomScrollOffset, 0), collapseDistance) } .onPreferenceChange(HorizontalPagerExclusionFramePreferenceKey.self) { frames in horizontalPagerExclusionFrames = frames @@ -419,24 +422,43 @@ struct VideoDetailView: View { private func tabScroll( _ tab: VideoPageTab, @ViewBuilder content: @escaping () -> Content, - collapseCompensation: @escaping () -> CGFloat = { 0 } + collapseCompensation: @escaping () -> CGFloat = { 0 }, + collapseDistance: @escaping () -> CGFloat = { 0 }, + refreshAction: (() async -> Void)? = nil ) -> some View { - ScrollView { - GeometryReader { proxy in - Color.clear.preference( - key: BottomScrollOffsetPreferenceKey.self, - value: [tab: -proxy.frame(in: .named(tab.scrollCoordinateSpaceName)).minY] - ) + GeometryReader { proxy in + let minScrollableContentHeight = proxy.size.height + collapseDistance() + 1 + let scrollView = ScrollView { + GeometryReader { proxy in + Color.clear.preference( + key: BottomScrollOffsetPreferenceKey.self, + value: [tab: -proxy.frame(in: .named(tab.scrollCoordinateSpaceName)).minY] + ) + } + .frame(height: 0) + + content() + .frame(maxWidth: .infinity, minHeight: minScrollableContentHeight, alignment: .top) + .padding(.bottom, 24) + .offset(y: collapseCompensation()) + .padding(.bottom, collapseCompensation()) } - .frame(height: 0) + .coordinateSpace(name: tab.scrollCoordinateSpaceName) + .background(VideoDetailScrollViewConfigurator(coordinator: gestureCoordinator)) + .id(tab) - content() - .padding(.bottom, 24) - .offset(y: collapseCompensation()) - .padding(.bottom, collapseCompensation()) + if let refreshAction { + scrollView.refreshable { + await refreshAction() + } + } else { + scrollView + } } - .coordinateSpace(name: tab.scrollCoordinateSpaceName) - .id(tab) + } + + private func tabCollapseCompensation(for tab: VideoPageTab, collapseCompensation: CGFloat) -> CGFloat { + min(max(bottomScrollOffsetsByTab[tab] ?? 0, 0), collapseCompensation) } } @@ -460,6 +482,10 @@ private enum VideoPlayerCollapseModel { let clampedCurrent = clamp(currentCollapseOffset, upperBound: collapseDistance) let nonnegativeActiveOffset = max(0, activeTabOffset) + if switchedTabsRecently { + return clampedCurrent + } + guard let previousActiveTabOffset else { return min(collapseDistance, max(clampedCurrent, nonnegativeActiveOffset)) } @@ -521,6 +547,7 @@ private enum VideoPageTab: String, CaseIterable, Identifiable { private struct VideoDetailTabPager: View { @Binding var selectedTab: VideoPageTab + let gestureCoordinator: VideoDetailGestureCoordinator let excludedDragStartFrames: [CGRect] let introduction: () -> Introduction let comments: () -> Comments @@ -529,11 +556,13 @@ private struct VideoDetailTabPager: View { init( selectedTab: Binding, + gestureCoordinator: VideoDetailGestureCoordinator, excludedDragStartFrames: [CGRect], @ViewBuilder introduction: @escaping () -> Introduction, @ViewBuilder comments: @escaping () -> Comments ) { _selectedTab = selectedTab + self.gestureCoordinator = gestureCoordinator self.excludedDragStartFrames = excludedDragStartFrames self.introduction = introduction self.comments = comments @@ -557,6 +586,7 @@ private struct VideoDetailTabPager: View { private var horizontalPagingGesture: some Gesture { DragGesture(minimumDistance: 28, coordinateSpace: .local) .onChanged { value in + updateHorizontalPagingState(value) guard isHorizontalPagingDrag(value) else { dragTranslation = 0 return @@ -572,6 +602,7 @@ private struct VideoDetailTabPager: View { withAnimation(.interactiveSpring(response: 0.28, dampingFraction: 0.86)) { dragTranslation = 0 } + gestureCoordinator.setHorizontalPagingActive(false) } guard isHorizontalPagingDrag(value) else { return } @@ -605,6 +636,17 @@ private struct VideoDetailTabPager: View { return abs(dx) > 36 && abs(dx) > abs(dy) * 2.0 } + private func updateHorizontalPagingState(_ value: DragGesture.Value) { + let dx = value.translation.width + let dy = value.translation.height + guard abs(dx) > 10 && abs(dx) > abs(dy) * 1.35 else { return } + guard value.startLocation.x > 24 else { return } + guard !excludedDragStartFrames.contains(where: { $0.contains(value.startLocation) }) else { + return + } + gestureCoordinator.setHorizontalPagingActive(true) + } + private func rubberBandedTranslation(_ translation: CGFloat) -> CGFloat { let index = selectedTab.pageIndex let isPullingBeforeFirst = index == 0 && translation > 0 @@ -616,6 +658,62 @@ private struct VideoDetailTabPager: View { } } +private final class VideoDetailGestureCoordinator { + private var scrollViews = NSHashTable.weakObjects() + private(set) var isHorizontalPagingActive = false + + func register(scrollView: UIScrollView) { + scrollViews.add(scrollView) + scrollView.isDirectionalLockEnabled = true + scrollView.refreshControl?.isEnabled = !isHorizontalPagingActive + } + + func setHorizontalPagingActive(_ isActive: Bool) { + guard isHorizontalPagingActive != isActive else { return } + isHorizontalPagingActive = isActive + for scrollView in scrollViews.allObjects { + scrollView.refreshControl?.isEnabled = !isActive + } + } +} + +private struct VideoDetailScrollViewConfigurator: UIViewRepresentable { + let coordinator: VideoDetailGestureCoordinator + + func makeUIView(context: Context) -> UIView { + let view = UIView(frame: .zero) + view.isUserInteractionEnabled = false + DispatchQueue.main.async { + configureScrollView(near: view) + } + return view + } + + func updateUIView(_ uiView: UIView, context: Context) { + DispatchQueue.main.async { + configureScrollView(near: uiView) + } + } + + private func configureScrollView(near view: UIView) { + guard let scrollView = view.firstSuperview(of: UIScrollView.self) else { return } + coordinator.register(scrollView: scrollView) + } +} + +private extension UIView { + func firstSuperview(of type: T.Type) -> T? { + var view = superview + while let current = view { + if let match = current as? T { + return match + } + view = current.superview + } + return nil + } +} + private enum VideoDetailPagerCoordinateSpace { static let name = "videoDetailPager" } From e4c7b638bf453bb4a191472e0fe23bae7bca3b35 Mon Sep 17 00:00:00 2001 From: pgs666 <74764545+pgs666@users.noreply.github.com> Date: Tue, 2 Jun 2026 05:43:10 +0800 Subject: [PATCH 015/216] fix(video): remove detail tab pull refresh --- iosApp/VideoDetailView.swift | 68 ++---------------------------------- 1 file changed, 2 insertions(+), 66 deletions(-) diff --git a/iosApp/VideoDetailView.swift b/iosApp/VideoDetailView.swift index e0876166..2fc53081 100644 --- a/iosApp/VideoDetailView.swift +++ b/iosApp/VideoDetailView.swift @@ -361,9 +361,6 @@ struct VideoDetailView: View { tabCollapseCompensation(for: .introduction, collapseCompensation: collapseCompensation) } collapseDistance: { collapseDistance - } refreshAction: { - guard !gestureCoordinator.isHorizontalPagingActive else { return } - await viewModel.refresh(videoCode: videoCode) } } comments: { tabScroll(.comments) { @@ -373,9 +370,6 @@ struct VideoDetailView: View { tabCollapseCompensation(for: .comments, collapseCompensation: collapseCompensation) } collapseDistance: { collapseDistance - } refreshAction: { - guard !gestureCoordinator.isHorizontalPagingActive else { return } - await commentViewModel.refresh() } } .frame(maxHeight: .infinity) @@ -423,12 +417,11 @@ struct VideoDetailView: View { _ tab: VideoPageTab, @ViewBuilder content: @escaping () -> Content, collapseCompensation: @escaping () -> CGFloat = { 0 }, - collapseDistance: @escaping () -> CGFloat = { 0 }, - refreshAction: (() async -> Void)? = nil + collapseDistance: @escaping () -> CGFloat = { 0 } ) -> some View { GeometryReader { proxy in let minScrollableContentHeight = proxy.size.height + collapseDistance() + 1 - let scrollView = ScrollView { + ScrollView { GeometryReader { proxy in Color.clear.preference( key: BottomScrollOffsetPreferenceKey.self, @@ -444,16 +437,7 @@ struct VideoDetailView: View { .padding(.bottom, collapseCompensation()) } .coordinateSpace(name: tab.scrollCoordinateSpaceName) - .background(VideoDetailScrollViewConfigurator(coordinator: gestureCoordinator)) .id(tab) - - if let refreshAction { - scrollView.refreshable { - await refreshAction() - } - } else { - scrollView - } } } @@ -659,58 +643,10 @@ private struct VideoDetailTabPager: View { } private final class VideoDetailGestureCoordinator { - private var scrollViews = NSHashTable.weakObjects() private(set) var isHorizontalPagingActive = false - func register(scrollView: UIScrollView) { - scrollViews.add(scrollView) - scrollView.isDirectionalLockEnabled = true - scrollView.refreshControl?.isEnabled = !isHorizontalPagingActive - } - func setHorizontalPagingActive(_ isActive: Bool) { - guard isHorizontalPagingActive != isActive else { return } isHorizontalPagingActive = isActive - for scrollView in scrollViews.allObjects { - scrollView.refreshControl?.isEnabled = !isActive - } - } -} - -private struct VideoDetailScrollViewConfigurator: UIViewRepresentable { - let coordinator: VideoDetailGestureCoordinator - - func makeUIView(context: Context) -> UIView { - let view = UIView(frame: .zero) - view.isUserInteractionEnabled = false - DispatchQueue.main.async { - configureScrollView(near: view) - } - return view - } - - func updateUIView(_ uiView: UIView, context: Context) { - DispatchQueue.main.async { - configureScrollView(near: uiView) - } - } - - private func configureScrollView(near view: UIView) { - guard let scrollView = view.firstSuperview(of: UIScrollView.self) else { return } - coordinator.register(scrollView: scrollView) - } -} - -private extension UIView { - func firstSuperview(of type: T.Type) -> T? { - var view = superview - while let current = view { - if let match = current as? T { - return match - } - view = current.superview - } - return nil } } From f12c95928307de8e999333f4f2e2abff963d9704 Mon Sep 17 00:00:00 2001 From: pgs666 <74764545+pgs666@users.noreply.github.com> Date: Tue, 2 Jun 2026 05:52:48 +0800 Subject: [PATCH 016/216] fix(video): drive collapse by scroll delta --- iosApp/VideoDetailView.swift | 17 +++-------------- 1 file changed, 3 insertions(+), 14 deletions(-) diff --git a/iosApp/VideoDetailView.swift b/iosApp/VideoDetailView.swift index 2fc53081..f37027d8 100644 --- a/iosApp/VideoDetailView.swift +++ b/iosApp/VideoDetailView.swift @@ -464,30 +464,19 @@ private enum VideoPlayerCollapseModel { switchedTabsRecently: Bool ) -> CGFloat { let clampedCurrent = clamp(currentCollapseOffset, upperBound: collapseDistance) - let nonnegativeActiveOffset = max(0, activeTabOffset) if switchedTabsRecently { return clampedCurrent } guard let previousActiveTabOffset else { + let nonnegativeActiveOffset = max(0, activeTabOffset) return min(collapseDistance, max(clampedCurrent, nonnegativeActiveOffset)) } let delta = activeTabOffset - previousActiveTabOffset - if delta > 0.5 { - if nonnegativeActiveOffset >= clampedCurrent { - return min(collapseDistance, nonnegativeActiveOffset) - } - return clampedCurrent - } - - if delta < -0.5 && activeTabOffset <= 0 { - return min(collapseDistance, max(0, clampedCurrent + delta)) - } - - if delta < -0.5 && !switchedTabsRecently { - return min(collapseDistance, nonnegativeActiveOffset) + if abs(delta) > 0.5 { + return clamp(clampedCurrent + delta, upperBound: collapseDistance) } return clampedCurrent From d583aa5467b2cc695fa39395291b6ff230ac2ce7 Mon Sep 17 00:00:00 2001 From: pgs666 <74764545+pgs666@users.noreply.github.com> Date: Tue, 2 Jun 2026 06:16:55 +0800 Subject: [PATCH 017/216] fix(video): disable detail scroll bounce --- iosApp/VideoDetailView.swift | 38 ++++++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/iosApp/VideoDetailView.swift b/iosApp/VideoDetailView.swift index f37027d8..225fec15 100644 --- a/iosApp/VideoDetailView.swift +++ b/iosApp/VideoDetailView.swift @@ -437,6 +437,7 @@ struct VideoDetailView: View { .padding(.bottom, collapseCompensation()) } .coordinateSpace(name: tab.scrollCoordinateSpaceName) + .background(VideoDetailScrollBounceDisabler()) .id(tab) } } @@ -639,6 +640,43 @@ private final class VideoDetailGestureCoordinator { } } +private struct VideoDetailScrollBounceDisabler: UIViewRepresentable { + func makeUIView(context: Context) -> UIView { + let view = UIView(frame: .zero) + view.isUserInteractionEnabled = false + DispatchQueue.main.async { + disableBounce(near: view) + } + return view + } + + func updateUIView(_ uiView: UIView, context: Context) { + DispatchQueue.main.async { + disableBounce(near: uiView) + } + } + + private func disableBounce(near view: UIView) { + guard let scrollView = view.firstSuperview(of: UIScrollView.self) else { return } + scrollView.bounces = false + scrollView.alwaysBounceVertical = false + scrollView.isDirectionalLockEnabled = true + } +} + +private extension UIView { + func firstSuperview(of type: T.Type) -> T? { + var view = superview + while let current = view { + if let match = current as? T { + return match + } + view = current.superview + } + return nil + } +} + private enum VideoDetailPagerCoordinateSpace { static let name = "videoDetailPager" } From 9a6f072484f31f857fa7873a008a823497234839 Mon Sep 17 00:00:00 2001 From: pgs666 <74764545+pgs666@users.noreply.github.com> Date: Tue, 2 Jun 2026 06:33:08 +0800 Subject: [PATCH 018/216] fix(video): harden detail gestures --- iosApp/CommentView.swift | 51 +++-- iosApp/RelatedVideoComponents.swift | 20 +- iosApp/VideoDetailView.swift | 305 ++++++++++++++++++---------- 3 files changed, 241 insertions(+), 135 deletions(-) diff --git a/iosApp/CommentView.swift b/iosApp/CommentView.swift index a75015cc..b61d4029 100644 --- a/iosApp/CommentView.swift +++ b/iosApp/CommentView.swift @@ -96,22 +96,31 @@ struct CommentView: View { } } .pickerStyle(.menu) + .horizontalPagerExclusionArea() Spacer() - Button { + TapOnlyControl { isShowingComposer = true } label: { Label("评论", systemImage: "square.and.pencil") + .font(.subheadline.weight(.semibold)) + .foregroundStyle(.white) + .padding(.horizontal, 12) + .padding(.vertical, 7) + .background(Color.accentColor, in: RoundedRectangle(cornerRadius: 8, style: .continuous)) } - .buttonStyle(.borderedProminent) - Button { + TapOnlyControl { viewModel.load() } label: { Image(systemName: "arrow.clockwise") + .font(.subheadline.weight(.semibold)) + .foregroundStyle(Color.accentColor) + .padding(.horizontal, 12) + .padding(.vertical, 7) + .background(Color.accentColor.opacity(0.12), in: RoundedRectangle(cornerRadius: 8, style: .continuous)) } - .buttonStyle(.bordered) } } @@ -136,11 +145,18 @@ struct CommentView: View { .font(.subheadline) .foregroundStyle(.secondary) .multilineTextAlignment(.center) - Button("重试") { + TapOnlyControl { viewModel.load() + } label: { + Text("重试") + .font(.subheadline.weight(.semibold)) + .foregroundStyle(.white) + .padding(.horizontal, 14) + .padding(.vertical, 8) + .background(Color.accentColor, in: RoundedRectangle(cornerRadius: 8, style: .continuous)) } - .buttonStyle(.borderedProminent) CloudflareVerifyButton(errorMessage: message) + .horizontalPagerExclusionArea() } .frame(maxWidth: .infinity) .padding(.vertical, 60) @@ -226,9 +242,7 @@ private struct CommentRowView: View { .font(.caption) .foregroundStyle(.secondary) Spacer() - Menu { - Button("举报", role: .destructive, action: onReport) - } label: { + TapOnlyControl(action: onReport) { Image(systemName: "ellipsis") .foregroundStyle(.secondary) } @@ -239,24 +253,29 @@ private struct CommentRowView: View { .textSelection(.enabled) HStack(spacing: 14) { - Button(action: onLike) { + TapOnlyControl(isDisabled: isRunningLike) { + onLike() + } label: { Label("\(comment.thumbUp ?? 0)", systemImage: comment.likeCommentStatus ? "hand.thumbsup.fill" : "hand.thumbsup") } - .disabled(isRunningLike) - Button(action: onDislike) { + TapOnlyControl(isDisabled: isRunningLike) { + onDislike() + } label: { Image(systemName: comment.unlikeCommentStatus ? "hand.thumbsdown.fill" : "hand.thumbsdown") } - .disabled(isRunningLike) - Button("回复", action: onReply) + TapOnlyControl(action: onReply) { + Text("回复") + } if comment.hasMoreReplies { - Button("查看 \(comment.replyCount ?? 0) 条回复", action: onShowReplies) + TapOnlyControl(action: onShowReplies) { + Text("查看 \(comment.replyCount ?? 0) 条回复") + } } } .font(.caption) - .buttonStyle(.plain) .foregroundStyle(.secondary) } } diff --git a/iosApp/RelatedVideoComponents.swift b/iosApp/RelatedVideoComponents.swift index f924c53e..14f1db36 100644 --- a/iosApp/RelatedVideoComponents.swift +++ b/iosApp/RelatedVideoComponents.swift @@ -10,6 +10,7 @@ struct HorizontalVideoSection: View { let showPlaying: Bool @State private var selectedVideo: VideoRelatedRow? + @State private var isShowingVideoList = false var body: some View { VStack(alignment: .leading, spacing: 10) { @@ -24,14 +25,8 @@ struct HorizontalVideoSection: View { } } Spacer() - NavigationLink { - RelatedVideoListView( - title: title, - videos: videos, - videoFeature: videoFeature, - commentFeature: commentFeature, - showPlaying: showPlaying - ) + TapOnlyControl { + isShowingVideoList = true } label: { Text("更多") .font(.caption.weight(.semibold)) @@ -50,6 +45,15 @@ struct HorizontalVideoSection: View { } } } + .navigationDestination(isPresented: $isShowingVideoList) { + RelatedVideoListView( + title: title, + videos: videos, + videoFeature: videoFeature, + commentFeature: commentFeature, + showPlaying: showPlaying + ) + } .navigationDestination( isPresented: Binding( get: { selectedVideo != nil }, diff --git a/iosApp/VideoDetailView.swift b/iosApp/VideoDetailView.swift index 225fec15..fc19f43c 100644 --- a/iosApp/VideoDetailView.swift +++ b/iosApp/VideoDetailView.swift @@ -641,39 +641,67 @@ private final class VideoDetailGestureCoordinator { } private struct VideoDetailScrollBounceDisabler: UIViewRepresentable { + func makeCoordinator() -> Coordinator { + Coordinator() + } + func makeUIView(context: Context) -> UIView { let view = UIView(frame: .zero) view.isUserInteractionEnabled = false - DispatchQueue.main.async { - disableBounce(near: view) - } + context.coordinator.scheduleDisableBounce(near: view) return view } func updateUIView(_ uiView: UIView, context: Context) { - DispatchQueue.main.async { - disableBounce(near: uiView) - } + context.coordinator.scheduleDisableBounce(near: uiView) } - private func disableBounce(near view: UIView) { - guard let scrollView = view.firstSuperview(of: UIScrollView.self) else { return } - scrollView.bounces = false - scrollView.alwaysBounceVertical = false - scrollView.isDirectionalLockEnabled = true + final class Coordinator { + private var generation = 0 + private var scheduledRetryCount = 0 + + func scheduleDisableBounce(near view: UIView) { + generation += 1 + scheduledRetryCount = 0 + disableBounce(near: view) + retryDisableBounce(near: view, generation: generation) + } + + private func retryDisableBounce(near view: UIView, generation: Int) { + guard scheduledRetryCount < 8 else { return } + scheduledRetryCount += 1 + DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { [weak view] in + guard generation == self.generation else { return } + guard let view else { return } + self.disableBounce(near: view) + self.retryDisableBounce(near: view, generation: generation) + } + } + + private func disableBounce(near view: UIView) { + let scrollViews = view.enclosingScrollViews() + guard !scrollViews.isEmpty else { return } + for scrollView in scrollViews { + scrollView.bounces = false + scrollView.alwaysBounceVertical = false + scrollView.alwaysBounceHorizontal = false + scrollView.isDirectionalLockEnabled = true + } + } } } private extension UIView { - func firstSuperview(of type: T.Type) -> T? { + func enclosingScrollViews() -> [UIScrollView] { + var scrollViews: [UIScrollView] = [] var view = superview while let current = view { - if let match = current as? T { - return match + if let scrollView = current as? UIScrollView { + scrollViews.append(scrollView) } view = current.superview } - return nil + return scrollViews } } @@ -699,6 +727,43 @@ private struct HorizontalPagerExclusionFrameReader: View { } } +extension View { + func horizontalPagerExclusionArea() -> some View { + background(HorizontalPagerExclusionFrameReader()) + } +} + +struct TapOnlyControl: View { + let isDisabled: Bool + let action: () -> Void + let label: () -> Label + + init( + isDisabled: Bool = false, + action: @escaping () -> Void, + @ViewBuilder label: @escaping () -> Label + ) { + self.isDisabled = isDisabled + self.action = action + self.label = label + } + + var body: some View { + label() + .opacity(isDisabled ? 0.45 : 1) + .contentShape(Rectangle()) + .onTapGesture { + guard !isDisabled else { return } + action() + } + .accessibilityAddTraits(.isButton) + .accessibilityAction { + guard !isDisabled else { return } + action() + } + } +} + /// Horizontally pages two tab bodies while reporting only ONE page's width to /// the parent vertical ScrollView. A plain HStack exposes its full 2× width /// during sizeThatFits, which can make nested lazy grids compute enormous @@ -807,7 +872,7 @@ private struct AndroidStyleIntroduction: View { commentFeature: commentFeature, showPlaying: true ) - .background(HorizontalPagerExclusionFrameReader()) + .horizontalPagerExclusionArea() } if showsRelated && !snapshot.relatedVideos.isEmpty { @@ -830,6 +895,7 @@ private struct ArtistCard: View { let commentFeature: CommentFeature @Environment(\.searchFeature) private var searchFeature @State private var isConfirmingUnsubscribe = false + @State private var isShowingArtistVideos = false var body: some View { HStack(spacing: 12) { @@ -841,7 +907,7 @@ private struct ArtistCard: View { Spacer() - Button { + TapOnlyControl(isDisabled: isRunning) { if artist.isSubscribed { isConfirmingUnsubscribe = true } else { @@ -855,8 +921,6 @@ private struct ArtistCard: View { .padding(.vertical, 7) .background(Color.accentColor.opacity(0.14), in: Capsule()) } - .disabled(isRunning) - .buttonStyle(.plain) } .padding(12) .background(Color(.secondarySystemGroupedBackground), in: RoundedRectangle(cornerRadius: 12, style: .continuous)) @@ -868,6 +932,16 @@ private struct ArtistCard: View { } message: { Text("确定要取消订阅吗?") } + .navigationDestination(isPresented: $isShowingArtistVideos) { + if let searchFeature { + ArtistVideosView( + artistName: artist.name, + searchFeature: searchFeature, + videoFeature: videoFeature, + commentFeature: commentFeature + ) + } + } } /// Wraps the avatar + name + genre block in a NavigationLink that pushes @@ -876,18 +950,12 @@ private struct ArtistCard: View { /// production but keeps the view robust during previews / testing). @ViewBuilder private var artistInfoTappable: some View { - if let searchFeature { - NavigationLink { - ArtistVideosView( - artistName: artist.name, - searchFeature: searchFeature, - videoFeature: videoFeature, - commentFeature: commentFeature - ) + if searchFeature != nil { + TapOnlyControl { + isShowingArtistVideos = true } label: { artistInfoLabel } - .buttonStyle(.plain) } else { artistInfoLabel } @@ -972,10 +1040,12 @@ private struct ExpandableDescription: View { .lineLimit(expanded ? nil : 4) .textSelection(.enabled) - Button(expanded ? String(localized: "收起") : String(localized: "展开")) { + TapOnlyControl { withAnimation(.easeInOut(duration: 0.18)) { expanded.toggle() } + } label: { + Text(expanded ? String(localized: "收起") : String(localized: "展开")) } .font(.caption.weight(.semibold)) } @@ -992,6 +1062,7 @@ private struct ActionButtonRow: View { let onDownload: (VideoPlaybackSourceRow) -> Void @Environment(\.openURL) private var openURL @State private var isShowingMyList = false + @State private var isShowingMoreActions = false @State private var isShowingShareSheet = false @State private var isShowingDownloadQuality = false @@ -1012,80 +1083,72 @@ private struct ActionButtonRow: View { } var body: some View { - ScrollView(.horizontal, showsIndicators: false) { - HStack(spacing: 10) { - LabelButton( - title: snapshot.isFav ? "已收藏" : "收藏", - systemImage: snapshot.isFav ? "heart.fill" : "heart", - action: onToggleFavorite - ) - - LabelButton( - title: snapshot.isWatchLater ? "已稍后" : "稍后观看", - systemImage: "text.badge.plus", - action: onToggleWatchLater - ) + HStack(spacing: 6) { + LabelButton( + title: snapshot.isFav ? "已收藏" : "收藏", + systemImage: snapshot.isFav ? "heart.fill" : "heart", + action: onToggleFavorite + ) - LabelButton( - title: "加入列表", - systemImage: "list.bullet", - action: { - if snapshot.myListItems.isEmpty { - onShowMessage(String(localized: "video.action.playlist.empty")) - } else { - isShowingMyList = true - } - } - ) + LabelButton( + title: snapshot.isWatchLater ? "已稍后" : "稍后观看", + systemImage: "text.badge.plus", + action: onToggleWatchLater + ) - LabelButton( - title: "下载", - systemImage: "arrow.down.circle", - action: { - if downloadableSources.isEmpty { - // No selectable resolutions parsed — defer to the - // site's official download page in the browser. - if let downloadURL { openURL(downloadURL) } - } else { - isShowingDownloadQuality = true - } - } - ) + LabelButton( + title: "更多", + systemImage: "ellipsis.circle", + action: { + isShowingMoreActions = true + } + ) - LabelButton( - title: "分享", - systemImage: "square.and.arrow.up", - action: { - if videoURL != nil { - isShowingShareSheet = true - } + LabelButton( + title: "分享", + systemImage: "square.and.arrow.up", + action: { + if videoURL != nil { + isShowingShareSheet = true } - ) + } + ) + } + .confirmationDialog("更多操作", isPresented: $isShowingMoreActions, titleVisibility: .visible) { + Button("加入列表") { + if snapshot.myListItems.isEmpty { + onShowMessage(String(localized: "video.action.playlist.empty")) + } else { + isShowingMyList = true + } + } - if snapshot.originalComic?.isEmpty == false { - LabelButton( - title: "原作漫画", - systemImage: "book", - action: { - if let originalComic = snapshot.originalComic, - let url = URL(string: originalComic) { - openURL(url) - } - } - ) + Button("下载") { + if downloadableSources.isEmpty { + // No selectable resolutions parsed — defer to the + // site's official download page in the browser. + if let downloadURL { openURL(downloadURL) } + } else { + isShowingDownloadQuality = true } + } - LabelButton( - title: "网页", - systemImage: "safari", - action: { - if let videoURL { - openURL(videoURL) - } + if snapshot.originalComic?.isEmpty == false { + Button("原作漫画") { + if let originalComic = snapshot.originalComic, + let url = URL(string: originalComic) { + openURL(url) } - ) + } } - .padding(.horizontal, 2) + + Button("网页") { + if let videoURL { + openURL(videoURL) + } + } + + Button("取消", role: .cancel) {} } .confirmationDialog("播放列表", isPresented: $isShowingMyList) { ForEach(snapshot.myListItems) { item in @@ -1133,10 +1196,10 @@ private struct LabelButton: View { var action: () -> Void = {} var body: some View { - Button(action: action) { + TapOnlyControl(action: action) { LabelButtonContent(title: title, systemImage: systemImage) } - .buttonStyle(.borderless) + .frame(maxWidth: .infinity) } } @@ -1152,8 +1215,7 @@ private struct LabelButtonContent: View { .font(.caption) .lineLimit(1) } - .frame(minWidth: 76) - .padding(.horizontal, 10) + .frame(maxWidth: .infinity) .padding(.vertical, 8) } } @@ -1163,6 +1225,7 @@ private struct TagFlow: View { let videoFeature: VideoFeature let commentFeature: CommentFeature @Environment(\.searchFeature) private var searchFeature + @State private var selectedTag: String? var body: some View { VStack(alignment: .leading, spacing: 8) { @@ -1174,32 +1237,52 @@ private struct TagFlow: View { // same column width. FlowLayout(spacing: 8, lineSpacing: 8) { ForEach(Array(tags.enumerated()), id: \.offset) { _, tag in - if let searchFeature { - NavigationLink { - ArtistVideosView( - title: "#\(tag)", - mode: .keyword(tag), - searchFeature: searchFeature, - videoFeature: videoFeature, - commentFeature: commentFeature - ) + if searchFeature != nil { + TapOnlyControl { + selectedTag = tag } label: { Text(tag).font(.caption) + .padding(.horizontal, 12) + .padding(.vertical, 7) + .background( + Capsule() + .strokeBorder(Color.accentColor.opacity(0.45), lineWidth: 1) + ) } - .buttonStyle(.bordered) } else { // Defensive: if the search feature isn't injected ( // which shouldn't happen in production) the tag still // renders as a disabled bordered chip rather than // disappearing entirely. - Button(tag) { /* no-op */ } + Text(tag) .font(.caption) - .buttonStyle(.bordered) - .disabled(true) + .padding(.horizontal, 12) + .padding(.vertical, 7) + .foregroundStyle(.secondary) + .background( + Capsule() + .strokeBorder(Color.secondary.opacity(0.25), lineWidth: 1) + ) } } } } + .navigationDestination( + isPresented: Binding( + get: { selectedTag != nil }, + set: { if !$0 { selectedTag = nil } } + ) + ) { + if let selectedTag, let searchFeature { + ArtistVideosView( + title: "#\(selectedTag)", + mode: .keyword(selectedTag), + searchFeature: searchFeature, + videoFeature: videoFeature, + commentFeature: commentFeature + ) + } + } } } From b1e92e72de5fd4ce778e6fedb8bfca14909c289f Mon Sep 17 00:00:00 2001 From: pgs666 <74764545+pgs666@users.noreply.github.com> Date: Tue, 2 Jun 2026 06:42:52 +0800 Subject: [PATCH 019/216] revert(video): remove detail bounce disabler --- iosApp/VideoDetailView.swift | 66 ------------------------------------ 1 file changed, 66 deletions(-) diff --git a/iosApp/VideoDetailView.swift b/iosApp/VideoDetailView.swift index fc19f43c..d1d29476 100644 --- a/iosApp/VideoDetailView.swift +++ b/iosApp/VideoDetailView.swift @@ -437,7 +437,6 @@ struct VideoDetailView: View { .padding(.bottom, collapseCompensation()) } .coordinateSpace(name: tab.scrollCoordinateSpaceName) - .background(VideoDetailScrollBounceDisabler()) .id(tab) } } @@ -640,71 +639,6 @@ private final class VideoDetailGestureCoordinator { } } -private struct VideoDetailScrollBounceDisabler: UIViewRepresentable { - func makeCoordinator() -> Coordinator { - Coordinator() - } - - func makeUIView(context: Context) -> UIView { - let view = UIView(frame: .zero) - view.isUserInteractionEnabled = false - context.coordinator.scheduleDisableBounce(near: view) - return view - } - - func updateUIView(_ uiView: UIView, context: Context) { - context.coordinator.scheduleDisableBounce(near: uiView) - } - - final class Coordinator { - private var generation = 0 - private var scheduledRetryCount = 0 - - func scheduleDisableBounce(near view: UIView) { - generation += 1 - scheduledRetryCount = 0 - disableBounce(near: view) - retryDisableBounce(near: view, generation: generation) - } - - private func retryDisableBounce(near view: UIView, generation: Int) { - guard scheduledRetryCount < 8 else { return } - scheduledRetryCount += 1 - DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { [weak view] in - guard generation == self.generation else { return } - guard let view else { return } - self.disableBounce(near: view) - self.retryDisableBounce(near: view, generation: generation) - } - } - - private func disableBounce(near view: UIView) { - let scrollViews = view.enclosingScrollViews() - guard !scrollViews.isEmpty else { return } - for scrollView in scrollViews { - scrollView.bounces = false - scrollView.alwaysBounceVertical = false - scrollView.alwaysBounceHorizontal = false - scrollView.isDirectionalLockEnabled = true - } - } - } -} - -private extension UIView { - func enclosingScrollViews() -> [UIScrollView] { - var scrollViews: [UIScrollView] = [] - var view = superview - while let current = view { - if let scrollView = current as? UIScrollView { - scrollViews.append(scrollView) - } - view = current.superview - } - return scrollViews - } -} - private enum VideoDetailPagerCoordinateSpace { static let name = "videoDetailPager" } From 1a1feb1d792577750b6f2975662cc50a93ef2536 Mon Sep 17 00:00:00 2001 From: pgs666 <74764545+pgs666@users.noreply.github.com> Date: Tue, 2 Jun 2026 06:49:32 +0800 Subject: [PATCH 020/216] fix(video): reliably disable detail scroll bounce --- iosApp/VideoDetailView.swift | 123 ++++++++++++++++++++++++++++------- 1 file changed, 99 insertions(+), 24 deletions(-) diff --git a/iosApp/VideoDetailView.swift b/iosApp/VideoDetailView.swift index d1d29476..6d853464 100644 --- a/iosApp/VideoDetailView.swift +++ b/iosApp/VideoDetailView.swift @@ -422,6 +422,9 @@ struct VideoDetailView: View { GeometryReader { proxy in let minScrollableContentHeight = proxy.size.height + collapseDistance() + 1 ScrollView { + BounceDisabledScrollViewConfigurator() + .frame(width: 0, height: 0) + GeometryReader { proxy in Color.clear.preference( key: BottomScrollOffsetPreferenceKey.self, @@ -446,6 +449,71 @@ struct VideoDetailView: View { } } +private struct BounceDisabledScrollViewConfigurator: UIViewRepresentable { + func makeUIView(context: Context) -> ConfiguringView { + let view = ConfiguringView() + view.isUserInteractionEnabled = false + return view + } + + func updateUIView(_ uiView: ConfiguringView, context: Context) { + uiView.scheduleConfiguration() + } + + final class ConfiguringView: UIView { + private var isConfigurationScheduled = false + private var remainingRetries = 12 + + override func didMoveToSuperview() { + super.didMoveToSuperview() + remainingRetries = 12 + scheduleConfiguration() + } + + override func didMoveToWindow() { + super.didMoveToWindow() + remainingRetries = 12 + scheduleConfiguration() + } + + override func layoutSubviews() { + super.layoutSubviews() + scheduleConfiguration() + } + + func scheduleConfiguration() { + guard !isConfigurationScheduled else { return } + isConfigurationScheduled = true + DispatchQueue.main.async { [weak self] in + guard let self else { return } + self.isConfigurationScheduled = false + if !self.configureNearestScrollView(), self.window != nil, self.remainingRetries > 0 { + self.remainingRetries -= 1 + DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { [weak self] in + self?.scheduleConfiguration() + } + } + } + } + + @discardableResult + private func configureNearestScrollView() -> Bool { + var current = superview + while let view = current { + if let scrollView = view as? UIScrollView { + scrollView.bounces = false + scrollView.alwaysBounceVertical = false + scrollView.alwaysBounceHorizontal = false + scrollView.isDirectionalLockEnabled = true + return true + } + current = view.superview + } + return false + } + } +} + /// Reports each tab-owned ScrollView's vertical offset from its top so the /// player area can shrink (B-station-style) based only on the active tab. private struct BottomScrollOffsetPreferenceKey: PreferenceKey { @@ -542,25 +610,27 @@ private struct VideoDetailTabPager: View { } var body: some View { - VideoDetailPagerLayout( - selectedIndex: selectedTab.pageIndex, - dragTranslation: dragTranslation - ) { - introduction() - comments() + GeometryReader { proxy in + let pagerGlobalOrigin = proxy.frame(in: .global).origin + VideoDetailPagerLayout( + selectedIndex: selectedTab.pageIndex, + dragTranslation: dragTranslation + ) { + introduction() + comments() + } + .animation(.interactiveSpring(response: 0.28, dampingFraction: 0.86), value: selectedTab) + .contentShape(Rectangle()) + .clipped() + .simultaneousGesture(horizontalPagingGesture(pagerGlobalOrigin: pagerGlobalOrigin)) } - .animation(.interactiveSpring(response: 0.28, dampingFraction: 0.86), value: selectedTab) - .contentShape(Rectangle()) - .clipped() - .coordinateSpace(name: VideoDetailPagerCoordinateSpace.name) - .simultaneousGesture(horizontalPagingGesture) } - private var horizontalPagingGesture: some Gesture { + private func horizontalPagingGesture(pagerGlobalOrigin: CGPoint) -> some Gesture { DragGesture(minimumDistance: 28, coordinateSpace: .local) .onChanged { value in - updateHorizontalPagingState(value) - guard isHorizontalPagingDrag(value) else { + updateHorizontalPagingState(value, pagerGlobalOrigin: pagerGlobalOrigin) + guard isHorizontalPagingDrag(value, pagerGlobalOrigin: pagerGlobalOrigin) else { dragTranslation = 0 return } @@ -578,7 +648,7 @@ private struct VideoDetailTabPager: View { gestureCoordinator.setHorizontalPagingActive(false) } - guard isHorizontalPagingDrag(value) else { return } + guard isHorizontalPagingDrag(value, pagerGlobalOrigin: pagerGlobalOrigin) else { return } let threshold: CGFloat = 72 let projected = value.predictedEndTranslation.width let currentIndex = selectedTab.pageIndex @@ -599,27 +669,36 @@ private struct VideoDetailTabPager: View { } } - private func isHorizontalPagingDrag(_ value: DragGesture.Value) -> Bool { + private func isHorizontalPagingDrag(_ value: DragGesture.Value, pagerGlobalOrigin: CGPoint) -> Bool { let dx = value.translation.width let dy = value.translation.height guard value.startLocation.x > 24 else { return false } - guard !excludedDragStartFrames.contains(where: { $0.contains(value.startLocation) }) else { + let globalStartLocation = globalStartLocation(value.startLocation, pagerGlobalOrigin: pagerGlobalOrigin) + guard !excludedDragStartFrames.contains(where: { $0.contains(globalStartLocation) }) else { return false } return abs(dx) > 36 && abs(dx) > abs(dy) * 2.0 } - private func updateHorizontalPagingState(_ value: DragGesture.Value) { + private func updateHorizontalPagingState(_ value: DragGesture.Value, pagerGlobalOrigin: CGPoint) { let dx = value.translation.width let dy = value.translation.height guard abs(dx) > 10 && abs(dx) > abs(dy) * 1.35 else { return } guard value.startLocation.x > 24 else { return } - guard !excludedDragStartFrames.contains(where: { $0.contains(value.startLocation) }) else { + let globalStartLocation = globalStartLocation(value.startLocation, pagerGlobalOrigin: pagerGlobalOrigin) + guard !excludedDragStartFrames.contains(where: { $0.contains(globalStartLocation) }) else { return } gestureCoordinator.setHorizontalPagingActive(true) } + private func globalStartLocation(_ startLocation: CGPoint, pagerGlobalOrigin: CGPoint) -> CGPoint { + CGPoint( + x: pagerGlobalOrigin.x + startLocation.x, + y: pagerGlobalOrigin.y + startLocation.y + ) + } + private func rubberBandedTranslation(_ translation: CGFloat) -> CGFloat { let index = selectedTab.pageIndex let isPullingBeforeFirst = index == 0 && translation > 0 @@ -639,10 +718,6 @@ private final class VideoDetailGestureCoordinator { } } -private enum VideoDetailPagerCoordinateSpace { - static let name = "videoDetailPager" -} - private struct HorizontalPagerExclusionFramePreferenceKey: PreferenceKey { static var defaultValue: [CGRect] = [] static func reduce(value: inout [CGRect], nextValue: () -> [CGRect]) { @@ -655,7 +730,7 @@ private struct HorizontalPagerExclusionFrameReader: View { GeometryReader { proxy in Color.clear.preference( key: HorizontalPagerExclusionFramePreferenceKey.self, - value: [proxy.frame(in: .named(VideoDetailPagerCoordinateSpace.name))] + value: [proxy.frame(in: .global)] ) } } From 3f26b1cd9907ff1496968c19ea160437417e5563 Mon Sep 17 00:00:00 2001 From: pgs666 <74764545+pgs666@users.noreply.github.com> Date: Tue, 2 Jun 2026 06:57:42 +0800 Subject: [PATCH 021/216] fix(video): lock vertical scroll during horizontal paging --- iosApp/VideoDetailView.swift | 44 ++++++++++++++++++++++++++++++++++-- 1 file changed, 42 insertions(+), 2 deletions(-) diff --git a/iosApp/VideoDetailView.swift b/iosApp/VideoDetailView.swift index 6d853464..90296591 100644 --- a/iosApp/VideoDetailView.swift +++ b/iosApp/VideoDetailView.swift @@ -422,7 +422,7 @@ struct VideoDetailView: View { GeometryReader { proxy in let minScrollableContentHeight = proxy.size.height + collapseDistance() + 1 ScrollView { - BounceDisabledScrollViewConfigurator() + BounceDisabledScrollViewConfigurator(gestureCoordinator: gestureCoordinator) .frame(width: 0, height: 0) GeometryReader { proxy in @@ -450,8 +450,10 @@ struct VideoDetailView: View { } private struct BounceDisabledScrollViewConfigurator: UIViewRepresentable { + let gestureCoordinator: VideoDetailGestureCoordinator + func makeUIView(context: Context) -> ConfiguringView { - let view = ConfiguringView() + let view = ConfiguringView(gestureCoordinator: gestureCoordinator) view.isUserInteractionEnabled = false return view } @@ -461,9 +463,20 @@ private struct BounceDisabledScrollViewConfigurator: UIViewRepresentable { } final class ConfiguringView: UIView { + private let gestureCoordinator: VideoDetailGestureCoordinator private var isConfigurationScheduled = false private var remainingRetries = 12 + init(gestureCoordinator: VideoDetailGestureCoordinator) { + self.gestureCoordinator = gestureCoordinator + super.init(frame: .zero) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + override func didMoveToSuperview() { super.didMoveToSuperview() remainingRetries = 12 @@ -505,6 +518,7 @@ private struct BounceDisabledScrollViewConfigurator: UIViewRepresentable { scrollView.alwaysBounceVertical = false scrollView.alwaysBounceHorizontal = false scrollView.isDirectionalLockEnabled = true + gestureCoordinator.registerVerticalScrollView(scrollView) return true } current = view.superview @@ -712,9 +726,35 @@ private struct VideoDetailTabPager: View { private final class VideoDetailGestureCoordinator { private(set) var isHorizontalPagingActive = false + private var verticalScrollViews: [WeakScrollView] = [] func setHorizontalPagingActive(_ isActive: Bool) { + guard isHorizontalPagingActive != isActive else { return } isHorizontalPagingActive = isActive + updateVerticalScrollAvailability() + } + + func registerVerticalScrollView(_ scrollView: UIScrollView) { + verticalScrollViews.removeAll { $0.scrollView == nil } + if !verticalScrollViews.contains(where: { $0.scrollView === scrollView }) { + verticalScrollViews.append(WeakScrollView(scrollView)) + } + scrollView.isScrollEnabled = !isHorizontalPagingActive + } + + private func updateVerticalScrollAvailability() { + verticalScrollViews.removeAll { $0.scrollView == nil } + for weakScrollView in verticalScrollViews { + weakScrollView.scrollView?.isScrollEnabled = !isHorizontalPagingActive + } + } + + private final class WeakScrollView { + weak var scrollView: UIScrollView? + + init(_ scrollView: UIScrollView) { + self.scrollView = scrollView + } } } From f5642fd4df3b191e91cedad9f86e2457f9165c0b Mon Sep 17 00:00:00 2001 From: pgs666 <74764545+pgs666@users.noreply.github.com> Date: Tue, 2 Jun 2026 07:04:59 +0800 Subject: [PATCH 022/216] fix(video): extend detail content to bottom edge --- iosApp/VideoDetailView.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/iosApp/VideoDetailView.swift b/iosApp/VideoDetailView.swift index 90296591..74d8cb3d 100644 --- a/iosApp/VideoDetailView.swift +++ b/iosApp/VideoDetailView.swift @@ -76,7 +76,7 @@ struct VideoDetailView: View { // again, producing the slide-in/out animation. .hidesTabBarOnAppear() .statusBarHidden(isPlayerFullscreen) - .ignoresSafeArea(edges: isPlayerFullscreen ? .all : []) + .ignoresSafeArea(edges: isPlayerFullscreen ? .all : .bottom) .task { viewModel.loadIfNeeded(videoCode: videoCode) } From 870a8eb8f97b16f0892aaa5be6fbb69efd24ee7d Mon Sep 17 00:00:00 2001 From: pgs666 <74764545+pgs666@users.noreply.github.com> Date: Tue, 2 Jun 2026 07:07:39 +0800 Subject: [PATCH 023/216] fix(video): avoid excess blank scroll filler --- iosApp/VideoDetailView.swift | 36 ++++++++++++++++++++++++++++++------ 1 file changed, 30 insertions(+), 6 deletions(-) diff --git a/iosApp/VideoDetailView.swift b/iosApp/VideoDetailView.swift index 74d8cb3d..0b61e2ec 100644 --- a/iosApp/VideoDetailView.swift +++ b/iosApp/VideoDetailView.swift @@ -420,9 +420,12 @@ struct VideoDetailView: View { collapseDistance: @escaping () -> CGFloat = { 0 } ) -> some View { GeometryReader { proxy in - let minScrollableContentHeight = proxy.size.height + collapseDistance() + 1 + let collapseScrollInset = collapseDistance() + 1 ScrollView { - BounceDisabledScrollViewConfigurator(gestureCoordinator: gestureCoordinator) + BounceDisabledScrollViewConfigurator( + gestureCoordinator: gestureCoordinator, + bottomInset: collapseScrollInset + ) .frame(width: 0, height: 0) GeometryReader { proxy in @@ -434,10 +437,9 @@ struct VideoDetailView: View { .frame(height: 0) content() - .frame(maxWidth: .infinity, minHeight: minScrollableContentHeight, alignment: .top) + .frame(maxWidth: .infinity, minHeight: proxy.size.height, alignment: .top) .padding(.bottom, 24) .offset(y: collapseCompensation()) - .padding(.bottom, collapseCompensation()) } .coordinateSpace(name: tab.scrollCoordinateSpaceName) .id(tab) @@ -451,24 +453,31 @@ struct VideoDetailView: View { private struct BounceDisabledScrollViewConfigurator: UIViewRepresentable { let gestureCoordinator: VideoDetailGestureCoordinator + let bottomInset: CGFloat func makeUIView(context: Context) -> ConfiguringView { - let view = ConfiguringView(gestureCoordinator: gestureCoordinator) + let view = ConfiguringView( + gestureCoordinator: gestureCoordinator, + bottomInset: bottomInset + ) view.isUserInteractionEnabled = false return view } func updateUIView(_ uiView: ConfiguringView, context: Context) { + uiView.updateBottomInset(bottomInset) uiView.scheduleConfiguration() } final class ConfiguringView: UIView { private let gestureCoordinator: VideoDetailGestureCoordinator + private var bottomInset: CGFloat private var isConfigurationScheduled = false private var remainingRetries = 12 - init(gestureCoordinator: VideoDetailGestureCoordinator) { + init(gestureCoordinator: VideoDetailGestureCoordinator, bottomInset: CGFloat) { self.gestureCoordinator = gestureCoordinator + self.bottomInset = bottomInset super.init(frame: .zero) } @@ -494,6 +503,10 @@ private struct BounceDisabledScrollViewConfigurator: UIViewRepresentable { scheduleConfiguration() } + func updateBottomInset(_ bottomInset: CGFloat) { + self.bottomInset = bottomInset + } + func scheduleConfiguration() { guard !isConfigurationScheduled else { return } isConfigurationScheduled = true @@ -518,6 +531,7 @@ private struct BounceDisabledScrollViewConfigurator: UIViewRepresentable { scrollView.alwaysBounceVertical = false scrollView.alwaysBounceHorizontal = false scrollView.isDirectionalLockEnabled = true + applyBottomInset(to: scrollView) gestureCoordinator.registerVerticalScrollView(scrollView) return true } @@ -525,6 +539,16 @@ private struct BounceDisabledScrollViewConfigurator: UIViewRepresentable { } return false } + + private func applyBottomInset(to scrollView: UIScrollView) { + var contentInset = scrollView.contentInset + contentInset.bottom = bottomInset + scrollView.contentInset = contentInset + + var indicatorInsets = scrollView.verticalScrollIndicatorInsets + indicatorInsets.bottom = bottomInset + scrollView.verticalScrollIndicatorInsets = indicatorInsets + } } } From 8aed8445c1f8692dba61e7c6b84c07b2ffcada64 Mon Sep 17 00:00:00 2001 From: pgs666 <74764545+pgs666@users.noreply.github.com> Date: Tue, 2 Jun 2026 07:21:12 +0800 Subject: [PATCH 024/216] fix(video): keep detail scroll insets stable --- iosApp/VideoDetailView.swift | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/iosApp/VideoDetailView.swift b/iosApp/VideoDetailView.swift index 0b61e2ec..a7d2214a 100644 --- a/iosApp/VideoDetailView.swift +++ b/iosApp/VideoDetailView.swift @@ -504,7 +504,9 @@ private struct BounceDisabledScrollViewConfigurator: UIViewRepresentable { } func updateBottomInset(_ bottomInset: CGFloat) { + guard abs(self.bottomInset - bottomInset) > 0.5 else { return } self.bottomInset = bottomInset + remainingRetries = 12 } func scheduleConfiguration() { @@ -513,7 +515,8 @@ private struct BounceDisabledScrollViewConfigurator: UIViewRepresentable { DispatchQueue.main.async { [weak self] in guard let self else { return } self.isConfigurationScheduled = false - if !self.configureNearestScrollView(), self.window != nil, self.remainingRetries > 0 { + self.configureNearestScrollView() + if self.window != nil, self.remainingRetries > 0 { self.remainingRetries -= 1 DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { [weak self] in self?.scheduleConfiguration() @@ -531,6 +534,7 @@ private struct BounceDisabledScrollViewConfigurator: UIViewRepresentable { scrollView.alwaysBounceVertical = false scrollView.alwaysBounceHorizontal = false scrollView.isDirectionalLockEnabled = true + scrollView.contentInsetAdjustmentBehavior = .never applyBottomInset(to: scrollView) gestureCoordinator.registerVerticalScrollView(scrollView) return true From 456fa4b94562008c2096cb6cb0fcc0068595f2f3 Mon Sep 17 00:00:00 2001 From: pgs666 <74764545+pgs666@users.noreply.github.com> Date: Tue, 2 Jun 2026 07:23:12 +0800 Subject: [PATCH 025/216] feat(comment): show end of list footer --- iosApp/CommentView.swift | 6 ++++++ iosApp/Localizable.xcstrings | 22 ++++++++++++++++++++++ 2 files changed, 28 insertions(+) diff --git a/iosApp/CommentView.swift b/iosApp/CommentView.swift index b61d4029..e820c71c 100644 --- a/iosApp/CommentView.swift +++ b/iosApp/CommentView.swift @@ -199,6 +199,12 @@ struct CommentView: View { } ) } + + Text("comment.no_more") + .font(.footnote) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity) + .padding(.vertical, 12) } } } diff --git a/iosApp/Localizable.xcstrings b/iosApp/Localizable.xcstrings index 72437eeb..f3d46e7e 100644 --- a/iosApp/Localizable.xcstrings +++ b/iosApp/Localizable.xcstrings @@ -6380,6 +6380,28 @@ } } } + }, + "comment.no_more": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "No more comments" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "没有更多了" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "沒有更多了" + } + } + } } }, "version": "1.0" From d188fbcc88da2040ee53e4411f95f86896e1a8df Mon Sep 17 00:00:00 2001 From: pgs666 <74764545+pgs666@users.noreply.github.com> Date: Tue, 2 Jun 2026 07:44:48 +0800 Subject: [PATCH 026/216] fix(video): gate vertical scroll with horizontal pan --- iosApp/VideoDetailView.swift | 277 ++++++++++++++++++++++++++--------- 1 file changed, 210 insertions(+), 67 deletions(-) diff --git a/iosApp/VideoDetailView.swift b/iosApp/VideoDetailView.swift index a7d2214a..424516a9 100644 --- a/iosApp/VideoDetailView.swift +++ b/iosApp/VideoDetailView.swift @@ -652,8 +652,7 @@ private struct VideoDetailTabPager: View { } var body: some View { - GeometryReader { proxy in - let pagerGlobalOrigin = proxy.frame(in: .global).origin + GeometryReader { _ in VideoDetailPagerLayout( selectedIndex: selectedTab.pageIndex, dragTranslation: dragTranslation @@ -664,81 +663,59 @@ private struct VideoDetailTabPager: View { .animation(.interactiveSpring(response: 0.28, dampingFraction: 0.86), value: selectedTab) .contentShape(Rectangle()) .clipped() - .simultaneousGesture(horizontalPagingGesture(pagerGlobalOrigin: pagerGlobalOrigin)) + .background( + HorizontalPagingPanGestureInstaller( + excludedDragStartFrames: excludedDragStartFrames, + onBegan: { + gestureCoordinator.setHorizontalPagingActive(true) + }, + onChanged: { translation in + var transaction = Transaction() + transaction.disablesAnimations = true + withTransaction(transaction) { + dragTranslation = rubberBandedTranslation(translation) + } + }, + onEnded: { translation, velocity in + finishHorizontalPaging(translation: translation, velocity: velocity) + }, + onCancelled: { + cancelHorizontalPaging() + } + ) + ) } } - private func horizontalPagingGesture(pagerGlobalOrigin: CGPoint) -> some Gesture { - DragGesture(minimumDistance: 28, coordinateSpace: .local) - .onChanged { value in - updateHorizontalPagingState(value, pagerGlobalOrigin: pagerGlobalOrigin) - guard isHorizontalPagingDrag(value, pagerGlobalOrigin: pagerGlobalOrigin) else { - dragTranslation = 0 - return - } - var transaction = Transaction() - transaction.disablesAnimations = true - withTransaction(transaction) { - dragTranslation = rubberBandedTranslation(value.translation.width) - } - } - .onEnded { value in - defer { - withAnimation(.interactiveSpring(response: 0.28, dampingFraction: 0.86)) { - dragTranslation = 0 - } - gestureCoordinator.setHorizontalPagingActive(false) - } - - guard isHorizontalPagingDrag(value, pagerGlobalOrigin: pagerGlobalOrigin) else { return } - let threshold: CGFloat = 72 - let projected = value.predictedEndTranslation.width - let currentIndex = selectedTab.pageIndex - let targetIndex: Int - - if projected < -threshold { - targetIndex = min(currentIndex + 1, VideoPageTab.allCases.count - 1) - } else if projected > threshold { - targetIndex = max(currentIndex - 1, 0) - } else { - targetIndex = currentIndex - } + private func finishHorizontalPaging(translation: CGFloat, velocity: CGFloat) { + defer { + cancelHorizontalPaging() + } - guard targetIndex != currentIndex else { return } - withAnimation(.interactiveSpring(response: 0.28, dampingFraction: 0.86)) { - selectedTab = .page(at: targetIndex) - } - } - } + let threshold: CGFloat = 72 + let projected = translation + velocity * 0.18 + let currentIndex = selectedTab.pageIndex + let targetIndex: Int - private func isHorizontalPagingDrag(_ value: DragGesture.Value, pagerGlobalOrigin: CGPoint) -> Bool { - let dx = value.translation.width - let dy = value.translation.height - guard value.startLocation.x > 24 else { return false } - let globalStartLocation = globalStartLocation(value.startLocation, pagerGlobalOrigin: pagerGlobalOrigin) - guard !excludedDragStartFrames.contains(where: { $0.contains(globalStartLocation) }) else { - return false + if projected < -threshold { + targetIndex = min(currentIndex + 1, VideoPageTab.allCases.count - 1) + } else if projected > threshold { + targetIndex = max(currentIndex - 1, 0) + } else { + targetIndex = currentIndex } - return abs(dx) > 36 && abs(dx) > abs(dy) * 2.0 - } - private func updateHorizontalPagingState(_ value: DragGesture.Value, pagerGlobalOrigin: CGPoint) { - let dx = value.translation.width - let dy = value.translation.height - guard abs(dx) > 10 && abs(dx) > abs(dy) * 1.35 else { return } - guard value.startLocation.x > 24 else { return } - let globalStartLocation = globalStartLocation(value.startLocation, pagerGlobalOrigin: pagerGlobalOrigin) - guard !excludedDragStartFrames.contains(where: { $0.contains(globalStartLocation) }) else { - return + guard targetIndex != currentIndex else { return } + withAnimation(.interactiveSpring(response: 0.28, dampingFraction: 0.86)) { + selectedTab = .page(at: targetIndex) } - gestureCoordinator.setHorizontalPagingActive(true) } - private func globalStartLocation(_ startLocation: CGPoint, pagerGlobalOrigin: CGPoint) -> CGPoint { - CGPoint( - x: pagerGlobalOrigin.x + startLocation.x, - y: pagerGlobalOrigin.y + startLocation.y - ) + private func cancelHorizontalPaging() { + withAnimation(.interactiveSpring(response: 0.28, dampingFraction: 0.86)) { + dragTranslation = 0 + } + gestureCoordinator.setHorizontalPagingActive(false) } private func rubberBandedTranslation(_ translation: CGFloat) -> CGFloat { @@ -752,6 +729,172 @@ private struct VideoDetailTabPager: View { } } +private struct HorizontalPagingPanGestureInstaller: UIViewRepresentable { + let excludedDragStartFrames: [CGRect] + let onBegan: () -> Void + let onChanged: (CGFloat) -> Void + let onEnded: (CGFloat, CGFloat) -> Void + let onCancelled: () -> Void + + func makeCoordinator() -> Coordinator { + Coordinator( + excludedDragStartFrames: excludedDragStartFrames, + onBegan: onBegan, + onChanged: onChanged, + onEnded: onEnded, + onCancelled: onCancelled + ) + } + + func makeUIView(context: Context) -> InstallingView { + let view = InstallingView(coordinator: context.coordinator) + view.isUserInteractionEnabled = false + return view + } + + func updateUIView(_ uiView: InstallingView, context: Context) { + context.coordinator.update( + excludedDragStartFrames: excludedDragStartFrames, + onBegan: onBegan, + onChanged: onChanged, + onEnded: onEnded, + onCancelled: onCancelled + ) + uiView.scheduleInstall() + } + + final class InstallingView: UIView { + private let coordinator: Coordinator + private weak var installedView: UIView? + + init(coordinator: Coordinator) { + self.coordinator = coordinator + super.init(frame: .zero) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func didMoveToSuperview() { + super.didMoveToSuperview() + scheduleInstall() + } + + override func didMoveToWindow() { + super.didMoveToWindow() + scheduleInstall() + } + + func scheduleInstall() { + DispatchQueue.main.async { [weak self] in + self?.installIfNeeded() + } + } + + private func installIfNeeded() { + guard let targetView = superview else { return } + guard installedView !== targetView else { return } + if let installedView { + installedView.removeGestureRecognizer(coordinator.panGestureRecognizer) + } + installedView = targetView + targetView.addGestureRecognizer(coordinator.panGestureRecognizer) + } + } + + final class Coordinator: NSObject, UIGestureRecognizerDelegate { + let panGestureRecognizer: UIPanGestureRecognizer + private var excludedDragStartFrames: [CGRect] + private var onBegan: () -> Void + private var onChanged: (CGFloat) -> Void + private var onEnded: (CGFloat, CGFloat) -> Void + private var onCancelled: () -> Void + + init( + excludedDragStartFrames: [CGRect], + onBegan: @escaping () -> Void, + onChanged: @escaping (CGFloat) -> Void, + onEnded: @escaping (CGFloat, CGFloat) -> Void, + onCancelled: @escaping () -> Void + ) { + self.excludedDragStartFrames = excludedDragStartFrames + self.onBegan = onBegan + self.onChanged = onChanged + self.onEnded = onEnded + self.onCancelled = onCancelled + self.panGestureRecognizer = UIPanGestureRecognizer() + super.init() + panGestureRecognizer.delegate = self + panGestureRecognizer.cancelsTouchesInView = true + panGestureRecognizer.delaysTouchesBegan = false + panGestureRecognizer.delaysTouchesEnded = false + panGestureRecognizer.addTarget(self, action: #selector(handlePan(_:))) + } + + func update( + excludedDragStartFrames: [CGRect], + onBegan: @escaping () -> Void, + onChanged: @escaping (CGFloat) -> Void, + onEnded: @escaping (CGFloat, CGFloat) -> Void, + onCancelled: @escaping () -> Void + ) { + self.excludedDragStartFrames = excludedDragStartFrames + self.onBegan = onBegan + self.onChanged = onChanged + self.onEnded = onEnded + self.onCancelled = onCancelled + } + + func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool { + guard let panGestureRecognizer = gestureRecognizer as? UIPanGestureRecognizer, + let view = gestureRecognizer.view else { + return false + } + let startLocation = panGestureRecognizer.location(in: view) + guard startLocation.x > 24 else { return false } + let globalStartLocation = view.convert(startLocation, to: nil) + guard !excludedDragStartFrames.contains(where: { $0.contains(globalStartLocation) }) else { + return false + } + + let translation = panGestureRecognizer.translation(in: view) + let velocity = panGestureRecognizer.velocity(in: view) + let horizontal = max(abs(translation.x), abs(velocity.x) * 0.05) + let vertical = max(abs(translation.y), abs(velocity.y) * 0.05) + return horizontal > 12 && horizontal > vertical * 1.35 + } + + func gestureRecognizer( + _ gestureRecognizer: UIGestureRecognizer, + shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer + ) -> Bool { + false + } + + @objc private func handlePan(_ recognizer: UIPanGestureRecognizer) { + guard let view = recognizer.view else { return } + switch recognizer.state { + case .began: + onBegan() + onChanged(recognizer.translation(in: view).x) + case .changed: + onChanged(recognizer.translation(in: view).x) + case .ended: + onEnded( + recognizer.translation(in: view).x, + recognizer.velocity(in: view).x + ) + case .cancelled, .failed: + onCancelled() + default: + break + } + } + } +} + private final class VideoDetailGestureCoordinator { private(set) var isHorizontalPagingActive = false private var verticalScrollViews: [WeakScrollView] = [] From bbff52d42de193ce901c1d81077c72c19ffa4e71 Mon Sep 17 00:00:00 2001 From: pgs666 <74764545+pgs666@users.noreply.github.com> Date: Tue, 2 Jun 2026 07:49:17 +0800 Subject: [PATCH 027/216] fix(video): restore reliable scroll range --- iosApp/VideoDetailView.swift | 39 +++++++----------------------------- 1 file changed, 7 insertions(+), 32 deletions(-) diff --git a/iosApp/VideoDetailView.swift b/iosApp/VideoDetailView.swift index 424516a9..95308acc 100644 --- a/iosApp/VideoDetailView.swift +++ b/iosApp/VideoDetailView.swift @@ -420,12 +420,9 @@ struct VideoDetailView: View { collapseDistance: @escaping () -> CGFloat = { 0 } ) -> some View { GeometryReader { proxy in - let collapseScrollInset = collapseDistance() + 1 + let collapseScrollSpacerHeight = collapseDistance() + 1 ScrollView { - BounceDisabledScrollViewConfigurator( - gestureCoordinator: gestureCoordinator, - bottomInset: collapseScrollInset - ) + BounceDisabledScrollViewConfigurator(gestureCoordinator: gestureCoordinator) .frame(width: 0, height: 0) GeometryReader { proxy in @@ -440,6 +437,9 @@ struct VideoDetailView: View { .frame(maxWidth: .infinity, minHeight: proxy.size.height, alignment: .top) .padding(.bottom, 24) .offset(y: collapseCompensation()) + + Color.clear + .frame(height: collapseScrollSpacerHeight) } .coordinateSpace(name: tab.scrollCoordinateSpaceName) .id(tab) @@ -453,31 +453,24 @@ struct VideoDetailView: View { private struct BounceDisabledScrollViewConfigurator: UIViewRepresentable { let gestureCoordinator: VideoDetailGestureCoordinator - let bottomInset: CGFloat func makeUIView(context: Context) -> ConfiguringView { - let view = ConfiguringView( - gestureCoordinator: gestureCoordinator, - bottomInset: bottomInset - ) + let view = ConfiguringView(gestureCoordinator: gestureCoordinator) view.isUserInteractionEnabled = false return view } func updateUIView(_ uiView: ConfiguringView, context: Context) { - uiView.updateBottomInset(bottomInset) uiView.scheduleConfiguration() } final class ConfiguringView: UIView { private let gestureCoordinator: VideoDetailGestureCoordinator - private var bottomInset: CGFloat private var isConfigurationScheduled = false private var remainingRetries = 12 - init(gestureCoordinator: VideoDetailGestureCoordinator, bottomInset: CGFloat) { + init(gestureCoordinator: VideoDetailGestureCoordinator) { self.gestureCoordinator = gestureCoordinator - self.bottomInset = bottomInset super.init(frame: .zero) } @@ -503,12 +496,6 @@ private struct BounceDisabledScrollViewConfigurator: UIViewRepresentable { scheduleConfiguration() } - func updateBottomInset(_ bottomInset: CGFloat) { - guard abs(self.bottomInset - bottomInset) > 0.5 else { return } - self.bottomInset = bottomInset - remainingRetries = 12 - } - func scheduleConfiguration() { guard !isConfigurationScheduled else { return } isConfigurationScheduled = true @@ -534,8 +521,6 @@ private struct BounceDisabledScrollViewConfigurator: UIViewRepresentable { scrollView.alwaysBounceVertical = false scrollView.alwaysBounceHorizontal = false scrollView.isDirectionalLockEnabled = true - scrollView.contentInsetAdjustmentBehavior = .never - applyBottomInset(to: scrollView) gestureCoordinator.registerVerticalScrollView(scrollView) return true } @@ -543,16 +528,6 @@ private struct BounceDisabledScrollViewConfigurator: UIViewRepresentable { } return false } - - private func applyBottomInset(to scrollView: UIScrollView) { - var contentInset = scrollView.contentInset - contentInset.bottom = bottomInset - scrollView.contentInset = contentInset - - var indicatorInsets = scrollView.verticalScrollIndicatorInsets - indicatorInsets.bottom = bottomInset - scrollView.verticalScrollIndicatorInsets = indicatorInsets - } } } From 4e95852d4f71d51d01a5cb87917860eda503272d Mon Sep 17 00:00:00 2001 From: pgs666 <74764545+pgs666@users.noreply.github.com> Date: Tue, 2 Jun 2026 16:27:23 +0800 Subject: [PATCH 028/216] fix(video): restore paging and playing scroll height --- iosApp/VideoDetailView.swift | 30 +++++++++++++++++++++++------- 1 file changed, 23 insertions(+), 7 deletions(-) diff --git a/iosApp/VideoDetailView.swift b/iosApp/VideoDetailView.swift index 95308acc..241cd261 100644 --- a/iosApp/VideoDetailView.swift +++ b/iosApp/VideoDetailView.swift @@ -217,6 +217,11 @@ struct VideoDetailView: View { parentHeight: proxy.size.height ) let currentPlayerShrink = playerVisualShrink(panelWidth: leftWidth) + let currentBottomScrollHeight = proxy.size.height - ( + isPlayerPlaying + ? currentPlayerHeight + : playerMinimumHeight(panelWidth: leftWidth) + ) playerArea(snapshot: snapshot) .frame( width: leftWidth, @@ -230,10 +235,10 @@ struct VideoDetailView: View { belowPlayerScroll( snapshot: snapshot, showsRelated: !isWide, - collapseDistance: currentPlayerCollapseDistance, - collapseCompensation: currentPlayerShrink + collapseDistance: isPlayerPlaying ? 0 : currentPlayerCollapseDistance, + collapseCompensation: isPlayerPlaying ? 0 : currentPlayerShrink ) - .frame(height: max(0, proxy.size.height - playerMinimumHeight(panelWidth: leftWidth))) + .frame(height: max(0, currentBottomScrollHeight)) .offset(y: currentPlayerHeight) } } @@ -741,6 +746,7 @@ private struct HorizontalPagingPanGestureInstaller: UIViewRepresentable { final class InstallingView: UIView { private let coordinator: Coordinator private weak var installedView: UIView? + private var remainingInstallRetries = 8 init(coordinator: Coordinator) { self.coordinator = coordinator @@ -754,28 +760,38 @@ private struct HorizontalPagingPanGestureInstaller: UIViewRepresentable { override func didMoveToSuperview() { super.didMoveToSuperview() + remainingInstallRetries = 8 scheduleInstall() } override func didMoveToWindow() { super.didMoveToWindow() + remainingInstallRetries = 8 scheduleInstall() } func scheduleInstall() { DispatchQueue.main.async { [weak self] in - self?.installIfNeeded() + guard let self else { return } + if !self.installIfNeeded(), self.window != nil, self.remainingInstallRetries > 0 { + self.remainingInstallRetries -= 1 + DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { [weak self] in + self?.scheduleInstall() + } + } } } - private func installIfNeeded() { - guard let targetView = superview else { return } - guard installedView !== targetView else { return } + @discardableResult + private func installIfNeeded() -> Bool { + guard let targetView = superview?.superview ?? superview else { return false } + guard installedView !== targetView else { return true } if let installedView { installedView.removeGestureRecognizer(coordinator.panGestureRecognizer) } installedView = targetView targetView.addGestureRecognizer(coordinator.panGestureRecognizer) + return true } } From 45c3f8dee9ae01f4516156f124f0b70855f97f7b Mon Sep 17 00:00:00 2001 From: pgs666 <74764545+pgs666@users.noreply.github.com> Date: Tue, 2 Jun 2026 16:49:27 +0800 Subject: [PATCH 029/216] fix(video): prioritize horizontal pager pan --- iosApp/VideoDetailView.swift | 37 ++++++++++++++++++++++++++++++++---- 1 file changed, 33 insertions(+), 4 deletions(-) diff --git a/iosApp/VideoDetailView.swift b/iosApp/VideoDetailView.swift index 241cd261..f0b9e348 100644 --- a/iosApp/VideoDetailView.swift +++ b/iosApp/VideoDetailView.swift @@ -661,6 +661,9 @@ private struct VideoDetailTabPager: View { }, onCancelled: { cancelHorizontalPaging() + }, + onInstalled: { panGestureRecognizer in + gestureCoordinator.registerHorizontalPagingPanGestureRecognizer(panGestureRecognizer) } ) ) @@ -715,6 +718,7 @@ private struct HorizontalPagingPanGestureInstaller: UIViewRepresentable { let onChanged: (CGFloat) -> Void let onEnded: (CGFloat, CGFloat) -> Void let onCancelled: () -> Void + let onInstalled: (UIPanGestureRecognizer) -> Void func makeCoordinator() -> Coordinator { Coordinator( @@ -722,7 +726,8 @@ private struct HorizontalPagingPanGestureInstaller: UIViewRepresentable { onBegan: onBegan, onChanged: onChanged, onEnded: onEnded, - onCancelled: onCancelled + onCancelled: onCancelled, + onInstalled: onInstalled ) } @@ -738,7 +743,8 @@ private struct HorizontalPagingPanGestureInstaller: UIViewRepresentable { onBegan: onBegan, onChanged: onChanged, onEnded: onEnded, - onCancelled: onCancelled + onCancelled: onCancelled, + onInstalled: onInstalled ) uiView.scheduleInstall() } @@ -791,6 +797,7 @@ private struct HorizontalPagingPanGestureInstaller: UIViewRepresentable { } installedView = targetView targetView.addGestureRecognizer(coordinator.panGestureRecognizer) + coordinator.notifyInstalled() return true } } @@ -802,19 +809,22 @@ private struct HorizontalPagingPanGestureInstaller: UIViewRepresentable { private var onChanged: (CGFloat) -> Void private var onEnded: (CGFloat, CGFloat) -> Void private var onCancelled: () -> Void + private var onInstalled: (UIPanGestureRecognizer) -> Void init( excludedDragStartFrames: [CGRect], onBegan: @escaping () -> Void, onChanged: @escaping (CGFloat) -> Void, onEnded: @escaping (CGFloat, CGFloat) -> Void, - onCancelled: @escaping () -> Void + onCancelled: @escaping () -> Void, + onInstalled: @escaping (UIPanGestureRecognizer) -> Void ) { self.excludedDragStartFrames = excludedDragStartFrames self.onBegan = onBegan self.onChanged = onChanged self.onEnded = onEnded self.onCancelled = onCancelled + self.onInstalled = onInstalled self.panGestureRecognizer = UIPanGestureRecognizer() super.init() panGestureRecognizer.delegate = self @@ -829,13 +839,19 @@ private struct HorizontalPagingPanGestureInstaller: UIViewRepresentable { onBegan: @escaping () -> Void, onChanged: @escaping (CGFloat) -> Void, onEnded: @escaping (CGFloat, CGFloat) -> Void, - onCancelled: @escaping () -> Void + onCancelled: @escaping () -> Void, + onInstalled: @escaping (UIPanGestureRecognizer) -> Void ) { self.excludedDragStartFrames = excludedDragStartFrames self.onBegan = onBegan self.onChanged = onChanged self.onEnded = onEnded self.onCancelled = onCancelled + self.onInstalled = onInstalled + } + + func notifyInstalled() { + onInstalled(panGestureRecognizer) } func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool { @@ -889,6 +905,7 @@ private struct HorizontalPagingPanGestureInstaller: UIViewRepresentable { private final class VideoDetailGestureCoordinator { private(set) var isHorizontalPagingActive = false private var verticalScrollViews: [WeakScrollView] = [] + private weak var horizontalPagingPanGestureRecognizer: UIPanGestureRecognizer? func setHorizontalPagingActive(_ isActive: Bool) { guard isHorizontalPagingActive != isActive else { return } @@ -901,9 +918,21 @@ private final class VideoDetailGestureCoordinator { if !verticalScrollViews.contains(where: { $0.scrollView === scrollView }) { verticalScrollViews.append(WeakScrollView(scrollView)) } + if let horizontalPagingPanGestureRecognizer { + scrollView.panGestureRecognizer.require(toFail: horizontalPagingPanGestureRecognizer) + } scrollView.isScrollEnabled = !isHorizontalPagingActive } + func registerHorizontalPagingPanGestureRecognizer(_ panGestureRecognizer: UIPanGestureRecognizer) { + guard horizontalPagingPanGestureRecognizer !== panGestureRecognizer else { return } + horizontalPagingPanGestureRecognizer = panGestureRecognizer + verticalScrollViews.removeAll { $0.scrollView == nil } + for weakScrollView in verticalScrollViews { + weakScrollView.scrollView?.panGestureRecognizer.require(toFail: panGestureRecognizer) + } + } + private func updateVerticalScrollAvailability() { verticalScrollViews.removeAll { $0.scrollView == nil } for weakScrollView in verticalScrollViews { From e533a4cf1a886dd921d5294d084d32998a8c31c3 Mon Sep 17 00:00:00 2001 From: pgs666 <74764545+pgs666@users.noreply.github.com> Date: Tue, 2 Jun 2026 17:08:57 +0800 Subject: [PATCH 030/216] fix(video): restore horizontal pager gesture --- iosApp/VideoDetailView.swift | 37 ++++-------------------------------- 1 file changed, 4 insertions(+), 33 deletions(-) diff --git a/iosApp/VideoDetailView.swift b/iosApp/VideoDetailView.swift index f0b9e348..241cd261 100644 --- a/iosApp/VideoDetailView.swift +++ b/iosApp/VideoDetailView.swift @@ -661,9 +661,6 @@ private struct VideoDetailTabPager: View { }, onCancelled: { cancelHorizontalPaging() - }, - onInstalled: { panGestureRecognizer in - gestureCoordinator.registerHorizontalPagingPanGestureRecognizer(panGestureRecognizer) } ) ) @@ -718,7 +715,6 @@ private struct HorizontalPagingPanGestureInstaller: UIViewRepresentable { let onChanged: (CGFloat) -> Void let onEnded: (CGFloat, CGFloat) -> Void let onCancelled: () -> Void - let onInstalled: (UIPanGestureRecognizer) -> Void func makeCoordinator() -> Coordinator { Coordinator( @@ -726,8 +722,7 @@ private struct HorizontalPagingPanGestureInstaller: UIViewRepresentable { onBegan: onBegan, onChanged: onChanged, onEnded: onEnded, - onCancelled: onCancelled, - onInstalled: onInstalled + onCancelled: onCancelled ) } @@ -743,8 +738,7 @@ private struct HorizontalPagingPanGestureInstaller: UIViewRepresentable { onBegan: onBegan, onChanged: onChanged, onEnded: onEnded, - onCancelled: onCancelled, - onInstalled: onInstalled + onCancelled: onCancelled ) uiView.scheduleInstall() } @@ -797,7 +791,6 @@ private struct HorizontalPagingPanGestureInstaller: UIViewRepresentable { } installedView = targetView targetView.addGestureRecognizer(coordinator.panGestureRecognizer) - coordinator.notifyInstalled() return true } } @@ -809,22 +802,19 @@ private struct HorizontalPagingPanGestureInstaller: UIViewRepresentable { private var onChanged: (CGFloat) -> Void private var onEnded: (CGFloat, CGFloat) -> Void private var onCancelled: () -> Void - private var onInstalled: (UIPanGestureRecognizer) -> Void init( excludedDragStartFrames: [CGRect], onBegan: @escaping () -> Void, onChanged: @escaping (CGFloat) -> Void, onEnded: @escaping (CGFloat, CGFloat) -> Void, - onCancelled: @escaping () -> Void, - onInstalled: @escaping (UIPanGestureRecognizer) -> Void + onCancelled: @escaping () -> Void ) { self.excludedDragStartFrames = excludedDragStartFrames self.onBegan = onBegan self.onChanged = onChanged self.onEnded = onEnded self.onCancelled = onCancelled - self.onInstalled = onInstalled self.panGestureRecognizer = UIPanGestureRecognizer() super.init() panGestureRecognizer.delegate = self @@ -839,19 +829,13 @@ private struct HorizontalPagingPanGestureInstaller: UIViewRepresentable { onBegan: @escaping () -> Void, onChanged: @escaping (CGFloat) -> Void, onEnded: @escaping (CGFloat, CGFloat) -> Void, - onCancelled: @escaping () -> Void, - onInstalled: @escaping (UIPanGestureRecognizer) -> Void + onCancelled: @escaping () -> Void ) { self.excludedDragStartFrames = excludedDragStartFrames self.onBegan = onBegan self.onChanged = onChanged self.onEnded = onEnded self.onCancelled = onCancelled - self.onInstalled = onInstalled - } - - func notifyInstalled() { - onInstalled(panGestureRecognizer) } func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool { @@ -905,7 +889,6 @@ private struct HorizontalPagingPanGestureInstaller: UIViewRepresentable { private final class VideoDetailGestureCoordinator { private(set) var isHorizontalPagingActive = false private var verticalScrollViews: [WeakScrollView] = [] - private weak var horizontalPagingPanGestureRecognizer: UIPanGestureRecognizer? func setHorizontalPagingActive(_ isActive: Bool) { guard isHorizontalPagingActive != isActive else { return } @@ -918,21 +901,9 @@ private final class VideoDetailGestureCoordinator { if !verticalScrollViews.contains(where: { $0.scrollView === scrollView }) { verticalScrollViews.append(WeakScrollView(scrollView)) } - if let horizontalPagingPanGestureRecognizer { - scrollView.panGestureRecognizer.require(toFail: horizontalPagingPanGestureRecognizer) - } scrollView.isScrollEnabled = !isHorizontalPagingActive } - func registerHorizontalPagingPanGestureRecognizer(_ panGestureRecognizer: UIPanGestureRecognizer) { - guard horizontalPagingPanGestureRecognizer !== panGestureRecognizer else { return } - horizontalPagingPanGestureRecognizer = panGestureRecognizer - verticalScrollViews.removeAll { $0.scrollView == nil } - for weakScrollView in verticalScrollViews { - weakScrollView.scrollView?.panGestureRecognizer.require(toFail: panGestureRecognizer) - } - } - private func updateVerticalScrollAvailability() { verticalScrollViews.removeAll { $0.scrollView == nil } for weakScrollView in verticalScrollViews { From fa6cc57b3375498364bac92b84acd9d3e8af10fc Mon Sep 17 00:00:00 2001 From: pgs666 <74764545+pgs666@users.noreply.github.com> Date: Tue, 2 Jun 2026 17:29:04 +0800 Subject: [PATCH 031/216] fix(video): arbitrate detail pager gestures --- ...7-video-detail-horizontal-pager-gesture.md | 50 ++++ iosApp/VideoDetailView.swift | 253 ++++++++---------- 2 files changed, 167 insertions(+), 136 deletions(-) create mode 100644 docs/agent-logs/2026-06-02-1727-video-detail-horizontal-pager-gesture.md diff --git a/docs/agent-logs/2026-06-02-1727-video-detail-horizontal-pager-gesture.md b/docs/agent-logs/2026-06-02-1727-video-detail-horizontal-pager-gesture.md new file mode 100644 index 00000000..6d9131e7 --- /dev/null +++ b/docs/agent-logs/2026-06-02-1727-video-detail-horizontal-pager-gesture.md @@ -0,0 +1,50 @@ +# Agent Log: Video Detail Horizontal Pager Gesture + +## User Input + +Original: + +```text +查看 han1viewer-ios这个库,并调查:为什么视频详情页的横滑手势完全失效了 +修复它:我想要的效果是,触发横滑之后完全不触发滚动,同理触发滚动后完全不触发横滑,尝试做完整的修复 +需要推送触发pr进行ci编译 +``` + +English translation: + +```text +Inspect the han1viewer-ios repository and investigate why the horizontal swipe gesture on the video detail page is completely broken. +Fix it: the desired behavior is that once horizontal paging is triggered, scrolling must not trigger at all; likewise once scrolling is triggered, horizontal paging must not trigger at all. Try to make a complete fix. +Push it to trigger PR CI compilation. +``` + +## What Changed + +- Reworked the video detail tab pager gesture arbitration in `iosApp/VideoDetailView.swift`. +- Replaced the background ancestor-level horizontal pan installer with a coordinator that installs a dedicated horizontal paging `UIPanGestureRecognizer` directly on each tab-owned `UIScrollView`. +- Made each vertical `UIScrollView.panGestureRecognizer` require the horizontal paging pan to fail before vertical scrolling can begin. +- Kept simultaneous recognition disabled, so horizontal paging and vertical scrolling cannot both win for the same touch sequence. +- Moved SwiftUI state callbacks into the `UIViewRepresentable` coordinator and weakly referenced them from the shared gesture coordinator to avoid retaining the page through closure chains. + +## Why + +The previous implementation depended on a SwiftUI background view walking up a private UIKit view hierarchy and attaching the horizontal pan to a guessed ancestor view. The tab contents are themselves `ScrollView`s, so the nested vertical scroll recognizer could win the gesture before horizontal paging ever reached `.began`. + +The new implementation puts both recognizers in the same `UIScrollView` gesture arena and establishes an explicit failure dependency: + +- Horizontal-looking gestures satisfy the custom pan's `gestureRecognizerShouldBegin`, so the scroll view pan fails and no vertical scroll happens. +- Vertical-looking gestures fail the custom pan, allowing the scroll view pan to proceed normally. + +## Mistakes Or Failed Attempts + +- The first local edit kept horizontal callbacks directly on `VideoDetailGestureCoordinator`. I changed that before committing because the coordinator is owned by SwiftUI state and those escaping closures can retain view state longer than intended. +- The first edit also left a short-drag no-page-change path that could keep a non-zero pager translation. I changed `finishHorizontalPaging` so it always resets the visual translation. + +## Verification + +- Ran `git diff --check`; it passed. +- Checked this environment for `xcodebuild` and `swiftc`; neither is available on this Linux aarch64 machine, so local Swift/iOS compilation cannot be performed here. + +## Known Limits + +- iOS build and runtime gesture verification must be done by GitHub Actions/macOS and a device or simulator test. diff --git a/iosApp/VideoDetailView.swift b/iosApp/VideoDetailView.swift index 241cd261..b5728269 100644 --- a/iosApp/VideoDetailView.swift +++ b/iosApp/VideoDetailView.swift @@ -644,11 +644,9 @@ private struct VideoDetailTabPager: View { .contentShape(Rectangle()) .clipped() .background( - HorizontalPagingPanGestureInstaller( + HorizontalPagingGestureConfigurator( + gestureCoordinator: gestureCoordinator, excludedDragStartFrames: excludedDragStartFrames, - onBegan: { - gestureCoordinator.setHorizontalPagingActive(true) - }, onChanged: { translation in var transaction = Transaction() transaction.disablesAnimations = true @@ -660,7 +658,7 @@ private struct VideoDetailTabPager: View { finishHorizontalPaging(translation: translation, velocity: velocity) }, onCancelled: { - cancelHorizontalPaging() + resetHorizontalPagingVisuals() } ) ) @@ -668,10 +666,6 @@ private struct VideoDetailTabPager: View { } private func finishHorizontalPaging(translation: CGFloat, velocity: CGFloat) { - defer { - cancelHorizontalPaging() - } - let threshold: CGFloat = 72 let projected = translation + velocity * 0.18 let currentIndex = selectedTab.pageIndex @@ -685,17 +679,18 @@ private struct VideoDetailTabPager: View { targetIndex = currentIndex } - guard targetIndex != currentIndex else { return } - withAnimation(.interactiveSpring(response: 0.28, dampingFraction: 0.86)) { - selectedTab = .page(at: targetIndex) + if targetIndex != currentIndex { + withAnimation(.interactiveSpring(response: 0.28, dampingFraction: 0.86)) { + selectedTab = .page(at: targetIndex) + } } + resetHorizontalPagingVisuals() } - private func cancelHorizontalPaging() { + private func resetHorizontalPagingVisuals() { withAnimation(.interactiveSpring(response: 0.28, dampingFraction: 0.86)) { dragTranslation = 0 } - gestureCoordinator.setHorizontalPagingActive(false) } private func rubberBandedTranslation(_ translation: CGFloat) -> CGFloat { @@ -709,186 +704,102 @@ private struct VideoDetailTabPager: View { } } -private struct HorizontalPagingPanGestureInstaller: UIViewRepresentable { +private struct HorizontalPagingGestureConfigurator: UIViewRepresentable { + let gestureCoordinator: VideoDetailGestureCoordinator let excludedDragStartFrames: [CGRect] - let onBegan: () -> Void let onChanged: (CGFloat) -> Void let onEnded: (CGFloat, CGFloat) -> Void let onCancelled: () -> Void func makeCoordinator() -> Coordinator { Coordinator( - excludedDragStartFrames: excludedDragStartFrames, - onBegan: onBegan, onChanged: onChanged, onEnded: onEnded, onCancelled: onCancelled ) } - func makeUIView(context: Context) -> InstallingView { - let view = InstallingView(coordinator: context.coordinator) + func makeUIView(context: Context) -> UIView { + let view = UIView() view.isUserInteractionEnabled = false return view } - func updateUIView(_ uiView: InstallingView, context: Context) { + func updateUIView(_ uiView: UIView, context: Context) { context.coordinator.update( - excludedDragStartFrames: excludedDragStartFrames, - onBegan: onBegan, onChanged: onChanged, onEnded: onEnded, onCancelled: onCancelled ) - uiView.scheduleInstall() - } - - final class InstallingView: UIView { - private let coordinator: Coordinator - private weak var installedView: UIView? - private var remainingInstallRetries = 8 - - init(coordinator: Coordinator) { - self.coordinator = coordinator - super.init(frame: .zero) - } - - @available(*, unavailable) - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - override func didMoveToSuperview() { - super.didMoveToSuperview() - remainingInstallRetries = 8 - scheduleInstall() - } - - override func didMoveToWindow() { - super.didMoveToWindow() - remainingInstallRetries = 8 - scheduleInstall() - } - - func scheduleInstall() { - DispatchQueue.main.async { [weak self] in - guard let self else { return } - if !self.installIfNeeded(), self.window != nil, self.remainingInstallRetries > 0 { - self.remainingInstallRetries -= 1 - DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { [weak self] in - self?.scheduleInstall() - } - } - } - } - - @discardableResult - private func installIfNeeded() -> Bool { - guard let targetView = superview?.superview ?? superview else { return false } - guard installedView !== targetView else { return true } - if let installedView { - installedView.removeGestureRecognizer(coordinator.panGestureRecognizer) - } - installedView = targetView - targetView.addGestureRecognizer(coordinator.panGestureRecognizer) - return true - } + gestureCoordinator.configureHorizontalPaging( + excludedDragStartFrames: excludedDragStartFrames, + actions: context.coordinator + ) } - final class Coordinator: NSObject, UIGestureRecognizerDelegate { - let panGestureRecognizer: UIPanGestureRecognizer - private var excludedDragStartFrames: [CGRect] - private var onBegan: () -> Void + final class Coordinator: VideoDetailHorizontalPagingActions { private var onChanged: (CGFloat) -> Void private var onEnded: (CGFloat, CGFloat) -> Void private var onCancelled: () -> Void init( - excludedDragStartFrames: [CGRect], - onBegan: @escaping () -> Void, onChanged: @escaping (CGFloat) -> Void, onEnded: @escaping (CGFloat, CGFloat) -> Void, onCancelled: @escaping () -> Void ) { - self.excludedDragStartFrames = excludedDragStartFrames - self.onBegan = onBegan self.onChanged = onChanged self.onEnded = onEnded self.onCancelled = onCancelled - self.panGestureRecognizer = UIPanGestureRecognizer() - super.init() - panGestureRecognizer.delegate = self - panGestureRecognizer.cancelsTouchesInView = true - panGestureRecognizer.delaysTouchesBegan = false - panGestureRecognizer.delaysTouchesEnded = false - panGestureRecognizer.addTarget(self, action: #selector(handlePan(_:))) } func update( - excludedDragStartFrames: [CGRect], - onBegan: @escaping () -> Void, onChanged: @escaping (CGFloat) -> Void, onEnded: @escaping (CGFloat, CGFloat) -> Void, onCancelled: @escaping () -> Void ) { - self.excludedDragStartFrames = excludedDragStartFrames - self.onBegan = onBegan self.onChanged = onChanged self.onEnded = onEnded self.onCancelled = onCancelled } - func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool { - guard let panGestureRecognizer = gestureRecognizer as? UIPanGestureRecognizer, - let view = gestureRecognizer.view else { - return false - } - let startLocation = panGestureRecognizer.location(in: view) - guard startLocation.x > 24 else { return false } - let globalStartLocation = view.convert(startLocation, to: nil) - guard !excludedDragStartFrames.contains(where: { $0.contains(globalStartLocation) }) else { - return false - } - - let translation = panGestureRecognizer.translation(in: view) - let velocity = panGestureRecognizer.velocity(in: view) - let horizontal = max(abs(translation.x), abs(velocity.x) * 0.05) - let vertical = max(abs(translation.y), abs(velocity.y) * 0.05) - return horizontal > 12 && horizontal > vertical * 1.35 + func horizontalPagingChanged(_ translation: CGFloat) { + onChanged(translation) } - func gestureRecognizer( - _ gestureRecognizer: UIGestureRecognizer, - shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer - ) -> Bool { - false + func horizontalPagingEnded(translation: CGFloat, velocity: CGFloat) { + onEnded(translation, velocity) } - @objc private func handlePan(_ recognizer: UIPanGestureRecognizer) { - guard let view = recognizer.view else { return } - switch recognizer.state { - case .began: - onBegan() - onChanged(recognizer.translation(in: view).x) - case .changed: - onChanged(recognizer.translation(in: view).x) - case .ended: - onEnded( - recognizer.translation(in: view).x, - recognizer.velocity(in: view).x - ) - case .cancelled, .failed: - onCancelled() - default: - break - } + func horizontalPagingCancelled() { + onCancelled() } } } -private final class VideoDetailGestureCoordinator { +private protocol VideoDetailHorizontalPagingActions: AnyObject { + func horizontalPagingChanged(_ translation: CGFloat) + func horizontalPagingEnded(translation: CGFloat, velocity: CGFloat) + func horizontalPagingCancelled() +} + +private final class VideoDetailGestureCoordinator: NSObject, UIGestureRecognizerDelegate { private(set) var isHorizontalPagingActive = false private var verticalScrollViews: [WeakScrollView] = [] + private var excludedDragStartFrames: [CGRect] = [] + private weak var horizontalPagingActions: VideoDetailHorizontalPagingActions? + + func configureHorizontalPaging( + excludedDragStartFrames: [CGRect], + actions: VideoDetailHorizontalPagingActions + ) { + self.excludedDragStartFrames = excludedDragStartFrames + self.horizontalPagingActions = actions + + verticalScrollViews.removeAll { $0.scrollView == nil } + for weakScrollView in verticalScrollViews { + installHorizontalPagingPanIfNeeded(on: weakScrollView) + } + } func setHorizontalPagingActive(_ isActive: Bool) { guard isHorizontalPagingActive != isActive else { return } @@ -901,9 +812,30 @@ private final class VideoDetailGestureCoordinator { if !verticalScrollViews.contains(where: { $0.scrollView === scrollView }) { verticalScrollViews.append(WeakScrollView(scrollView)) } + if let weakScrollView = verticalScrollViews.first(where: { $0.scrollView === scrollView }) { + installHorizontalPagingPanIfNeeded(on: weakScrollView) + } scrollView.isScrollEnabled = !isHorizontalPagingActive } + private func installHorizontalPagingPanIfNeeded(on weakScrollView: WeakScrollView) { + guard let scrollView = weakScrollView.scrollView else { return } + guard weakScrollView.horizontalPagingPanGestureRecognizer == nil else { return } + + let panGestureRecognizer = UIPanGestureRecognizer( + target: self, + action: #selector(handleHorizontalPagingPan(_:)) + ) + panGestureRecognizer.delegate = self + panGestureRecognizer.cancelsTouchesInView = true + panGestureRecognizer.delaysTouchesBegan = false + panGestureRecognizer.delaysTouchesEnded = false + + scrollView.addGestureRecognizer(panGestureRecognizer) + scrollView.panGestureRecognizer.require(toFail: panGestureRecognizer) + weakScrollView.horizontalPagingPanGestureRecognizer = panGestureRecognizer + } + private func updateVerticalScrollAvailability() { verticalScrollViews.removeAll { $0.scrollView == nil } for weakScrollView in verticalScrollViews { @@ -911,8 +843,57 @@ private final class VideoDetailGestureCoordinator { } } + func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool { + guard let panGestureRecognizer = gestureRecognizer as? UIPanGestureRecognizer, + let view = gestureRecognizer.view else { + return false + } + let startLocation = panGestureRecognizer.location(in: view) + guard startLocation.x > 24 else { return false } + let globalStartLocation = view.convert(startLocation, to: nil) + guard !excludedDragStartFrames.contains(where: { $0.contains(globalStartLocation) }) else { + return false + } + + let translation = panGestureRecognizer.translation(in: view) + let velocity = panGestureRecognizer.velocity(in: view) + let horizontal = max(abs(translation.x), abs(velocity.x) * 0.05) + let vertical = max(abs(translation.y), abs(velocity.y) * 0.05) + return horizontal > 12 && horizontal > vertical * 1.35 + } + + func gestureRecognizer( + _ gestureRecognizer: UIGestureRecognizer, + shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer + ) -> Bool { + false + } + + @objc private func handleHorizontalPagingPan(_ recognizer: UIPanGestureRecognizer) { + guard let view = recognizer.view else { return } + switch recognizer.state { + case .began: + setHorizontalPagingActive(true) + horizontalPagingActions?.horizontalPagingChanged(recognizer.translation(in: view).x) + case .changed: + horizontalPagingActions?.horizontalPagingChanged(recognizer.translation(in: view).x) + case .ended: + horizontalPagingActions?.horizontalPagingEnded( + translation: recognizer.translation(in: view).x, + velocity: recognizer.velocity(in: view).x + ) + setHorizontalPagingActive(false) + case .cancelled, .failed: + horizontalPagingActions?.horizontalPagingCancelled() + setHorizontalPagingActive(false) + default: + break + } + } + private final class WeakScrollView { weak var scrollView: UIScrollView? + weak var horizontalPagingPanGestureRecognizer: UIPanGestureRecognizer? init(_ scrollView: UIScrollView) { self.scrollView = scrollView From f9d05f749352a801f9e0674960b6aeead03f137f Mon Sep 17 00:00:00 2001 From: pgs666 <74764545+pgs666@users.noreply.github.com> Date: Tue, 2 Jun 2026 17:59:40 +0800 Subject: [PATCH 032/216] fix(video): load player when autoplay is disabled --- ...-1754-hide-series-video-metadata-footer.md | 37 +++++++++++++++ ...026-06-02-1758-fix-autoplay-load-paused.md | 35 +++++++++++++++ iosApp/KSPlayerView.swift | 13 +++--- iosApp/RelatedVideoComponents.swift | 45 ++++++++++++++----- iosApp/VideoDetailView.swift | 3 +- 5 files changed, 113 insertions(+), 20 deletions(-) create mode 100644 docs/agent-logs/2026-06-02-1754-hide-series-video-metadata-footer.md create mode 100644 docs/agent-logs/2026-06-02-1758-fix-autoplay-load-paused.md diff --git a/docs/agent-logs/2026-06-02-1754-hide-series-video-metadata-footer.md b/docs/agent-logs/2026-06-02-1754-hide-series-video-metadata-footer.md new file mode 100644 index 00000000..bfb5787d --- /dev/null +++ b/docs/agent-logs/2026-06-02-1754-hide-series-video-metadata-footer.md @@ -0,0 +1,37 @@ +# Agent Log: Hide Series Video Metadata Footer + +## User Input + +Original: + +```text +目前版本的“系列影片”一栏和界面都有问题,标题卡片下方的作者和发布时间没法正常显示。 +那就不需要实现了,直接对这个系列影片特殊处理,不显示那一行 +``` + +English translation: + +```text +In the current version, the "Series Videos" section and UI have problems; the author and publish time under the title card cannot display normally. +Then there is no need to implement parsing; just special-case this Series Videos section and do not show that line. +``` + +## What Changed + +- Added a `showsMetadataFooter` flag to `RelatedVideoCard`. +- Passed that flag through `HorizontalVideoSection` and `RelatedVideoListView`. +- Set `showsMetadataFooter: false` for the video detail "系列影片" section only. + +## Why + +The series playlist data currently does not reliably include author and upload-time metadata, and the user requested a direct UI special case instead of parser work. Hiding the footer avoids placeholder or broken-looking metadata in both the inline horizontal section and the "更多" list. + +## Verification + +- Ran `git diff --check`; it passed. +- Checked Swift call sites for `HorizontalVideoSection`, `RelatedVideoListView`, and `RelatedVideoCard`. + +## Known Limits + +- This intentionally does not add series playlist author/upload-time parsing. +- This Linux environment cannot run Xcode/iOS compilation locally. diff --git a/docs/agent-logs/2026-06-02-1758-fix-autoplay-load-paused.md b/docs/agent-logs/2026-06-02-1758-fix-autoplay-load-paused.md new file mode 100644 index 00000000..d434b087 --- /dev/null +++ b/docs/agent-logs/2026-06-02-1758-fix-autoplay-load-paused.md @@ -0,0 +1,35 @@ +# Agent Log: Fix Autoplay Disabled Loading + +## User Input + +Original: + +```text +接下来修复“打开视频时自动播放”这个开关。预期的行为是关闭这个开关后正常加载但是加载出之后不开始播放,但是现在的行为是连加载都不开始,改了它并推送 +``` + +English translation: + +```text +Next, fix the "auto-play when opening a video" switch. The expected behavior is that after turning it off, the video still loads normally but does not start playing after it has loaded. The current behavior is that loading does not even start. Change it and push it. +``` + +## What Changed + +- Updated `KSPlayerView.makeKSOptions` so `KSOptions.isAutoPlay` stays `true`. +- Left the user preference enforcement in `onStateChanged`: once KSPlayer reaches `.bufferFinished`, the app calls `play()` or `pause()` according to `autoPlayOnEnter`. +- Updated the diagnostic mount log label from `ksAutoPlay` to `ksLoadAutoPlay` to make the distinction explicit. + +## Why + +KSPlayer uses `KSOptions.isAutoPlay` as part of whether it begins opening/buffering the URL. Binding it directly to the user preference caused the "auto-play off" state to prevent loading entirely. Keeping the KSPlayer loading switch on and pausing after the first ready state matches the intended behavior: load the video, but wait for user input before playback. + +## Verification + +- Ran `git diff --check`; it passed. +- Ran `./gradlew :shared:jvmTest`; it passed. +- PR CI will verify Swift/iOS compilation after push. + +## Known Limits + +- This Linux environment cannot run Xcode/iOS compilation locally. diff --git a/iosApp/KSPlayerView.swift b/iosApp/KSPlayerView.swift index 57980022..1b4e384b 100644 --- a/iosApp/KSPlayerView.swift +++ b/iosApp/KSPlayerView.swift @@ -477,7 +477,7 @@ struct KSPlayerView: View { // it up via the onStateChanged callback once the layer is // created. statusObserver.observe(Self.findAVPlayer(in: coordinator.playerLayer?.player)) - AppLogger.log("player mount autoPlayOnEnter=\(autoPlayOnEnter) ksAutoPlay=\(KSOptions.isAutoPlay)") + AppLogger.log("player mount autoPlayOnEnter=\(autoPlayOnEnter) ksLoadAutoPlay=\(KSOptions.isAutoPlay)") } .onDisappear { hideControlsTask?.cancel() @@ -1298,12 +1298,11 @@ struct KSPlayerView: View { private static let playbackRates: [Float] = [0.5, 0.75, 1.0, 1.25, 1.5, 2.0] private func makeKSOptions(resumeSeconds: TimeInterval) -> KSOptions { - // KSOptions.isAutoPlay is a class-level static. Re-set it on every - // option build so the user's auto_play_on_enter preference takes - // effect for the very next video they open (without restarting - // the app). When OFF, the player loads the source but stays - // paused at frame 0 until the user taps the play/pause button. - KSOptions.isAutoPlay = autoPlayOnEnter + // KSPlayer uses this class-level switch to decide whether to begin + // opening/buffering the URL at all. Keep it ON for loading, then + // enforce the user's autoPlayOnEnter preference once the layer first + // reaches .bufferFinished in onStateChanged. + KSOptions.isAutoPlay = true let options = KSOptions() options.appendHeader([ "User-Agent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1", diff --git a/iosApp/RelatedVideoComponents.swift b/iosApp/RelatedVideoComponents.swift index 14f1db36..214c9d6b 100644 --- a/iosApp/RelatedVideoComponents.swift +++ b/iosApp/RelatedVideoComponents.swift @@ -8,6 +8,7 @@ struct HorizontalVideoSection: View { let videoFeature: VideoFeature let commentFeature: CommentFeature let showPlaying: Bool + let showsMetadataFooter: Bool @State private var selectedVideo: VideoRelatedRow? @State private var isShowingVideoList = false @@ -39,7 +40,12 @@ struct HorizontalVideoSection: View { ManualNavigationCard { selectedVideo = video } label: { - RelatedVideoCard(video: video, showPlaying: showPlaying, width: 172) + RelatedVideoCard( + video: video, + showPlaying: showPlaying, + showsMetadataFooter: showsMetadataFooter, + width: 172 + ) } } } @@ -51,7 +57,8 @@ struct HorizontalVideoSection: View { videos: videos, videoFeature: videoFeature, commentFeature: commentFeature, - showPlaying: showPlaying + showPlaying: showPlaying, + showsMetadataFooter: showsMetadataFooter ) } .navigationDestination( @@ -77,6 +84,7 @@ struct RelatedVideoListView: View { let videoFeature: VideoFeature let commentFeature: CommentFeature let showPlaying: Bool + let showsMetadataFooter: Bool var body: some View { ScrollView { @@ -93,7 +101,11 @@ struct RelatedVideoListView: View { commentFeature: commentFeature ) } label: { - RelatedVideoCard(video: video, showPlaying: showPlaying) + RelatedVideoCard( + video: video, + showPlaying: showPlaying, + showsMetadataFooter: showsMetadataFooter + ) } .buttonStyle(.plain) } @@ -238,11 +250,18 @@ struct TabletRelatedVideoRow: View { struct RelatedVideoCard: View { let video: VideoRelatedRow let showPlaying: Bool + let showsMetadataFooter: Bool let width: CGFloat? - init(video: VideoRelatedRow, showPlaying: Bool, width: CGFloat? = nil) { + init( + video: VideoRelatedRow, + showPlaying: Bool, + showsMetadataFooter: Bool = true, + width: CGFloat? = nil + ) { self.video = video self.showPlaying = showPlaying + self.showsMetadataFooter = showsMetadataFooter self.width = width } @@ -307,14 +326,16 @@ struct RelatedVideoCard: View { .foregroundStyle(.primary) .frame(maxWidth: .infinity, alignment: .leading) - HStack(spacing: 6) { - MarqueeText(text: video.artistLabel) - if let uploadTime = video.uploadTime, !uploadTime.isEmpty { - Text(uploadTime) - .font(.caption) - .foregroundStyle(.secondary) - .lineLimit(1) - .layoutPriority(1) + if showsMetadataFooter { + HStack(spacing: 6) { + MarqueeText(text: video.artistLabel) + if let uploadTime = video.uploadTime, !uploadTime.isEmpty { + Text(uploadTime) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + .layoutPriority(1) + } } } } diff --git a/iosApp/VideoDetailView.swift b/iosApp/VideoDetailView.swift index b5728269..e2bd44f0 100644 --- a/iosApp/VideoDetailView.swift +++ b/iosApp/VideoDetailView.swift @@ -1062,7 +1062,8 @@ private struct AndroidStyleIntroduction: View { videos: snapshot.playlistVideos, videoFeature: videoFeature, commentFeature: commentFeature, - showPlaying: true + showPlaying: true, + showsMetadataFooter: false ) .horizontalPagerExclusionArea() } From 3eff9d52791896a08158b7614158bb309740c666 Mon Sep 17 00:00:00 2001 From: pgs666 <74764545+pgs666@users.noreply.github.com> Date: Tue, 2 Jun 2026 18:11:35 +0800 Subject: [PATCH 033/216] fix(video): hide inline speed and quality controls --- ...2-1817-hide-inline-player-speed-quality.md | 38 +++++++++++++++++++ iosApp/KSPlayerView.swift | 23 ++++++----- 2 files changed, 51 insertions(+), 10 deletions(-) create mode 100644 docs/agent-logs/2026-06-02-1817-hide-inline-player-speed-quality.md diff --git a/docs/agent-logs/2026-06-02-1817-hide-inline-player-speed-quality.md b/docs/agent-logs/2026-06-02-1817-hide-inline-player-speed-quality.md new file mode 100644 index 00000000..8af21b40 --- /dev/null +++ b/docs/agent-logs/2026-06-02-1817-hide-inline-player-speed-quality.md @@ -0,0 +1,38 @@ +# Agent Log: Hide Inline Player Speed And Quality Controls + +## User Input + +Original: + +```text +能不能让播放器在不全屏的时候不显示播放倍速和视频画质的按钮?全屏后再显示,防止让进度条变成短短的一条 +``` + +English translation: + +```text +Can the player hide the playback speed and video quality buttons when it is not fullscreen, and show them only after entering fullscreen, so the progress bar does not become very short? +``` + +## What Changed + +- Updated `iosApp/KSPlayerView.swift` so the playback speed menu and quality menu are only rendered when `isFullscreen` is true. +- Left the fullscreen button visible in both inline and fullscreen modes. +- Preserved the existing behavior that the quality menu only appears when there is more than one playback source. + +## Why + +The inline video player has limited horizontal space. Rendering play/pause, timestamps, the progress slider, playback speed, quality, and fullscreen controls in one row made the slider too short. Hiding speed and quality while inline keeps the main playback controls usable, while still exposing those menus in fullscreen where there is enough room. + +## Mistakes Or Failed Attempts + +- None. + +## Verification + +- `git diff --check` passed. +- `./gradlew :shared:jvmTest` passed on local Linux aarch64. Kotlin/Native is still unsupported on this host, so the actual iOS Swift build is left to GitHub Actions. + +## Known Limits + +- This is a visibility/layout change only. The selected playback rate and source state are unchanged. diff --git a/iosApp/KSPlayerView.swift b/iosApp/KSPlayerView.swift index 1b4e384b..ee262401 100644 --- a/iosApp/KSPlayerView.swift +++ b/iosApp/KSPlayerView.swift @@ -832,16 +832,19 @@ struct KSPlayerView: View { .font(.caption2.monospacedDigit()) .foregroundStyle(.white) - // 倍速 menu - playbackRateMenu - - // 画质 menu — only shown if the snapshot exposes more than one - // source (typical: the page only ships a single 'auto' source - // because the script extraction returns one URL). When more - // qualities exist they sit between the rate menu and the - // fullscreen toggle as the user requested. - if snapshot.playbackSources.count > 1 { - qualityMenu + if isFullscreen { + // Keep inline chrome compact; these menus are available + // once fullscreen has enough horizontal room. + playbackRateMenu + + // 画质 menu — only shown if the snapshot exposes more than one + // source (typical: the page only ships a single 'auto' source + // because the script extraction returns one URL). When more + // qualities exist they sit between the rate menu and the + // fullscreen toggle as the user requested. + if snapshot.playbackSources.count > 1 { + qualityMenu + } } // 全屏 toggle — placed immediately to the right of the playback From 80c3c2af36e7c7af085b74b4eba094d53207a0dc Mon Sep 17 00:00:00 2001 From: pgs666 <74764545+pgs666@users.noreply.github.com> Date: Tue, 2 Jun 2026 18:17:32 +0800 Subject: [PATCH 034/216] fix(video): sync paused state after autoplay load --- ...26-06-02-1824-fix-autoplay-paused-state.md | 38 +++++++++++++++++++ iosApp/KSPlayerView.swift | 4 +- 2 files changed, 41 insertions(+), 1 deletion(-) create mode 100644 docs/agent-logs/2026-06-02-1824-fix-autoplay-paused-state.md diff --git a/docs/agent-logs/2026-06-02-1824-fix-autoplay-paused-state.md b/docs/agent-logs/2026-06-02-1824-fix-autoplay-paused-state.md new file mode 100644 index 00000000..5f475c9b --- /dev/null +++ b/docs/agent-logs/2026-06-02-1824-fix-autoplay-paused-state.md @@ -0,0 +1,38 @@ +# Agent Log: Fix Autoplay Disabled Paused State + +## User Input + +Original: + +```text +自动播放修复的逻辑有一点点小问题:加载好后播放器没有认为自己是暂停状态,双击之后反而是暂停,顺便还导致了加载好之后视频没有在播放但是上下滑动下方view时不会让视频自动收起 +另外,记得使用--exit-status来监控 +``` + +English translation: + +```text +There is a small issue with the autoplay fix: after loading, the player does not think it is paused. Double-tapping pauses instead, and after loading the video is not playing but scrolling the lower view does not automatically collapse the video. Also, remember to use --exit-status for monitoring. +``` + +## What Changed + +- Updated `iosApp/KSPlayerView.swift` so the first `.bufferFinished` autoplay enforcement also updates the local `isPlaying` value with the action that was actually applied. +- When `auto_play_on_enter` is disabled, the player still loads, then pauses, and now explicitly reports paused state to the parent view. + +## Why + +The previous fix paused the KSPlayer layer after buffering, but then continued to copy `state.isPlaying` from the original `.bufferFinished` callback. That old value could remain true even after `layer.pause()`, leaving SwiftUI and `VideoDetailView` believing playback was active. This broke double-tap play/pause semantics and prevented the paused-only scroll collapse behavior. + +## Mistakes Or Failed Attempts + +- The first autoplay-disabled loading fix handled the media loading behavior but missed the SwiftUI playing-state mirror used by controls and parent layout. + +## Verification + +- `git diff --check` passed. +- `./gradlew :shared:jvmTest` passed on local Linux aarch64. Kotlin/Native remains unsupported on this host, so the iOS Swift build is verified through GitHub Actions with `--exit-status`. + +## Known Limits + +- This fixes state mirroring for the autoplay-disabled startup pause. It does not change KSPlayer's underlying state enum semantics. diff --git a/iosApp/KSPlayerView.swift b/iosApp/KSPlayerView.swift index ee262401..0ade4dbf 100644 --- a/iosApp/KSPlayerView.swift +++ b/iosApp/KSPlayerView.swift @@ -306,16 +306,18 @@ struct KSPlayerView: View { // is set after, the recursive call sees it false // and calls play() again — unbounded recursion // until the stack guard kills the process. + var nowPlaying = state.isPlaying if !autoPlayApplied, state == .bufferFinished { autoPlayApplied = true AppLogger.log("autoplay enforced: \(autoPlayOnEnter ? "play" : "pause")") if autoPlayOnEnter { layer.play() + nowPlaying = true } else { layer.pause() + nowPlaying = false } } - let nowPlaying = state.isPlaying if nowPlaying != isPlaying { isPlaying = nowPlaying onPlayingChanged(nowPlaying) From 991ab5c24dbca225e1e3464ac52cb9f8f8ea2955 Mon Sep 17 00:00:00 2001 From: pgs666 <74764545+pgs666@users.noreply.github.com> Date: Tue, 2 Jun 2026 18:41:14 +0800 Subject: [PATCH 035/216] fix(video): refine playback layout and comment composer --- ...-1845-review-fixes-and-comment-composer.md | 44 +++++++ iosApp/CommentView.swift | 30 ----- iosApp/CommentViewModel.swift | 8 +- iosApp/KSPlayerView.swift | 87 +++++++------ iosApp/VideoDetailView.swift | 119 ++++++++++++++++-- 5 files changed, 208 insertions(+), 80 deletions(-) create mode 100644 docs/agent-logs/2026-06-02-1845-review-fixes-and-comment-composer.md diff --git a/docs/agent-logs/2026-06-02-1845-review-fixes-and-comment-composer.md b/docs/agent-logs/2026-06-02-1845-review-fixes-and-comment-composer.md new file mode 100644 index 00000000..db2bb00b --- /dev/null +++ b/docs/agent-logs/2026-06-02-1845-review-fixes-and-comment-composer.md @@ -0,0 +1,44 @@ +# Agent Log: Playback Layout Review Fixes And Comment Composer + +## User Input + +Original: + +```text +把你总结的五个问题一个一个修复。顺便把评论区的发送评论按钮换成输入框放在屏幕底部,使用原生组件(iOS26时为透明玻璃,之前的系统版本兼容成普通的屏幕底部输入框 +``` + +English translation: + +```text +Fix the five issues you summarized one by one. Also replace the comment area's send-comment button with an input field at the bottom of the screen, using native components: transparent glass on iOS 26, and a normal bottom-screen input field on earlier system versions. +``` + +## What Changed + +- Updated fullscreen player controls to respect safe-area insets, so top and bottom chrome avoid notches, rounded corners, and the home indicator. +- Made the player bottom control bar responsive: time labels hide on narrow widths and the progress slider gets layout priority. +- Fixed the manually collapsed player strip layout so the lower content uses the visible 50-point strip height instead of reserving the larger follow-collapse minimum height. +- Tightened the iPad two-column threshold so the related sidebar only appears when there is enough width for both a 620-point player column and a 360-point sidebar. +- Constrained long tag chips to a single truncated line so they cannot push the detail page wider than the screen. +- Removed the main comment compose button from the comment header and added a native bottom comment composer on the comments tab. +- Used SwiftUI's iOS 26 `glassEffect` for the bottom composer chrome, with a `regularMaterial` rounded container fallback on earlier iOS versions. +- Made `CommentViewModel.postComment(text:)` report whether a send actually started, so the bottom composer only clears after local validation passes. + +## Why + +The playback page had several layout edges that were individually small but visible on real devices: fullscreen controls could sit too close to unsafe screen edges, the progress slider could still be squeezed, collapsed-player content had a bottom-height mismatch, iPad split mode activated too early, and long tags could overflow. The comment action also required a modal sheet for a short message, while the desired interaction is a persistent native composer at the bottom of the comments screen. + +## Mistakes Or Failed Attempts + +- None. + +## Verification + +- `git diff --check` passed. +- `./gradlew :shared:jvmTest` passed on local Linux aarch64. Kotlin/Native is unsupported on this host, so iOS/Swift verification must run on GitHub Actions with Xcode 26. + +## Known Limits + +- The iOS 26 glass API is guarded with `#available(iOS 26.0, *)`; final validation depends on the CI Xcode 26 build. +- Reply composition still uses the existing sheet flow; this change only replaces the main comment composer button. diff --git a/iosApp/CommentView.swift b/iosApp/CommentView.swift index e820c71c..119d14c0 100644 --- a/iosApp/CommentView.swift +++ b/iosApp/CommentView.swift @@ -3,12 +3,10 @@ import Han1meShared struct CommentView: View { @ObservedObject private var viewModel: CommentViewModel - @State private var composeText = "" @State private var replyTarget: CommentRow? @State private var replyText = "" @State private var reportTarget: CommentRow? @State private var repliesTarget: CommentRow? - @State private var isShowingComposer = false init(viewModel: CommentViewModel) { _viewModel = ObservedObject(wrappedValue: viewModel) @@ -30,23 +28,6 @@ struct CommentView: View { } message: { Text(viewModel.actionMessage ?? "") } - .sheet(isPresented: $isShowingComposer) { - CommentTextSheet( - title: "发表评论", - text: $composeText, - placeholder: "输入评论", - submitTitle: "发送", - onCancel: { - isShowingComposer = false - composeText = "" - }, - onSubmit: { - viewModel.postComment(text: composeText) - isShowingComposer = false - composeText = "" - } - ) - } .sheet(item: $replyTarget) { comment in CommentTextSheet( title: "回复 \(comment.username)", @@ -100,17 +81,6 @@ struct CommentView: View { Spacer() - TapOnlyControl { - isShowingComposer = true - } label: { - Label("评论", systemImage: "square.and.pencil") - .font(.subheadline.weight(.semibold)) - .foregroundStyle(.white) - .padding(.horizontal, 12) - .padding(.vertical, 7) - .background(Color.accentColor, in: RoundedRectangle(cornerRadius: 8, style: .continuous)) - } - TapOnlyControl { viewModel.load() } label: { diff --git a/iosApp/CommentViewModel.swift b/iosApp/CommentViewModel.swift index 142b757b..012889e4 100644 --- a/iosApp/CommentViewModel.swift +++ b/iosApp/CommentViewModel.swift @@ -83,15 +83,16 @@ final class CommentViewModel: ObservableObject { await loadComments(generation: generation) } - func postComment(text: String) { + @discardableResult + func postComment(text: String) -> Bool { let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) guard trimmed.count >= 2 else { actionMessage = String(localized: "评论太短") - return + return false } guard let snapshot = currentSnapshot, let userId = snapshot.currentUserId else { actionMessage = String(localized: "请先登录") - return + return false } runAction(id: "post-comment") { @@ -104,6 +105,7 @@ final class CommentViewModel: ObservableObject { self.actionMessage = String(localized: "评论已发送") self.load() } + return true } func postReply(to comment: CommentRow, text: String) { diff --git a/iosApp/KSPlayerView.swift b/iosApp/KSPlayerView.swift index 0ade4dbf..8c55bed8 100644 --- a/iosApp/KSPlayerView.swift +++ b/iosApp/KSPlayerView.swift @@ -227,14 +227,12 @@ struct KSPlayerView: View { let resumeSeconds = TimeInterval(snapshot.playbackPositionMillis) / 1000 let options = makeKSOptions(resumeSeconds: resumeSeconds) - // GeometryReader wraps KSVideoPlayer (alone, not the whole ZStack) so the - // DragGesture handler can see the player's own size — needed to decide - // whether a vertical swipe started on the LEFT half (brightness) or the - // RIGHT half (volume), and to scale a horizontal swipe to a sensible - // seek delta. KSVideoPlayer is the only child of this GeometryReader, - // no branching, so view identity is preserved. - ZStack { - GeometryReader { proxy in + // GeometryReader gives both the video gestures and the controls overlay + // the player's actual size. Gestures need it for swipe classification; + // the overlay needs it for fullscreen safe-area padding. + GeometryReader { proxy in + let safeAreaInsets = isFullscreen ? proxy.safeAreaInsets : EdgeInsets() + ZStack { KSVideoPlayer(coordinator: coordinator, url: url, options: options) .onPlay { current, total in guard current.isFinite, current >= 0 else { return } @@ -431,30 +429,30 @@ struct KSPlayerView: View { handlePressOrSwipeEnded() } ) - } - // Z-order: KSVideoPlayer < controlsOverlay < swipeHUD / boostHint. - // The two HUDs sit ABOVE the controls so the centre play / skip - // buttons (which live inside controlsOverlay) don't visually - // cover the swipe HUD or the boost badge. - if showsControls { - controlsOverlay.transition(.opacity) - } + // Z-order: KSVideoPlayer < controlsOverlay < swipeHUD / boostHint. + // The two HUDs sit ABOVE the controls so the centre play / skip + // buttons (which live inside controlsOverlay) don't visually + // cover the swipe HUD or the boost badge. + if showsControls { + controlsOverlay(safeAreaInsets: safeAreaInsets).transition(.opacity) + } - if dragState != .none { - swipeHUD.transition(.opacity) - } + if dragState != .none { + swipeHUD.transition(.opacity) + } - if isBoosted { - boostHint.transition(.opacity) - } + if isBoosted { + boostHint.transition(.opacity) + } - if statusObserver.isWaitingForPlayback { - loadingHUD.transition(.opacity) - } + if statusObserver.isWaitingForPlayback { + loadingHUD.transition(.opacity) + } - if physicalVolumeHUDActive { - physicalVolumeHUD.transition(.opacity) + if physicalVolumeHUDActive { + physicalVolumeHUD.transition(.opacity) + } } } .onAppear { @@ -690,7 +688,7 @@ struct KSPlayerView: View { return String(format: "%.0f B/s", bytesPerSec) } - private var controlsOverlay: some View { + private func controlsOverlay(safeAreaInsets: EdgeInsets) -> some View { ZStack { LinearGradient( colors: [.black.opacity(0.5), .clear, .black.opacity(0.55)], @@ -704,8 +702,10 @@ struct KSPlayerView: View { Spacer() bottomBar } - .padding(.horizontal, 12) - .padding(.vertical, 10) + .padding(.leading, 12 + safeAreaInsets.leading) + .padding(.trailing, 12 + safeAreaInsets.trailing) + .padding(.top, 10 + safeAreaInsets.top) + .padding(.bottom, 10 + safeAreaInsets.bottom) .foregroundStyle(.white) } // Pin to the player ZStack's full extent. Without this, the @@ -774,6 +774,14 @@ struct KSPlayerView: View { } private var bottomBar: some View { + GeometryReader { proxy in + bottomBarContent(showsTimeLabels: proxy.size.width >= 390) + .frame(width: proxy.size.width, height: proxy.size.height) + } + .frame(height: 44) + } + + private func bottomBarContent(showsTimeLabels: Bool) -> some View { let total = max(TimeInterval(coordinator.timemodel.totalTime), 1) // Slider value is now a PLAIN @State (not a closure-based binding) so // SwiftUI's first drag delta correctly persists into binding source on @@ -794,9 +802,12 @@ struct KSPlayerView: View { scheduleAutoHide() } - Text(Self.formatTime(sliderValue)) - .font(.caption2.monospacedDigit()) - .foregroundStyle(.white) + if showsTimeLabels { + Text(Self.formatTime(sliderValue)) + .font(.caption2.monospacedDigit()) + .foregroundStyle(.white) + .fixedSize(horizontal: true, vertical: false) + } Slider( value: $sliderValue, @@ -829,10 +840,14 @@ struct KSPlayerView: View { sliderValue = asTime } } + .layoutPriority(1) - Text(Self.formatTime(TimeInterval(coordinator.timemodel.totalTime))) - .font(.caption2.monospacedDigit()) - .foregroundStyle(.white) + if showsTimeLabels { + Text(Self.formatTime(TimeInterval(coordinator.timemodel.totalTime))) + .font(.caption2.monospacedDigit()) + .foregroundStyle(.white) + .fixedSize(horizontal: true, vertical: false) + } if isFullscreen { // Keep inline chrome compact; these menus are available diff --git a/iosApp/VideoDetailView.swift b/iosApp/VideoDetailView.swift index e2bd44f0..a2915858 100644 --- a/iosApp/VideoDetailView.swift +++ b/iosApp/VideoDetailView.swift @@ -28,6 +28,7 @@ struct VideoDetailView: View { /// positions instead of sharing one outer ScrollView offset. @State private var bottomScrollOffsetsByTab: [VideoPageTab: CGFloat] = [:] @State private var lastSelectedTabChangeAt = Date.distantPast + @State private var commentComposeText = "" /// Natural size of the loaded video (reported by KSPlayer the first time /// the underlying player gets a non-zero presentation size). Used to /// decide whether fullscreen should lock the device to portrait or @@ -201,13 +202,18 @@ struct VideoDetailView: View { // Phone / iPad portrait collapses to a single full-width left panel // (no sidebar), giving the same visual as before for those modes. GeometryReader { proxy in + let leftMinimumWidth: CGFloat = 620 + let sidebarMinimumWidth: CGFloat = 360 let isWide = horizontalSizeClass == .regular - && proxy.size.width >= 900 + && proxy.size.width >= leftMinimumWidth + sidebarMinimumWidth && proxy.size.width > proxy.size.height && !isPlayerFullscreen let leftWidth: CGFloat = isWide - ? min(max(proxy.size.width * 0.64, 620), proxy.size.width - 360) + ? min(max(proxy.size.width * 0.64, leftMinimumWidth), proxy.size.width - sidebarMinimumWidth) : proxy.size.width + let showsCommentComposer = selectedTab == .comments && !isPlayerFullscreen + let commentComposerBottomPadding = showsCommentComposer ? max(proxy.safeAreaInsets.bottom, 10) : 0 + let commentComposerReservedHeight = showsCommentComposer ? 72 + commentComposerBottomPadding : 0 HStack(alignment: .top, spacing: 0) { ZStack(alignment: .top) { @@ -218,7 +224,7 @@ struct VideoDetailView: View { ) let currentPlayerShrink = playerVisualShrink(panelWidth: leftWidth) let currentBottomScrollHeight = proxy.size.height - ( - isPlayerPlaying + isPlayerCollapsed || isPlayerPlaying ? currentPlayerHeight : playerMinimumHeight(panelWidth: leftWidth) ) @@ -235,12 +241,28 @@ struct VideoDetailView: View { belowPlayerScroll( snapshot: snapshot, showsRelated: !isWide, - collapseDistance: isPlayerPlaying ? 0 : currentPlayerCollapseDistance, - collapseCompensation: isPlayerPlaying ? 0 : currentPlayerShrink + collapseDistance: isPlayerCollapsed || isPlayerPlaying ? 0 : currentPlayerCollapseDistance, + collapseCompensation: isPlayerCollapsed || isPlayerPlaying ? 0 : currentPlayerShrink, + commentBottomInset: commentComposerReservedHeight ) .frame(height: max(0, currentBottomScrollHeight)) .offset(y: currentPlayerHeight) } + + if showsCommentComposer { + VStack { + Spacer() + CommentComposerBar( + text: $commentComposeText, + isSending: commentViewModel.runningActionIDs.contains("post-comment"), + onSubmit: submitComment + ) + .padding(.horizontal, 16) + .padding(.bottom, commentComposerBottomPadding) + } + .frame(width: leftWidth, height: proxy.size.height) + .transition(.move(edge: .bottom).combined(with: .opacity)) + } } .frame(width: leftWidth, height: proxy.size.height, alignment: .top) .clipped() @@ -330,7 +352,8 @@ struct VideoDetailView: View { snapshot: VideoDetailScreenSnapshot, showsRelated: Bool, collapseDistance: CGFloat, - collapseCompensation: CGFloat + collapseCompensation: CGFloat, + commentBottomInset: CGFloat ) -> some View { VStack(spacing: 0) { Picker("Content", selection: $selectedTab) { @@ -375,6 +398,8 @@ struct VideoDetailView: View { tabCollapseCompensation(for: .comments, collapseCompensation: collapseCompensation) } collapseDistance: { collapseDistance + } bottomInset: { + commentBottomInset } } .frame(maxHeight: .infinity) @@ -418,11 +443,18 @@ struct VideoDetailView: View { ) } + private func submitComment() { + if commentViewModel.postComment(text: commentComposeText) { + commentComposeText = "" + } + } + private func tabScroll( _ tab: VideoPageTab, @ViewBuilder content: @escaping () -> Content, collapseCompensation: @escaping () -> CGFloat = { 0 }, - collapseDistance: @escaping () -> CGFloat = { 0 } + collapseDistance: @escaping () -> CGFloat = { 0 }, + bottomInset: @escaping () -> CGFloat = { 0 } ) -> some View { GeometryReader { proxy in let collapseScrollSpacerHeight = collapseDistance() + 1 @@ -440,7 +472,7 @@ struct VideoDetailView: View { content() .frame(maxWidth: .infinity, minHeight: proxy.size.height, alignment: .top) - .padding(.bottom, 24) + .padding(.bottom, 24 + bottomInset()) .offset(y: collapseCompensation()) Color.clear @@ -456,6 +488,60 @@ struct VideoDetailView: View { } } +private struct CommentComposerBar: View { + @Binding var text: String + let isSending: Bool + let onSubmit: () -> Void + + private var canSubmit: Bool { + text.trimmingCharacters(in: .whitespacesAndNewlines).count >= 2 && !isSending + } + + var body: some View { + HStack(spacing: 10) { + TextField("输入评论", text: $text) + .textFieldStyle(.plain) + .submitLabel(.send) + .onSubmit { + guard canSubmit else { return } + onSubmit() + } + .padding(.horizontal, 14) + .frame(minHeight: 42) + .background(Color(.secondarySystemBackground).opacity(0.78), in: Capsule()) + + Button(action: onSubmit) { + Image(systemName: isSending ? "hourglass" : "paperplane.fill") + .font(.headline) + .frame(width: 42, height: 42) + } + .disabled(!canSubmit) + .buttonStyle(.plain) + .foregroundStyle(canSubmit ? Color.accentColor : Color.secondary) + .accessibilityLabel("发送评论") + } + .padding(.horizontal, 10) + .padding(.vertical, 8) + .commentComposerChrome() + } +} + +private extension View { + @ViewBuilder + func commentComposerChrome() -> some View { + if #available(iOS 26.0, *) { + glassEffect(.regular, in: RoundedRectangle(cornerRadius: 28, style: .continuous)) + } else { + background(.regularMaterial, in: RoundedRectangle(cornerRadius: 28, style: .continuous)) + .overlay { + RoundedRectangle(cornerRadius: 28, style: .continuous) + .strokeBorder(Color.secondary.opacity(0.16), lineWidth: 0.5) + } + .shadow(color: .black.opacity(0.12), radius: 14, x: 0, y: 4) + } + } +} + private struct BounceDisabledScrollViewConfigurator: UIViewRepresentable { let gestureCoordinator: VideoDetailGestureCoordinator @@ -1434,7 +1520,7 @@ private struct TagFlow: View { TapOnlyControl { selectedTag = tag } label: { - Text(tag).font(.caption) + TagChipText(tag: tag) .padding(.horizontal, 12) .padding(.vertical, 7) .background( @@ -1447,8 +1533,7 @@ private struct TagFlow: View { // which shouldn't happen in production) the tag still // renders as a disabled bordered chip rather than // disappearing entirely. - Text(tag) - .font(.caption) + TagChipText(tag: tag) .padding(.horizontal, 12) .padding(.vertical, 7) .foregroundStyle(.secondary) @@ -1479,6 +1564,18 @@ private struct TagFlow: View { } } +private struct TagChipText: View { + let tag: String + + var body: some View { + Text(tag) + .font(.caption) + .lineLimit(1) + .truncationMode(.tail) + .frame(maxWidth: 240, alignment: .leading) + } +} + /// Lightweight flow layout: lays out subviews left-to-right, wrapping to a /// new line when a child wouldn't fit on the current one. Each child takes /// its natural intrinsic width, so different-length labels pack tightly. From 11435f9153a71bab50e9220fe6eb951dcdc9efb4 Mon Sep 17 00:00:00 2001 From: pgs666 <74764545+pgs666@users.noreply.github.com> Date: Tue, 2 Jun 2026 19:09:00 +0800 Subject: [PATCH 036/216] fix(video): restore play state and composer inset --- ...1-fix-player-state-and-comment-composer.md | 43 +++++++++ iosApp/KSPlayerView.swift | 33 +++++-- iosApp/VideoDetailView.swift | 90 +++++++++++-------- 3 files changed, 121 insertions(+), 45 deletions(-) create mode 100644 docs/agent-logs/2026-06-02-1901-fix-player-state-and-comment-composer.md diff --git a/docs/agent-logs/2026-06-02-1901-fix-player-state-and-comment-composer.md b/docs/agent-logs/2026-06-02-1901-fix-player-state-and-comment-composer.md new file mode 100644 index 00000000..58e744a4 --- /dev/null +++ b/docs/agent-logs/2026-06-02-1901-fix-player-state-and-comment-composer.md @@ -0,0 +1,43 @@ +# Agent Log: Fix Player State And Comment Composer + +## User Input + +Original: + +```text +那个输入栏改的不好。你先搜搜有没有最佳实现。另外你把播放器改坏了,现在(关闭打开视频时自动播放功能)正在播放的时候播放器不会认为正在播放,导致双击播放暂停等等功能全部失效了 +``` + +English translation: + +```text +That input bar change is not good. First search for a best-practice implementation. Also, you broke the player: now when "autoplay on opening video" is disabled, while the video is playing the player does not think it is playing, which breaks double-tap play/pause and related features. +``` + +## What Changed + +- Reworked the main comment composer so it is attached to the comments `ScrollView` with `safeAreaInset(edge: .bottom)` instead of a manual screen-covering overlay. +- Kept the composer as a native bottom bar: iOS 26 uses SwiftUI `glassEffect` on the input field; older iOS versions use a normal `.bar` bottom background with a system secondary input capsule. +- Switched the composer to `TextField(_:text:axis:)` with a 1-to-4-line limit, so short comments stay compact and longer comments can grow without opening a sheet. +- Fixed the player playing-state mirror by updating SwiftUI state immediately when user actions call `play()` or `pause()`. +- Stopped treating a non-playing `.bufferFinished` callback as authoritative after autoplay enforcement, so KSPlayer's ready/buffered state no longer overwrites manual play state. + +## Why + +Apple's SwiftUI layout APIs support inserting bottom UI with safe-area-aware modifiers instead of manually overlaying controls and guessing heights. The previous implementation manually placed the composer over the whole detail panel and padded the scroll content by an estimated height, which is fragile around keyboard, home indicator, paging, and rotation. + +The player regression came from relying on KSPlayer's state callback to mirror playing state. With autoplay disabled, the layer is intentionally paused after buffering, but later manual `play()` can leave the callback in `.bufferFinished` while playback is active. The UI state must mirror explicit user play/pause commands as well. + +## Mistakes Or Failed Attempts + +- The previous composer implementation used a manual overlay and reserved-height padding instead of safe-area insertion. +- The previous autoplay-disabled fix synchronized the initial paused state, but did not keep later manual play/pause transitions authoritative. + +## Verification + +- `git diff --check` passed. +- `./gradlew :shared:jvmTest` passed on local Linux aarch64. Kotlin/Native is unsupported on this host, so the iOS Swift build is verified through GitHub Actions. + +## Known Limits + +- The iOS 26 glass appearance is compile-guarded with `#available(iOS 26.0, *)`; final Swift API verification is done by CI on Xcode 26. diff --git a/iosApp/KSPlayerView.swift b/iosApp/KSPlayerView.swift index 8c55bed8..c561c9fc 100644 --- a/iosApp/KSPlayerView.swift +++ b/iosApp/KSPlayerView.swift @@ -277,7 +277,10 @@ struct KSPlayerView: View { // previous value across re-entry. onProgress(current) } - .onFinish { _, _ in onPlaybackEnded() } + .onFinish { _, _ in + setPlayingState(false) + onPlaybackEnded() + } .onStateChanged { layer, state in // DIAGNOSTIC: the player previously swallowed every // state, so a failed open showed only a black screen @@ -304,21 +307,20 @@ struct KSPlayerView: View { // is set after, the recursive call sees it false // and calls play() again — unbounded recursion // until the stack guard kills the process. - var nowPlaying = state.isPlaying if !autoPlayApplied, state == .bufferFinished { autoPlayApplied = true AppLogger.log("autoplay enforced: \(autoPlayOnEnter ? "play" : "pause")") if autoPlayOnEnter { layer.play() - nowPlaying = true + setPlayingState(true) } else { layer.pause() - nowPlaying = false + setPlayingState(false) } - } - if nowPlaying != isPlaying { - isPlaying = nowPlaying - onPlayingChanged(nowPlaying) + } else if state.isPlaying { + setPlayingState(true) + } else if state == .error { + setPlayingState(false) } // Wire / re-wire the timeControlStatus observer // any time KSPlayer's state ticks, so we always have @@ -494,6 +496,7 @@ struct KSPlayerView: View { // navigation stack — without an explicit pause, BOTH videos // would keep playing audio simultaneously. coordinator.playerLayer?.pause() + setPlayingState(false) } .onValueChange(of: isShrunken) { newValue in // The moment the parent reports the player has begun shrinking @@ -1261,7 +1264,19 @@ struct KSPlayerView: View { private func togglePlayPause() { guard let layer = coordinator.playerLayer else { return } AppLogger.log("gesture: toggle play/pause was=\(isPlaying ? "playing" : "paused")") - if isPlaying { layer.pause() } else { layer.play() } + let shouldPlay = !isPlaying + if shouldPlay { + layer.play() + } else { + layer.pause() + } + setPlayingState(shouldPlay) + } + + private func setPlayingState(_ nowPlaying: Bool) { + guard nowPlaying != isPlaying else { return } + isPlaying = nowPlaying + onPlayingChanged(nowPlaying) } private func startBoost() { diff --git a/iosApp/VideoDetailView.swift b/iosApp/VideoDetailView.swift index a2915858..268e16e2 100644 --- a/iosApp/VideoDetailView.swift +++ b/iosApp/VideoDetailView.swift @@ -211,9 +211,6 @@ struct VideoDetailView: View { let leftWidth: CGFloat = isWide ? min(max(proxy.size.width * 0.64, leftMinimumWidth), proxy.size.width - sidebarMinimumWidth) : proxy.size.width - let showsCommentComposer = selectedTab == .comments && !isPlayerFullscreen - let commentComposerBottomPadding = showsCommentComposer ? max(proxy.safeAreaInsets.bottom, 10) : 0 - let commentComposerReservedHeight = showsCommentComposer ? 72 + commentComposerBottomPadding : 0 HStack(alignment: .top, spacing: 0) { ZStack(alignment: .top) { @@ -242,27 +239,11 @@ struct VideoDetailView: View { snapshot: snapshot, showsRelated: !isWide, collapseDistance: isPlayerCollapsed || isPlayerPlaying ? 0 : currentPlayerCollapseDistance, - collapseCompensation: isPlayerCollapsed || isPlayerPlaying ? 0 : currentPlayerShrink, - commentBottomInset: commentComposerReservedHeight + collapseCompensation: isPlayerCollapsed || isPlayerPlaying ? 0 : currentPlayerShrink ) .frame(height: max(0, currentBottomScrollHeight)) .offset(y: currentPlayerHeight) } - - if showsCommentComposer { - VStack { - Spacer() - CommentComposerBar( - text: $commentComposeText, - isSending: commentViewModel.runningActionIDs.contains("post-comment"), - onSubmit: submitComment - ) - .padding(.horizontal, 16) - .padding(.bottom, commentComposerBottomPadding) - } - .frame(width: leftWidth, height: proxy.size.height) - .transition(.move(edge: .bottom).combined(with: .opacity)) - } } .frame(width: leftWidth, height: proxy.size.height, alignment: .top) .clipped() @@ -352,8 +333,7 @@ struct VideoDetailView: View { snapshot: VideoDetailScreenSnapshot, showsRelated: Bool, collapseDistance: CGFloat, - collapseCompensation: CGFloat, - commentBottomInset: CGFloat + collapseCompensation: CGFloat ) -> some View { VStack(spacing: 0) { Picker("Content", selection: $selectedTab) { @@ -398,8 +378,14 @@ struct VideoDetailView: View { tabCollapseCompensation(for: .comments, collapseCompensation: collapseCompensation) } collapseDistance: { collapseDistance - } bottomInset: { - commentBottomInset + } bottomAccessory: { + AnyView( + CommentComposerBar( + text: $commentComposeText, + isSending: commentViewModel.runningActionIDs.contains("post-comment"), + onSubmit: submitComment + ) + ) } } .frame(maxHeight: .infinity) @@ -454,7 +440,7 @@ struct VideoDetailView: View { @ViewBuilder content: @escaping () -> Content, collapseCompensation: @escaping () -> CGFloat = { 0 }, collapseDistance: @escaping () -> CGFloat = { 0 }, - bottomInset: @escaping () -> CGFloat = { 0 } + bottomAccessory: (() -> AnyView)? = nil ) -> some View { GeometryReader { proxy in let collapseScrollSpacerHeight = collapseDistance() + 1 @@ -472,7 +458,7 @@ struct VideoDetailView: View { content() .frame(maxWidth: .infinity, minHeight: proxy.size.height, alignment: .top) - .padding(.bottom, 24 + bottomInset()) + .padding(.bottom, 24) .offset(y: collapseCompensation()) Color.clear @@ -480,6 +466,11 @@ struct VideoDetailView: View { } .coordinateSpace(name: tab.scrollCoordinateSpaceName) .id(tab) + .commentComposerSafeAreaInset(isVisible: bottomAccessory != nil) { + if let bottomAccessory { + bottomAccessory() + } + } } } @@ -499,16 +490,17 @@ private struct CommentComposerBar: View { var body: some View { HStack(spacing: 10) { - TextField("输入评论", text: $text) + TextField("输入评论", text: $text, axis: .vertical) .textFieldStyle(.plain) .submitLabel(.send) + .lineLimit(1...4) .onSubmit { guard canSubmit else { return } onSubmit() } .padding(.horizontal, 14) - .frame(minHeight: 42) - .background(Color(.secondarySystemBackground).opacity(0.78), in: Capsule()) + .padding(.vertical, 10) + .commentComposerFieldChrome() Button(action: onSubmit) { Image(systemName: isSending ? "hourglass" : "paperplane.fill") @@ -520,24 +512,50 @@ private struct CommentComposerBar: View { .foregroundStyle(canSubmit ? Color.accentColor : Color.secondary) .accessibilityLabel("发送评论") } - .padding(.horizontal, 10) + .padding(.horizontal, 16) .padding(.vertical, 8) - .commentComposerChrome() + .frame(maxWidth: .infinity) + .commentComposerBarChrome() } } private extension View { @ViewBuilder - func commentComposerChrome() -> some View { + func commentComposerSafeAreaInset( + isVisible: Bool, + @ViewBuilder bar: () -> Bar + ) -> some View { + if isVisible { + safeAreaInset(edge: .bottom, spacing: 0) { + bar() + } + } else { + self + } + } + + @ViewBuilder + func commentComposerFieldChrome() -> some View { if #available(iOS 26.0, *) { - glassEffect(.regular, in: RoundedRectangle(cornerRadius: 28, style: .continuous)) + glassEffect(.regular, in: Capsule()) } else { - background(.regularMaterial, in: RoundedRectangle(cornerRadius: 28, style: .continuous)) + background(Color(.secondarySystemBackground), in: Capsule()) .overlay { - RoundedRectangle(cornerRadius: 28, style: .continuous) + Capsule() .strokeBorder(Color.secondary.opacity(0.16), lineWidth: 0.5) } - .shadow(color: .black.opacity(0.12), radius: 14, x: 0, y: 4) + } + } + + @ViewBuilder + func commentComposerBarChrome() -> some View { + if #available(iOS 26.0, *) { + background(.clear) + } else { + background(.bar) + .overlay(alignment: .top) { + Divider() + } } } } From 26f9d90b40ae4823b635ff86d8f54f33cbe8fa5b Mon Sep 17 00:00:00 2001 From: pgs666 <74764545+pgs666@users.noreply.github.com> Date: Tue, 2 Jun 2026 19:26:35 +0800 Subject: [PATCH 037/216] fix(video): float comment composer above pager --- .../2026-06-02-1922-float-comment-composer.md | 36 +++++ iosApp/VideoDetailView.swift | 123 +++++++++--------- 2 files changed, 98 insertions(+), 61 deletions(-) create mode 100644 docs/agent-logs/2026-06-02-1922-float-comment-composer.md diff --git a/docs/agent-logs/2026-06-02-1922-float-comment-composer.md b/docs/agent-logs/2026-06-02-1922-float-comment-composer.md new file mode 100644 index 00000000..2621b8df --- /dev/null +++ b/docs/agent-logs/2026-06-02-1922-float-comment-composer.md @@ -0,0 +1,36 @@ +# Agent Log: Float Comment Composer + +## User Input + +Original: + +```text +评论按钮实现依然不对:我希望它悬浮在简介/评论 view上面,检测到在评论view里面就自动出现,如果不在就自动消失,而不是把它整个放进评论区的scroll view里面。然后这个输入框我希望它能跟随输入法上边缘浮起,而不是被输入法挡住。先搜索再做 +``` + +English translation: + +```text +The comment button implementation is still wrong: I want it to float over the introduction/comments views, automatically appear when the comments view is active, and disappear when it is not. It should not be placed inside the comments scroll view. The input field should also float with the keyboard's top edge instead of being covered by the keyboard. Search first, then implement. +``` + +## What Changed + +- Moved the main comment composer out of the comments `ScrollView`. +- Mounted the composer as a floating overlay on the introduction/comments pager container. +- The composer now appears only when the active tab is `.comments`, and disappears on `.introduction`. +- Marked the floating composer as a horizontal-pager exclusion area so input/button touches do not trigger page swipes. +- Stopped ignoring keyboard safe-area regions in inline video detail mode by switching to container-only safe-area ignoring, and only in fullscreen. +- Added interactive keyboard dismissal to the tab scroll views. +- Added a little extra bottom padding to the comments content so the floating composer does not cover the last visible comments. + +## Why + +Apple's SwiftUI safe-area model treats keyboard avoidance as a safe-area adjustment. The previous root-level safe-area ignore used the default safe-area regions, which can include the keyboard region. Keeping inline mode inside the keyboard-safe layout lets the floating composer move with the keyboard without manual keyboard-height observers. + +The previous composer was attached to the comments `ScrollView` as a bottom inset. That made it part of the scroll view's layout instead of a layer controlled by the active pager tab. + +## Verification + +- `git diff --check` passed. +- `./gradlew :shared:jvmTest` passed on local Linux aarch64. Kotlin/Native remains unsupported on this host, so the iOS Swift build is verified through GitHub Actions. diff --git a/iosApp/VideoDetailView.swift b/iosApp/VideoDetailView.swift index 268e16e2..37e8b62a 100644 --- a/iosApp/VideoDetailView.swift +++ b/iosApp/VideoDetailView.swift @@ -77,7 +77,7 @@ struct VideoDetailView: View { // again, producing the slide-in/out animation. .hidesTabBarOnAppear() .statusBarHidden(isPlayerFullscreen) - .ignoresSafeArea(edges: isPlayerFullscreen ? .all : .bottom) + .ignoresSafeArea(.container, edges: ignoredContainerSafeAreaEdges) .task { viewModel.loadIfNeeded(videoCode: videoCode) } @@ -165,6 +165,10 @@ struct VideoDetailView: View { return .landscape } + private var ignoredContainerSafeAreaEdges: Edge.Set { + isPlayerFullscreen ? .all : Edge.Set() + } + @ViewBuilder private var content: some View { switch viewModel.state { @@ -346,49 +350,59 @@ struct VideoDetailView: View { .padding(.vertical, 8) .background(.background) - VideoDetailTabPager( - selectedTab: $selectedTab, - gestureCoordinator: gestureCoordinator, - excludedDragStartFrames: horizontalPagerExclusionFrames - ) { - tabScroll(.introduction) { - AndroidStyleIntroduction( - snapshot: snapshot, - videoFeature: videoFeature, - commentFeature: commentFeature, - isArtistActionRunning: viewModel.isActionRunning("artistSubscription"), - onToggleArtistSubscription: { viewModel.toggleArtistSubscription(snapshot: snapshot) }, - onToggleFavorite: { viewModel.toggleFavorite(snapshot: snapshot) }, - onToggleWatchLater: { viewModel.toggleWatchLater(snapshot: snapshot) }, - onSetMyListItem: { item, isSelected in viewModel.setMyListItem(snapshot: snapshot, item: item, isSelected: isSelected) }, - onShowMessage: { viewModel.showActionMessage($0) }, - showsRelated: showsRelated - ) - .padding(.top, 16) - } collapseCompensation: { - tabCollapseCompensation(for: .introduction, collapseCompensation: collapseCompensation) - } collapseDistance: { - collapseDistance - } - } comments: { - tabScroll(.comments) { - CommentView(viewModel: commentViewModel) - .padding(.top, 16) - } collapseCompensation: { - tabCollapseCompensation(for: .comments, collapseCompensation: collapseCompensation) - } collapseDistance: { - collapseDistance - } bottomAccessory: { - AnyView( - CommentComposerBar( - text: $commentComposeText, - isSending: commentViewModel.runningActionIDs.contains("post-comment"), - onSubmit: submitComment + ZStack(alignment: .bottom) { + VideoDetailTabPager( + selectedTab: $selectedTab, + gestureCoordinator: gestureCoordinator, + excludedDragStartFrames: horizontalPagerExclusionFrames + ) { + tabScroll(.introduction) { + AndroidStyleIntroduction( + snapshot: snapshot, + videoFeature: videoFeature, + commentFeature: commentFeature, + isArtistActionRunning: viewModel.isActionRunning("artistSubscription"), + onToggleArtistSubscription: { viewModel.toggleArtistSubscription(snapshot: snapshot) }, + onToggleFavorite: { viewModel.toggleFavorite(snapshot: snapshot) }, + onToggleWatchLater: { viewModel.toggleWatchLater(snapshot: snapshot) }, + onSetMyListItem: { item, isSelected in viewModel.setMyListItem(snapshot: snapshot, item: item, isSelected: isSelected) }, + onShowMessage: { viewModel.showActionMessage($0) }, + showsRelated: showsRelated ) + .padding(.top, 16) + } collapseCompensation: { + tabCollapseCompensation(for: .introduction, collapseCompensation: collapseCompensation) + } collapseDistance: { + collapseDistance + } + } comments: { + tabScroll( + .comments, + contentBottomPadding: 88 + ) { + CommentView(viewModel: commentViewModel) + .padding(.top, 16) + } collapseCompensation: { + tabCollapseCompensation(for: .comments, collapseCompensation: collapseCompensation) + } collapseDistance: { + collapseDistance + } + } + .frame(maxHeight: .infinity) + + if selectedTab == .comments { + CommentComposerBar( + text: $commentComposeText, + isSending: commentViewModel.runningActionIDs.contains("post-comment"), + onSubmit: submitComment ) + .horizontalPagerExclusionArea() + .transition(.move(edge: .bottom).combined(with: .opacity)) + .zIndex(1) } } .frame(maxHeight: .infinity) + .animation(.easeInOut(duration: 0.2), value: selectedTab) } .frame(maxHeight: .infinity) .onPreferenceChange(BottomScrollOffsetPreferenceKey.self) { offsets in @@ -437,10 +451,10 @@ struct VideoDetailView: View { private func tabScroll( _ tab: VideoPageTab, + contentBottomPadding: CGFloat = 24, @ViewBuilder content: @escaping () -> Content, collapseCompensation: @escaping () -> CGFloat = { 0 }, - collapseDistance: @escaping () -> CGFloat = { 0 }, - bottomAccessory: (() -> AnyView)? = nil + collapseDistance: @escaping () -> CGFloat = { 0 } ) -> some View { GeometryReader { proxy in let collapseScrollSpacerHeight = collapseDistance() + 1 @@ -458,19 +472,15 @@ struct VideoDetailView: View { content() .frame(maxWidth: .infinity, minHeight: proxy.size.height, alignment: .top) - .padding(.bottom, 24) + .padding(.bottom, contentBottomPadding) .offset(y: collapseCompensation()) Color.clear .frame(height: collapseScrollSpacerHeight) } .coordinateSpace(name: tab.scrollCoordinateSpaceName) + .scrollDismissesKeyboard(.interactively) .id(tab) - .commentComposerSafeAreaInset(isVisible: bottomAccessory != nil) { - if let bottomAccessory { - bottomAccessory() - } - } } } @@ -483,6 +493,7 @@ private struct CommentComposerBar: View { @Binding var text: String let isSending: Bool let onSubmit: () -> Void + @FocusState private var isFieldFocused: Bool private var canSubmit: Bool { text.trimmingCharacters(in: .whitespacesAndNewlines).count >= 2 && !isSending @@ -494,6 +505,7 @@ private struct CommentComposerBar: View { .textFieldStyle(.plain) .submitLabel(.send) .lineLimit(1...4) + .focused($isFieldFocused) .onSubmit { guard canSubmit else { return } onSubmit() @@ -516,24 +528,13 @@ private struct CommentComposerBar: View { .padding(.vertical, 8) .frame(maxWidth: .infinity) .commentComposerBarChrome() + .onDisappear { + isFieldFocused = false + } } } private extension View { - @ViewBuilder - func commentComposerSafeAreaInset( - isVisible: Bool, - @ViewBuilder bar: () -> Bar - ) -> some View { - if isVisible { - safeAreaInset(edge: .bottom, spacing: 0) { - bar() - } - } else { - self - } - } - @ViewBuilder func commentComposerFieldChrome() -> some View { if #available(iOS 26.0, *) { From 5e70d56656e11ca8d886f59bb5f72d980762432f Mon Sep 17 00:00:00 2001 From: pgs666 <74764545+pgs666@users.noreply.github.com> Date: Tue, 2 Jun 2026 19:43:54 +0800 Subject: [PATCH 038/216] fix(video): lift comment composer above detail --- ...-1937-anchor-comment-composer-safe-area.md | 31 ++++ .../2026-06-02-1942-root-comment-composer.md | 36 +++++ iosApp/CommentView.swift | 20 ++- iosApp/VideoDetailView.swift | 140 +++++++++++------- 4 files changed, 170 insertions(+), 57 deletions(-) create mode 100644 docs/agent-logs/2026-06-02-1937-anchor-comment-composer-safe-area.md create mode 100644 docs/agent-logs/2026-06-02-1942-root-comment-composer.md diff --git a/docs/agent-logs/2026-06-02-1937-anchor-comment-composer-safe-area.md b/docs/agent-logs/2026-06-02-1937-anchor-comment-composer-safe-area.md new file mode 100644 index 00000000..621161fa --- /dev/null +++ b/docs/agent-logs/2026-06-02-1937-anchor-comment-composer-safe-area.md @@ -0,0 +1,31 @@ +# Agent Log: Anchor Comment Composer Safe Area + +## User Input + +Original: + +```text +能不能把评论框铆定在屏幕内部?现在还是会出屏幕外。另外现在安全区设计又出问题了,scrollview无论何时都与屏幕最下面有一层gap +``` + +English translation: + +```text +Can you anchor the comment box inside the screen? It still goes outside the screen now. Also the safe-area design is broken again: the scroll view always has a gap above the bottom of the screen. +``` + +## What Changed + +- Restored inline video-detail content to ignore only the container bottom safe area, not the keyboard safe area. +- Passed the outer geometry bottom safe-area inset into the floating comment composer. +- Added the bottom safe-area inset inside the composer chrome so the input field stays above the home indicator. +- Increased comments tab bottom content clearance by the same inset so the floating composer does not cover the last comments. + +## Why + +The previous change stopped ignoring the container bottom safe area in inline mode. That fixed keyboard avoidance but reintroduced the visible bottom gap under the pager/scroll area. The correct split is to ignore only `.container` bottom for the page, while keeping keyboard safe-area avoidance active. The floating composer then needs its own bottom inset so it remains inside the screen. + +## Verification + +- Superseded by the root-level composer change logged in `2026-06-02-1942-root-comment-composer.md`. +- Local checks and GitHub Actions CI are run after both changes together. diff --git a/docs/agent-logs/2026-06-02-1942-root-comment-composer.md b/docs/agent-logs/2026-06-02-1942-root-comment-composer.md new file mode 100644 index 00000000..b5ddd66f --- /dev/null +++ b/docs/agent-logs/2026-06-02-1942-root-comment-composer.md @@ -0,0 +1,36 @@ +# Agent Log: Root Comment Composer + +## User Input + +Original: + +```text +评论框为什么不能在整个view的最上层呢,就像首页的tab栏一样 +还有点击查看x条回复的时候,回复别人的时候,这个输入框都要隐藏 +``` + +English translation: + +```text +Why can't the comment box be at the top layer of the whole view, like the home tab bar? +Also, when tapping to view x replies or replying to someone, this input box should be hidden. +``` + +## What Changed + +- Promoted the main comment composer to a root-level `VideoDetailView` overlay, above the player/detail/pager content stack. +- Scoped safe-area ignoring to the content layer, so the detail scroll area can still reach the screen bottom while the composer remains anchored as page chrome. +- Moved horizontal pager exclusion-frame collection to the root layer so touches on the composer do not start horizontal paging. +- Added a `CommentView` callback for internal comment overlays. +- Hid the root composer while reply sheets or reply-thread sheets are active. + +## Why + +The composer is page chrome, not comments-list content. Keeping it at the root layer avoids clipping and frame constraints from the pager and scroll views, matching the way a tab bar floats above page content. + +Reply and reply-thread flows have their own input context. Showing the root-level main comment composer under those flows is visually confusing and can compete with the sheet's focused input. + +## Verification + +- `git diff --check` passed. +- `./gradlew :shared:jvmTest` passed on local Linux aarch64. Kotlin/Native remains unsupported on this host, so the iOS Swift build is verified through GitHub Actions. diff --git a/iosApp/CommentView.swift b/iosApp/CommentView.swift index 119d14c0..c2655317 100644 --- a/iosApp/CommentView.swift +++ b/iosApp/CommentView.swift @@ -3,13 +3,18 @@ import Han1meShared struct CommentView: View { @ObservedObject private var viewModel: CommentViewModel + private let onOverlayActivityChanged: (Bool) -> Void @State private var replyTarget: CommentRow? @State private var replyText = "" @State private var reportTarget: CommentRow? @State private var repliesTarget: CommentRow? - init(viewModel: CommentViewModel) { + init( + viewModel: CommentViewModel, + onOverlayActivityChanged: @escaping (Bool) -> Void = { _ in } + ) { _viewModel = ObservedObject(wrappedValue: viewModel) + self.onOverlayActivityChanged = onOverlayActivityChanged } var body: some View { @@ -21,6 +26,15 @@ struct CommentView: View { .task { viewModel.loadIfNeeded() } + .onValueChange(of: replyTarget?.id) { _ in + notifyOverlayActivityChanged() + } + .onValueChange(of: repliesTarget?.id) { _ in + notifyOverlayActivityChanged() + } + .onDisappear { + onOverlayActivityChanged(false) + } .alert("提示", isPresented: actionMessageBinding) { Button("好", role: .cancel) { viewModel.actionMessage = nil @@ -193,6 +207,10 @@ struct CommentView: View { set: { if !$0 { reportTarget = nil } } ) } + + private func notifyOverlayActivityChanged() { + onOverlayActivityChanged(replyTarget != nil || repliesTarget != nil) + } } private struct CommentRowView: View { diff --git a/iosApp/VideoDetailView.swift b/iosApp/VideoDetailView.swift index 37e8b62a..a6228213 100644 --- a/iosApp/VideoDetailView.swift +++ b/iosApp/VideoDetailView.swift @@ -29,6 +29,7 @@ struct VideoDetailView: View { @State private var bottomScrollOffsetsByTab: [VideoPageTab: CGFloat] = [:] @State private var lastSelectedTabChangeAt = Date.distantPast @State private var commentComposeText = "" + @State private var isCommentInternalOverlayActive = false /// Natural size of the loaded video (reported by KSPlayer the first time /// the underlying player gets a non-zero presentation size). Used to /// decide whether fullscreen should lock the device to portrait or @@ -54,7 +55,18 @@ struct VideoDetailView: View { } var body: some View { - content + GeometryReader { proxy in + ZStack(alignment: .bottom) { + content + .ignoresSafeArea(.container, edges: ignoredContainerSafeAreaEdges) + + rootCommentComposer(bottomSafeAreaInset: proxy.safeAreaInsets.bottom) + } + .animation(.easeInOut(duration: 0.2), value: shouldShowRootCommentComposer) + .onPreferenceChange(HorizontalPagerExclusionFramePreferenceKey.self) { frames in + horizontalPagerExclusionFrames = frames + } + } .logScreen("VideoDetail v=\(videoCode)") // Navigation bar (and its system back button) is hidden the // whole time. The player draws its own floating back button @@ -77,7 +89,6 @@ struct VideoDetailView: View { // again, producing the slide-in/out animation. .hidesTabBarOnAppear() .statusBarHidden(isPlayerFullscreen) - .ignoresSafeArea(.container, edges: ignoredContainerSafeAreaEdges) .task { viewModel.loadIfNeeded(videoCode: videoCode) } @@ -166,7 +177,31 @@ struct VideoDetailView: View { } private var ignoredContainerSafeAreaEdges: Edge.Set { - isPlayerFullscreen ? .all : Edge.Set() + isPlayerFullscreen ? .all : .bottom + } + + private var shouldShowRootCommentComposer: Bool { + guard !isPlayerFullscreen, selectedTab == .comments else { return false } + guard !isCommentInternalOverlayActive else { return false } + if case .loaded = viewModel.state { + return true + } + return false + } + + @ViewBuilder + private func rootCommentComposer(bottomSafeAreaInset: CGFloat) -> some View { + if shouldShowRootCommentComposer { + CommentComposerBar( + text: $commentComposeText, + isSending: commentViewModel.runningActionIDs.contains("post-comment"), + bottomSafeAreaInset: bottomSafeAreaInset, + onSubmit: submitComment + ) + .horizontalPagerExclusionArea() + .transition(.move(edge: .bottom).combined(with: .opacity)) + .zIndex(1) + } } @ViewBuilder @@ -243,7 +278,8 @@ struct VideoDetailView: View { snapshot: snapshot, showsRelated: !isWide, collapseDistance: isPlayerCollapsed || isPlayerPlaying ? 0 : currentPlayerCollapseDistance, - collapseCompensation: isPlayerCollapsed || isPlayerPlaying ? 0 : currentPlayerShrink + collapseCompensation: isPlayerCollapsed || isPlayerPlaying ? 0 : currentPlayerShrink, + bottomSafeAreaInset: proxy.safeAreaInsets.bottom ) .frame(height: max(0, currentBottomScrollHeight)) .offset(y: currentPlayerHeight) @@ -337,8 +373,10 @@ struct VideoDetailView: View { snapshot: VideoDetailScreenSnapshot, showsRelated: Bool, collapseDistance: CGFloat, - collapseCompensation: CGFloat + collapseCompensation: CGFloat, + bottomSafeAreaInset: CGFloat ) -> some View { + let composerClearance = 88 + bottomSafeAreaInset VStack(spacing: 0) { Picker("Content", selection: $selectedTab) { ForEach(VideoPageTab.allCases) { tab in @@ -350,55 +388,46 @@ struct VideoDetailView: View { .padding(.vertical, 8) .background(.background) - ZStack(alignment: .bottom) { - VideoDetailTabPager( - selectedTab: $selectedTab, - gestureCoordinator: gestureCoordinator, - excludedDragStartFrames: horizontalPagerExclusionFrames - ) { - tabScroll(.introduction) { - AndroidStyleIntroduction( - snapshot: snapshot, - videoFeature: videoFeature, - commentFeature: commentFeature, - isArtistActionRunning: viewModel.isActionRunning("artistSubscription"), - onToggleArtistSubscription: { viewModel.toggleArtistSubscription(snapshot: snapshot) }, - onToggleFavorite: { viewModel.toggleFavorite(snapshot: snapshot) }, - onToggleWatchLater: { viewModel.toggleWatchLater(snapshot: snapshot) }, - onSetMyListItem: { item, isSelected in viewModel.setMyListItem(snapshot: snapshot, item: item, isSelected: isSelected) }, - onShowMessage: { viewModel.showActionMessage($0) }, - showsRelated: showsRelated - ) - .padding(.top, 16) - } collapseCompensation: { - tabCollapseCompensation(for: .introduction, collapseCompensation: collapseCompensation) - } collapseDistance: { - collapseDistance - } - } comments: { - tabScroll( - .comments, - contentBottomPadding: 88 - ) { - CommentView(viewModel: commentViewModel) - .padding(.top, 16) - } collapseCompensation: { - tabCollapseCompensation(for: .comments, collapseCompensation: collapseCompensation) - } collapseDistance: { - collapseDistance - } + VideoDetailTabPager( + selectedTab: $selectedTab, + gestureCoordinator: gestureCoordinator, + excludedDragStartFrames: horizontalPagerExclusionFrames + ) { + tabScroll(.introduction) { + AndroidStyleIntroduction( + snapshot: snapshot, + videoFeature: videoFeature, + commentFeature: commentFeature, + isArtistActionRunning: viewModel.isActionRunning("artistSubscription"), + onToggleArtistSubscription: { viewModel.toggleArtistSubscription(snapshot: snapshot) }, + onToggleFavorite: { viewModel.toggleFavorite(snapshot: snapshot) }, + onToggleWatchLater: { viewModel.toggleWatchLater(snapshot: snapshot) }, + onSetMyListItem: { item, isSelected in viewModel.setMyListItem(snapshot: snapshot, item: item, isSelected: isSelected) }, + onShowMessage: { viewModel.showActionMessage($0) }, + showsRelated: showsRelated + ) + .padding(.top, 16) + } collapseCompensation: { + tabCollapseCompensation(for: .introduction, collapseCompensation: collapseCompensation) + } collapseDistance: { + collapseDistance } - .frame(maxHeight: .infinity) - - if selectedTab == .comments { - CommentComposerBar( - text: $commentComposeText, - isSending: commentViewModel.runningActionIDs.contains("post-comment"), - onSubmit: submitComment + } comments: { + tabScroll( + .comments, + contentBottomPadding: composerClearance + ) { + CommentView( + viewModel: commentViewModel, + onOverlayActivityChanged: { isActive in + isCommentInternalOverlayActive = isActive + } ) - .horizontalPagerExclusionArea() - .transition(.move(edge: .bottom).combined(with: .opacity)) - .zIndex(1) + .padding(.top, 16) + } collapseCompensation: { + tabCollapseCompensation(for: .comments, collapseCompensation: collapseCompensation) + } collapseDistance: { + collapseDistance } } .frame(maxHeight: .infinity) @@ -424,9 +453,6 @@ struct VideoDetailView: View { lastSelectedTabChangeAt = Date() bottomScrollOffset = min(max(bottomScrollOffset, 0), collapseDistance) } - .onPreferenceChange(HorizontalPagerExclusionFramePreferenceKey.self) { frames in - horizontalPagerExclusionFrames = frames - } } private func updatePlayerCollapseOffset( @@ -492,6 +518,7 @@ struct VideoDetailView: View { private struct CommentComposerBar: View { @Binding var text: String let isSending: Bool + let bottomSafeAreaInset: CGFloat let onSubmit: () -> Void @FocusState private var isFieldFocused: Bool @@ -525,7 +552,8 @@ private struct CommentComposerBar: View { .accessibilityLabel("发送评论") } .padding(.horizontal, 16) - .padding(.vertical, 8) + .padding(.top, 8) + .padding(.bottom, 8 + bottomSafeAreaInset) .frame(maxWidth: .infinity) .commentComposerBarChrome() .onDisappear { From 2478425a030461f813ba17a1582b1ec68688ef98 Mon Sep 17 00:00:00 2001 From: pgs666 <74764545+pgs666@users.noreply.github.com> Date: Tue, 2 Jun 2026 19:49:53 +0800 Subject: [PATCH 039/216] fix(video): add explicit composer layout return --- docs/agent-logs/2026-06-02-1942-root-comment-composer.md | 5 +++++ iosApp/VideoDetailView.swift | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/docs/agent-logs/2026-06-02-1942-root-comment-composer.md b/docs/agent-logs/2026-06-02-1942-root-comment-composer.md index b5ddd66f..ba2ce7dd 100644 --- a/docs/agent-logs/2026-06-02-1942-root-comment-composer.md +++ b/docs/agent-logs/2026-06-02-1942-root-comment-composer.md @@ -34,3 +34,8 @@ Reply and reply-thread flows have their own input context. Showing the root-leve - `git diff --check` passed. - `./gradlew :shared:jvmTest` passed on local Linux aarch64. Kotlin/Native remains unsupported on this host, so the iOS Swift build is verified through GitHub Actions. + +## Follow-Up + +- First GitHub Actions Swift build failed because `belowPlayerScroll -> some View` gained a local `let` before the view expression and therefore needed an explicit `return`. +- Added the explicit `return` before re-running checks. diff --git a/iosApp/VideoDetailView.swift b/iosApp/VideoDetailView.swift index a6228213..1ff1a1ae 100644 --- a/iosApp/VideoDetailView.swift +++ b/iosApp/VideoDetailView.swift @@ -377,7 +377,7 @@ struct VideoDetailView: View { bottomSafeAreaInset: CGFloat ) -> some View { let composerClearance = 88 + bottomSafeAreaInset - VStack(spacing: 0) { + return VStack(spacing: 0) { Picker("Content", selection: $selectedTab) { ForEach(VideoPageTab.allCases) { tab in Text(tab.title).tag(tab) From 66dd0ddb6d21e06354dd007b30a8abce9959dd72 Mon Sep 17 00:00:00 2001 From: pgs666 <74764545+pgs666@users.noreply.github.com> Date: Tue, 2 Jun 2026 20:15:18 +0800 Subject: [PATCH 040/216] fix(video): tune comment composer keyboard glass --- ...02-2014-comment-composer-keyboard-glass.md | 41 ++++++++++ iosApp/VideoDetailView.swift | 79 ++++++++++++++----- 2 files changed, 102 insertions(+), 18 deletions(-) create mode 100644 docs/agent-logs/2026-06-02-2014-comment-composer-keyboard-glass.md diff --git a/docs/agent-logs/2026-06-02-2014-comment-composer-keyboard-glass.md b/docs/agent-logs/2026-06-02-2014-comment-composer-keyboard-glass.md new file mode 100644 index 00000000..b3901ea4 --- /dev/null +++ b/docs/agent-logs/2026-06-02-2014-comment-composer-keyboard-glass.md @@ -0,0 +1,41 @@ +# Agent Log: Comment Composer Keyboard and Glass + +## User Input + +Original: + +```text +输入框的位置不对,首先就是在没有激活输入的时候位置太高,离屏幕底部太远,我希望他和tabbar的高度是一样的。然后就是激活输入后,输入框的位置达到了两倍的输入法高度以上,你搜搜应该怎么正确处理 +还有,当前输入框的处理对于iPad来说是不是有问题?检查一下(我自己还没测试过,请你看看代码) +另外,当前输入框虽然有玻璃效果,但是没有弹性效果,旁边的发送按钮页也没有玻璃效果和弹性效果,请你针对iOS26继续看文档代码 +``` + +English translation: + +```text +The input field position is wrong. First, when input is inactive it is too high and too far from the bottom; I want it to match the tab bar height. Then when input is active, it rises to more than twice the keyboard height. Search how this should be handled correctly. +Also, is the current input handling a problem for iPad? Check the code, I haven't tested it myself yet. +Also, the current input field has a glass effect but no elastic effect, and the send button has neither glass nor elastic effect. Continue checking the iOS 26 docs/code. +``` + +## What Changed + +- Removed manual bottom safe-area padding from the root comment composer so SwiftUI keyboard avoidance is not double-counted. +- Made the compact composer chrome use a 49 pt minimum height, matching the tab-bar content height expectation when the keyboard is inactive. +- Kept comment-list bottom clearance separate from the composer's actual position. +- Detected keyboard safe-area activation by comparing SwiftUI geometry safe-area bottom against the key window's container bottom inset, rather than using input focus. +- Wrapped the iOS 26 comment controls in `GlassEffectContainer`. +- Changed the input capsule to `.glassEffect(.regular.interactive(), in: Capsule())`. +- Added an iOS 26 circular interactive glass effect to the send button while preserving the fixed 42 pt hit target. + +## Why + +SwiftUI already treats the software keyboard as a safe-area adjustment when the view does not ignore the keyboard safe area. Manually adding the geometry bottom inset to the composer causes the focused bar to move by the keyboard height and then add the same inset again. + +Focus is not a reliable keyboard-height signal on iPad because hardware keyboards and floating/split software keyboards can focus a text field without producing a full bottom keyboard safe area. The composer should stay in normal root layout and let the system keyboard safe area move it only when the system actually exposes that inset. + +Apple's Liquid Glass documentation says custom components need `interactive(_:)` to get the same responsive touch and pointer reaction that standard glass buttons provide, and recommends `GlassEffectContainer` when multiple glass shapes are used together. + +## Verification + +- Pending local checks and GitHub Actions CI after push. diff --git a/iosApp/VideoDetailView.swift b/iosApp/VideoDetailView.swift index 1ff1a1ae..ae981174 100644 --- a/iosApp/VideoDetailView.swift +++ b/iosApp/VideoDetailView.swift @@ -60,7 +60,7 @@ struct VideoDetailView: View { content .ignoresSafeArea(.container, edges: ignoredContainerSafeAreaEdges) - rootCommentComposer(bottomSafeAreaInset: proxy.safeAreaInsets.bottom) + rootCommentComposer() } .animation(.easeInOut(duration: 0.2), value: shouldShowRootCommentComposer) .onPreferenceChange(HorizontalPagerExclusionFramePreferenceKey.self) { frames in @@ -190,12 +190,11 @@ struct VideoDetailView: View { } @ViewBuilder - private func rootCommentComposer(bottomSafeAreaInset: CGFloat) -> some View { + private func rootCommentComposer() -> some View { if shouldShowRootCommentComposer { CommentComposerBar( text: $commentComposeText, isSending: commentViewModel.runningActionIDs.contains("post-comment"), - bottomSafeAreaInset: bottomSafeAreaInset, onSubmit: submitComment ) .horizontalPagerExclusionArea() @@ -279,7 +278,9 @@ struct VideoDetailView: View { showsRelated: !isWide, collapseDistance: isPlayerCollapsed || isPlayerPlaying ? 0 : currentPlayerCollapseDistance, collapseCompensation: isPlayerCollapsed || isPlayerPlaying ? 0 : currentPlayerShrink, - bottomSafeAreaInset: proxy.safeAreaInsets.bottom + composerContentClearance: commentComposerContentClearance( + safeAreaBottom: proxy.safeAreaInsets.bottom + ) ) .frame(height: max(0, currentBottomScrollHeight)) .offset(y: currentPlayerHeight) @@ -374,9 +375,8 @@ struct VideoDetailView: View { showsRelated: Bool, collapseDistance: CGFloat, collapseCompensation: CGFloat, - bottomSafeAreaInset: CGFloat + composerContentClearance: CGFloat ) -> some View { - let composerClearance = 88 + bottomSafeAreaInset return VStack(spacing: 0) { Picker("Content", selection: $selectedTab) { ForEach(VideoPageTab.allCases) { tab in @@ -415,7 +415,7 @@ struct VideoDetailView: View { } comments: { tabScroll( .comments, - contentBottomPadding: composerClearance + contentBottomPadding: composerContentClearance ) { CommentView( viewModel: commentViewModel, @@ -513,12 +513,28 @@ struct VideoDetailView: View { private func tabCollapseCompensation(for tab: VideoPageTab, collapseCompensation: CGFloat) -> CGFloat { min(max(bottomScrollOffsetsByTab[tab] ?? 0, 0), collapseCompensation) } + + private func commentComposerContentClearance(safeAreaBottom: CGFloat) -> CGFloat { + let containerBottomInset = currentWindowBottomSafeAreaInset() + let isKeyboardSafeAreaActive = safeAreaBottom > containerBottomInset + 1 + return CommentComposerBar.compactHeight + (isKeyboardSafeAreaActive ? 0 : containerBottomInset) + } + + private func currentWindowBottomSafeAreaInset() -> CGFloat { + UIApplication.shared.connectedScenes + .compactMap { $0 as? UIWindowScene } + .flatMap(\.windows) + .first { $0.isKeyWindow }? + .safeAreaInsets + .bottom ?? 0 + } } private struct CommentComposerBar: View { + static let compactHeight: CGFloat = 49 + @Binding var text: String let isSending: Bool - let bottomSafeAreaInset: CGFloat let onSubmit: () -> Void @FocusState private var isFieldFocused: Bool @@ -527,6 +543,28 @@ private struct CommentComposerBar: View { } var body: some View { + composerControls + .padding(.horizontal, 16) + .frame(minHeight: Self.compactHeight) + .frame(maxWidth: .infinity) + .commentComposerBarChrome() + .onDisappear { + isFieldFocused = false + } + } + + @ViewBuilder + private var composerControls: some View { + if #available(iOS 26.0, *) { + GlassEffectContainer(spacing: 10) { + composerControlRow + } + } else { + composerControlRow + } + } + + private var composerControlRow: some View { HStack(spacing: 10) { TextField("输入评论", text: $text, axis: .vertical) .textFieldStyle(.plain) @@ -540,6 +578,7 @@ private struct CommentComposerBar: View { .padding(.horizontal, 14) .padding(.vertical, 10) .commentComposerFieldChrome() + .layoutPriority(1) Button(action: onSubmit) { Image(systemName: isSending ? "hourglass" : "paperplane.fill") @@ -547,17 +586,9 @@ private struct CommentComposerBar: View { .frame(width: 42, height: 42) } .disabled(!canSubmit) - .buttonStyle(.plain) .foregroundStyle(canSubmit ? Color.accentColor : Color.secondary) .accessibilityLabel("发送评论") - } - .padding(.horizontal, 16) - .padding(.top, 8) - .padding(.bottom, 8 + bottomSafeAreaInset) - .frame(maxWidth: .infinity) - .commentComposerBarChrome() - .onDisappear { - isFieldFocused = false + .commentComposerSendButtonChrome(isEnabled: canSubmit) } } } @@ -566,7 +597,8 @@ private extension View { @ViewBuilder func commentComposerFieldChrome() -> some View { if #available(iOS 26.0, *) { - glassEffect(.regular, in: Capsule()) + contentShape(Capsule()) + .glassEffect(.regular.interactive(), in: Capsule()) } else { background(Color(.secondarySystemBackground), in: Capsule()) .overlay { @@ -576,6 +608,17 @@ private extension View { } } + @ViewBuilder + func commentComposerSendButtonChrome(isEnabled: Bool) -> some View { + if #available(iOS 26.0, *) { + buttonStyle(.plain) + .contentShape(Circle()) + .glassEffect(.regular.interactive(isEnabled), in: Circle()) + } else { + buttonStyle(.plain) + } + } + @ViewBuilder func commentComposerBarChrome() -> some View { if #available(iOS 26.0, *) { From c5bb985e576d4e88d753f9ff00cc3524998466b9 Mon Sep 17 00:00:00 2001 From: pgs666 <74764545+pgs666@users.noreply.github.com> Date: Tue, 2 Jun 2026 20:23:09 +0800 Subject: [PATCH 041/216] docs: record comment composer ci verification --- .../2026-06-02-2014-comment-composer-keyboard-glass.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/agent-logs/2026-06-02-2014-comment-composer-keyboard-glass.md b/docs/agent-logs/2026-06-02-2014-comment-composer-keyboard-glass.md index b3901ea4..85e339b8 100644 --- a/docs/agent-logs/2026-06-02-2014-comment-composer-keyboard-glass.md +++ b/docs/agent-logs/2026-06-02-2014-comment-composer-keyboard-glass.md @@ -38,4 +38,6 @@ Apple's Liquid Glass documentation says custom components need `interactive(_:)` ## Verification -- Pending local checks and GitHub Actions CI after push. +- `git diff --check` passed. +- `./gradlew :shared:jvmTest` passed on local Linux aarch64. +- GitHub Actions `iOS App Build` passed on run `26819040731` with Xcode 26.2, including unsigned device app build, IPA packaging, and upload. From 8b203324886fc0c967b3f2167ebdf01223d15539 Mon Sep 17 00:00:00 2001 From: pgs666 <74764545+pgs666@users.noreply.github.com> Date: Tue, 2 Jun 2026 20:34:38 +0800 Subject: [PATCH 042/216] fix(video): constrain ipad comment composer width --- ...-06-02-2034-ipad-comment-composer-width.md | 31 +++++++++++++++++ iosApp/VideoDetailView.swift | 34 +++++++++++++------ 2 files changed, 54 insertions(+), 11 deletions(-) create mode 100644 docs/agent-logs/2026-06-02-2034-ipad-comment-composer-width.md diff --git a/docs/agent-logs/2026-06-02-2034-ipad-comment-composer-width.md b/docs/agent-logs/2026-06-02-2034-ipad-comment-composer-width.md new file mode 100644 index 00000000..0564bf51 --- /dev/null +++ b/docs/agent-logs/2026-06-02-2034-ipad-comment-composer-width.md @@ -0,0 +1,31 @@ +# Agent Log: iPad Comment Composer Width + +## User Input + +Original: + +```text +现在布局问题不大了,但是iPad上还有点小问题,就是iPad上的评论输入框太宽了,挡住了旁边的相关影片列表一小部分,能做特殊处理吗,先告诉我能还是不能 +好的,开始做吧 +``` + +English translation: + +```text +The layout is mostly fine now, but there is still a small issue on iPad: the comment input is too wide and covers part of the related videos list. Can this be specially handled? First tell me whether it can be done. +Okay, start doing it. +``` + +## What Changed + +- Reused the iPad two-column layout calculation for both the left video/detail panel and the root comment composer. +- Constrained the root comment composer to the left panel width only when the iPad related-videos sidebar is active. +- Kept phone, iPad portrait, narrow iPad split-view, and fullscreen behavior on the existing full-width path. + +## Why + +The root comment composer is page chrome, but in iPad landscape two-column mode the page chrome should belong to the left video/detail panel. Letting it span the full root view makes it overlap the right related-videos sidebar. + +## Verification + +- Pending local diff check and GitHub Actions CI after push. diff --git a/iosApp/VideoDetailView.swift b/iosApp/VideoDetailView.swift index ae981174..27f7034e 100644 --- a/iosApp/VideoDetailView.swift +++ b/iosApp/VideoDetailView.swift @@ -6,6 +6,8 @@ struct VideoDetailView: View { let videoCode: String private let videoFeature: VideoFeature private let commentFeature: CommentFeature + private let tabletLeftMinimumWidth: CGFloat = 620 + private let tabletSidebarMinimumWidth: CGFloat = 360 @StateObject private var viewModel: VideoDetailViewModel @StateObject private var commentViewModel: CommentViewModel @State private var selectedTab = VideoPageTab.introduction @@ -60,7 +62,7 @@ struct VideoDetailView: View { content .ignoresSafeArea(.container, edges: ignoredContainerSafeAreaEdges) - rootCommentComposer() + rootCommentComposer(layoutSize: proxy.size) } .animation(.easeInOut(duration: 0.2), value: shouldShowRootCommentComposer) .onPreferenceChange(HorizontalPagerExclusionFramePreferenceKey.self) { frames in @@ -190,13 +192,15 @@ struct VideoDetailView: View { } @ViewBuilder - private func rootCommentComposer() -> some View { + private func rootCommentComposer(layoutSize: CGSize) -> some View { if shouldShowRootCommentComposer { CommentComposerBar( text: $commentComposeText, isSending: commentViewModel.runningActionIDs.contains("post-comment"), onSubmit: submitComment ) + .frame(width: leftPanelWidth(for: layoutSize)) + .frame(maxWidth: .infinity, alignment: .leading) .horizontalPagerExclusionArea() .transition(.move(edge: .bottom).combined(with: .opacity)) .zIndex(1) @@ -240,15 +244,8 @@ struct VideoDetailView: View { // Phone / iPad portrait collapses to a single full-width left panel // (no sidebar), giving the same visual as before for those modes. GeometryReader { proxy in - let leftMinimumWidth: CGFloat = 620 - let sidebarMinimumWidth: CGFloat = 360 - let isWide = horizontalSizeClass == .regular - && proxy.size.width >= leftMinimumWidth + sidebarMinimumWidth - && proxy.size.width > proxy.size.height - && !isPlayerFullscreen - let leftWidth: CGFloat = isWide - ? min(max(proxy.size.width * 0.64, leftMinimumWidth), proxy.size.width - sidebarMinimumWidth) - : proxy.size.width + let isWide = usesTabletRelatedSidebar(for: proxy.size) + let leftWidth = leftPanelWidth(for: proxy.size) HStack(alignment: .top, spacing: 0) { ZStack(alignment: .top) { @@ -305,6 +302,21 @@ struct VideoDetailView: View { } } + private func usesTabletRelatedSidebar(for size: CGSize) -> Bool { + horizontalSizeClass == .regular + && size.width >= tabletLeftMinimumWidth + tabletSidebarMinimumWidth + && size.width > size.height + && !isPlayerFullscreen + } + + private func leftPanelWidth(for size: CGSize) -> CGFloat { + guard usesTabletRelatedSidebar(for: size) else { return size.width } + return min( + max(size.width * 0.64, tabletLeftMinimumWidth), + size.width - tabletSidebarMinimumWidth + ) + } + /// Player 高度: /// - 全屏:撑满整个父容器 /// - 折叠:50pt 标题 strip From 2d48fae9103c4a10d69d21fa2ee24bfffe4b3f1e Mon Sep 17 00:00:00 2001 From: pgs666 <74764545+pgs666@users.noreply.github.com> Date: Tue, 2 Jun 2026 21:31:59 +0800 Subject: [PATCH 043/216] fix(video): harden detail composer and orientation --- iosApp/VideoDetailView.swift | 99 ++++++++++++++++++++++++++++++------ 1 file changed, 83 insertions(+), 16 deletions(-) diff --git a/iosApp/VideoDetailView.swift b/iosApp/VideoDetailView.swift index 27f7034e..d7ed68d5 100644 --- a/iosApp/VideoDetailView.swift +++ b/iosApp/VideoDetailView.swift @@ -32,6 +32,8 @@ struct VideoDetailView: View { @State private var lastSelectedTabChangeAt = Date.distantPast @State private var commentComposeText = "" @State private var isCommentInternalOverlayActive = false + @State private var commentComposerHeight: CGFloat = CommentComposerBar.compactHeight + @State private var fullscreenOrientationTask: Task? /// Natural size of the loaded video (reported by KSPlayer the first time /// the underlying player gets a non-zero presentation size). Used to /// decide whether fullscreen should lock the device to portrait or @@ -68,6 +70,10 @@ struct VideoDetailView: View { .onPreferenceChange(HorizontalPagerExclusionFramePreferenceKey.self) { frames in horizontalPagerExclusionFrames = frames } + .onPreferenceChange(CommentComposerHeightPreferenceKey.self) { height in + guard height > 0, abs(commentComposerHeight - height) > 0.5 else { return } + commentComposerHeight = height + } } .logScreen("VideoDetail v=\(videoCode)") // Navigation bar (and its system back button) is hidden the @@ -95,6 +101,8 @@ struct VideoDetailView: View { viewModel.loadIfNeeded(videoCode: videoCode) } .onDisappear { + fullscreenOrientationTask?.cancel() + fullscreenOrientationTask = nil // KSPlayer pauses itself in its own .onDisappear; the // detail VM no longer owns a player. if isPlayerFullscreen { @@ -151,13 +159,7 @@ struct VideoDetailView: View { // already animated to its new size by then; the subsequent // orientation rotation is its own UIKit-driven animation // and doesn't fight with SwiftUI. - DispatchQueue.main.asyncAfter(deadline: .now() + 0.30) { - if newValue { - AppOrientationController.shared.lockForFullscreen(to: fullscreenOrientation) - } else { - AppOrientationController.shared.unlockAfterFullscreen() - } - } + scheduleFullscreenOrientationUpdate(isFullscreen: newValue) } } @@ -185,23 +187,33 @@ struct VideoDetailView: View { private var shouldShowRootCommentComposer: Bool { guard !isPlayerFullscreen, selectedTab == .comments else { return false } guard !isCommentInternalOverlayActive else { return false } + guard isCommentComposerReady else { return false } if case .loaded = viewModel.state { return true } return false } + private var isCommentComposerReady: Bool { + if case .loaded = commentViewModel.state { + return true + } + return false + } + @ViewBuilder private func rootCommentComposer(layoutSize: CGSize) -> some View { if shouldShowRootCommentComposer { CommentComposerBar( text: $commentComposeText, isSending: commentViewModel.runningActionIDs.contains("post-comment"), + isReady: isCommentComposerReady, onSubmit: submitComment ) .frame(width: leftPanelWidth(for: layoutSize)) - .frame(maxWidth: .infinity, alignment: .leading) + .reportCommentComposerHeight() .horizontalPagerExclusionArea() + .frame(maxWidth: .infinity, alignment: .leading) .transition(.move(edge: .bottom).combined(with: .opacity)) .zIndex(1) } @@ -303,9 +315,10 @@ struct VideoDetailView: View { } private func usesTabletRelatedSidebar(for size: CGSize) -> Bool { + let isLandscape = currentInterfaceOrientation()?.isLandscape ?? (size.width > size.height) horizontalSizeClass == .regular && size.width >= tabletLeftMinimumWidth + tabletSidebarMinimumWidth - && size.width > size.height + && isLandscape && !isPlayerFullscreen } @@ -482,11 +495,25 @@ struct VideoDetailView: View { } private func submitComment() { + guard isCommentComposerReady else { return } if commentViewModel.postComment(text: commentComposeText) { commentComposeText = "" } } + private func scheduleFullscreenOrientationUpdate(isFullscreen: Bool) { + fullscreenOrientationTask?.cancel() + fullscreenOrientationTask = Task { @MainActor in + try? await Task.sleep(nanoseconds: 300_000_000) + guard !Task.isCancelled, isPlayerFullscreen == isFullscreen else { return } + if isFullscreen { + AppOrientationController.shared.lockForFullscreen(to: fullscreenOrientation) + } else { + AppOrientationController.shared.unlockAfterFullscreen() + } + } + } + private func tabScroll( _ tab: VideoPageTab, contentBottomPadding: CGFloat = 24, @@ -529,17 +556,27 @@ struct VideoDetailView: View { private func commentComposerContentClearance(safeAreaBottom: CGFloat) -> CGFloat { let containerBottomInset = currentWindowBottomSafeAreaInset() let isKeyboardSafeAreaActive = safeAreaBottom > containerBottomInset + 1 - return CommentComposerBar.compactHeight + (isKeyboardSafeAreaActive ? 0 : containerBottomInset) + let composerHeight = max(commentComposerHeight, CommentComposerBar.compactHeight) + return composerHeight + (isKeyboardSafeAreaActive ? 0 : containerBottomInset) } private func currentWindowBottomSafeAreaInset() -> CGFloat { - UIApplication.shared.connectedScenes - .compactMap { $0 as? UIWindowScene } - .flatMap(\.windows) + currentWindowScene()? + .windows .first { $0.isKeyWindow }? .safeAreaInsets .bottom ?? 0 } + + private func currentInterfaceOrientation() -> UIInterfaceOrientation? { + currentWindowScene()?.interfaceOrientation + } + + private func currentWindowScene() -> UIWindowScene? { + UIApplication.shared.connectedScenes + .compactMap { $0 as? UIWindowScene } + .first { $0.activationState == .foregroundActive } + } } private struct CommentComposerBar: View { @@ -547,11 +584,12 @@ private struct CommentComposerBar: View { @Binding var text: String let isSending: Bool + let isReady: Bool let onSubmit: () -> Void @FocusState private var isFieldFocused: Bool private var canSubmit: Bool { - text.trimmingCharacters(in: .whitespacesAndNewlines).count >= 2 && !isSending + isReady && text.trimmingCharacters(in: .whitespacesAndNewlines).count >= 2 && !isSending } var body: some View { @@ -605,7 +643,25 @@ private struct CommentComposerBar: View { } } +private struct CommentComposerHeightPreferenceKey: PreferenceKey { + static var defaultValue: CGFloat = 0 + static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) { + value = max(value, nextValue()) + } +} + private extension View { + func reportCommentComposerHeight() -> some View { + background( + GeometryReader { proxy in + Color.clear.preference( + key: CommentComposerHeightPreferenceKey.self, + value: proxy.size.height + ) + } + ) + } + @ViewBuilder func commentComposerFieldChrome() -> some View { if #available(iOS 26.0, *) { @@ -1448,11 +1504,11 @@ private struct ActionButtonRow: View { @State private var isShowingDownloadQuality = false private var videoURL: URL? { - URL(string: "https://hanime1.me/watch?v=\(snapshot.videoCode)") + siteURL(path: "/watch") } private var downloadURL: URL? { - URL(string: "https://hanime1.me/download?v=\(snapshot.videoCode)") + siteURL(path: "/download") } /// Real downloadable sources (a concrete resolution + a usable URL). @@ -1463,6 +1519,17 @@ private struct ActionButtonRow: View { snapshot.playbackSources.filter { $0.label.uppercased() != "AUTO" && !$0.url.isEmpty } } + private func siteURL(path: String) -> URL? { + guard var components = URLComponents(string: AppDomain.currentBaseURL) else { + return nil + } + components.path = path + components.queryItems = [ + URLQueryItem(name: "v", value: snapshot.videoCode) + ] + return components.url + } + var body: some View { HStack(spacing: 6) { LabelButton( From d0e459cd4fcba02aee6b33e9834dda374ce19e25 Mon Sep 17 00:00:00 2001 From: pgs666 <74764545+pgs666@users.noreply.github.com> Date: Tue, 2 Jun 2026 21:41:32 +0800 Subject: [PATCH 044/216] fix(video): return tablet sidebar predicate --- iosApp/VideoDetailView.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/iosApp/VideoDetailView.swift b/iosApp/VideoDetailView.swift index d7ed68d5..ef39b602 100644 --- a/iosApp/VideoDetailView.swift +++ b/iosApp/VideoDetailView.swift @@ -316,7 +316,7 @@ struct VideoDetailView: View { private func usesTabletRelatedSidebar(for size: CGSize) -> Bool { let isLandscape = currentInterfaceOrientation()?.isLandscape ?? (size.width > size.height) - horizontalSizeClass == .regular + return horizontalSizeClass == .regular && size.width >= tabletLeftMinimumWidth + tabletSidebarMinimumWidth && isLandscape && !isPlayerFullscreen From c00f95f5e810ad857f6cde222cc7f82a5b68f983 Mon Sep 17 00:00:00 2001 From: pgs666 <74764545+pgs666@users.noreply.github.com> Date: Tue, 2 Jun 2026 22:00:29 +0800 Subject: [PATCH 045/216] fix(comment): restore sort mode switching --- iosApp/CommentView.swift | 21 ++++++++++++++++++--- iosApp/CommentViewModel.swift | 12 +++++++----- 2 files changed, 25 insertions(+), 8 deletions(-) diff --git a/iosApp/CommentView.swift b/iosApp/CommentView.swift index c2655317..af0a509b 100644 --- a/iosApp/CommentView.swift +++ b/iosApp/CommentView.swift @@ -85,12 +85,26 @@ struct CommentView: View { private var header: some View { HStack(spacing: 12) { - Picker("排序", selection: $viewModel.sortMode) { + Menu { ForEach(CommentViewModel.SortMode.allCases) { mode in - Text(mode.title).tag(mode) + Button { + viewModel.changeSortMode(mode) + } label: { + if mode == viewModel.sortMode { + Label(mode.title, systemImage: "checkmark") + } else { + Text(mode.title) + } + } } + } label: { + Label(viewModel.sortMode.title, systemImage: "arrow.up.arrow.down") + .font(.subheadline.weight(.semibold)) + .foregroundStyle(Color.accentColor) + .padding(.horizontal, 12) + .padding(.vertical, 7) + .background(Color.accentColor.opacity(0.12), in: RoundedRectangle(cornerRadius: 8, style: .continuous)) } - .pickerStyle(.menu) .horizontalPagerExclusionArea() Spacer() @@ -190,6 +204,7 @@ struct CommentView: View { .frame(maxWidth: .infinity) .padding(.vertical, 12) } + .id(viewModel.sortMode.id) } } } diff --git a/iosApp/CommentViewModel.swift b/iosApp/CommentViewModel.swift index 012889e4..fa30b17c 100644 --- a/iosApp/CommentViewModel.swift +++ b/iosApp/CommentViewModel.swift @@ -36,11 +36,7 @@ final class CommentViewModel: ObservableObject { } @Published private(set) var state: State = .idle - @Published var sortMode: SortMode = .latest { - didSet { - updateSortedComments() - } - } + @Published private(set) var sortMode: SortMode = .latest @Published var actionMessage: String? @Published private(set) var runningActionIDs: Set = [] @Published private(set) var sortedComments: [CommentRow] = [] @@ -83,6 +79,12 @@ final class CommentViewModel: ObservableObject { await loadComments(generation: generation) } + func changeSortMode(_ mode: SortMode) { + guard sortMode != mode else { return } + sortMode = mode + updateSortedComments() + } + @discardableResult func postComment(text: String) -> Bool { let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) From e40aa34ff000df32a9ba0f9b0380877d94fb1e04 Mon Sep 17 00:00:00 2001 From: pgs666 <74764545+pgs666@users.noreply.github.com> Date: Tue, 2 Jun 2026 22:14:58 +0800 Subject: [PATCH 046/216] fix(comment): correct latest earliest order --- iosApp/CommentViewModel.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/iosApp/CommentViewModel.swift b/iosApp/CommentViewModel.swift index fa30b17c..16e48cf2 100644 --- a/iosApp/CommentViewModel.swift +++ b/iosApp/CommentViewModel.swift @@ -208,9 +208,9 @@ final class CommentViewModel: ObservableObject { let comments = snapshot.comments switch sortMode { case .latest: - return comments - case .earliest: return Array(comments.reversed()) + case .earliest: + return comments case .mostReplies: return comments.sorted { ($0.replyCount ?? 0) > ($1.replyCount ?? 0) } case .mostLikes: From 83424be9282dc4fa260708f7dfe1b997b03e12ec Mon Sep 17 00:00:00 2001 From: pgs666 <74764545+pgs666@users.noreply.github.com> Date: Tue, 2 Jun 2026 22:15:45 +0800 Subject: [PATCH 047/216] fix(comment): default to most liked sort --- iosApp/CommentViewModel.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/iosApp/CommentViewModel.swift b/iosApp/CommentViewModel.swift index 16e48cf2..7922c794 100644 --- a/iosApp/CommentViewModel.swift +++ b/iosApp/CommentViewModel.swift @@ -36,7 +36,7 @@ final class CommentViewModel: ObservableObject { } @Published private(set) var state: State = .idle - @Published private(set) var sortMode: SortMode = .latest + @Published private(set) var sortMode: SortMode = .mostLikes @Published var actionMessage: String? @Published private(set) var runningActionIDs: Set = [] @Published private(set) var sortedComments: [CommentRow] = [] From 288e14e73a6fdd4c53309b35bd609e7e144284f4 Mon Sep 17 00:00:00 2001 From: pgs666 <74764545+pgs666@users.noreply.github.com> Date: Tue, 2 Jun 2026 23:07:48 +0800 Subject: [PATCH 048/216] Probe scroll exclusion jank --- iosApp/CommentView.swift | 5 +++-- iosApp/VideoDetailView.swift | 4 +++- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/iosApp/CommentView.swift b/iosApp/CommentView.swift index af0a509b..214952a7 100644 --- a/iosApp/CommentView.swift +++ b/iosApp/CommentView.swift @@ -105,7 +105,9 @@ struct CommentView: View { .padding(.vertical, 7) .background(Color.accentColor.opacity(0.12), in: RoundedRectangle(cornerRadius: 8, style: .continuous)) } - .horizontalPagerExclusionArea() + // Temporarily disabled for scroll-jank CI probe: reporting global + // frames from inside the vertical ScrollView invalidates parent + // layout continuously while scrolling. Spacer() @@ -154,7 +156,6 @@ struct CommentView: View { .background(Color.accentColor, in: RoundedRectangle(cornerRadius: 8, style: .continuous)) } CloudflareVerifyButton(errorMessage: message) - .horizontalPagerExclusionArea() } .frame(maxWidth: .infinity) .padding(.vertical, 60) diff --git a/iosApp/VideoDetailView.swift b/iosApp/VideoDetailView.swift index ef39b602..f7131a2f 100644 --- a/iosApp/VideoDetailView.swift +++ b/iosApp/VideoDetailView.swift @@ -1309,7 +1309,9 @@ private struct AndroidStyleIntroduction: View { showPlaying: true, showsMetadataFooter: false ) - .horizontalPagerExclusionArea() + // Temporarily disabled for scroll-jank CI probe: this section + // lives inside the vertical ScrollView, so its global frame + // preference changes on every scroll tick. } if showsRelated && !snapshot.relatedVideos.isEmpty { From 82ca54566edc70660c1e0866aae6482c4e345b87 Mon Sep 17 00:00:00 2001 From: pgs666 <74764545+pgs666@users.noreply.github.com> Date: Wed, 3 Jun 2026 00:17:52 +0800 Subject: [PATCH 049/216] Freeze collapse updates during tab switch --- iosApp/VideoDetailView.swift | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/iosApp/VideoDetailView.swift b/iosApp/VideoDetailView.swift index f7131a2f..9c0d0576 100644 --- a/iosApp/VideoDetailView.swift +++ b/iosApp/VideoDetailView.swift @@ -30,6 +30,7 @@ struct VideoDetailView: View { /// positions instead of sharing one outer ScrollView offset. @State private var bottomScrollOffsetsByTab: [VideoPageTab: CGFloat] = [:] @State private var lastSelectedTabChangeAt = Date.distantPast + @State private var freezePlayerCollapseUpdatesUntil = Date.distantPast @State private var commentComposeText = "" @State private var isCommentInternalOverlayActive = false @State private var commentComposerHeight: CGFloat = CommentComposerBar.compactHeight @@ -467,6 +468,9 @@ struct VideoDetailView: View { guard !gestureCoordinator.isHorizontalPagingActive else { return } + guard Date() >= freezePlayerCollapseUpdatesUntil else { + return + } let activeOffset = bottomScrollOffsetsByTab[selectedTab] ?? 0 updatePlayerCollapseOffset( activeTabOffset: activeOffset, @@ -476,6 +480,7 @@ struct VideoDetailView: View { } .onValueChange(of: selectedTab) { _ in lastSelectedTabChangeAt = Date() + freezePlayerCollapseUpdatesUntil = Date().addingTimeInterval(0.55) bottomScrollOffset = min(max(bottomScrollOffset, 0), collapseDistance) } } From ed10097e09043014d7fc42bf5ae12b351c9d3301 Mon Sep 17 00:00:00 2001 From: pgs666 <74764545+pgs666@users.noreply.github.com> Date: Wed, 3 Jun 2026 00:33:27 +0800 Subject: [PATCH 050/216] Revert "Freeze collapse updates during tab switch" This reverts commit 82ca54566edc70660c1e0866aae6482c4e345b87. --- iosApp/VideoDetailView.swift | 5 ----- 1 file changed, 5 deletions(-) diff --git a/iosApp/VideoDetailView.swift b/iosApp/VideoDetailView.swift index 9c0d0576..f7131a2f 100644 --- a/iosApp/VideoDetailView.swift +++ b/iosApp/VideoDetailView.swift @@ -30,7 +30,6 @@ struct VideoDetailView: View { /// positions instead of sharing one outer ScrollView offset. @State private var bottomScrollOffsetsByTab: [VideoPageTab: CGFloat] = [:] @State private var lastSelectedTabChangeAt = Date.distantPast - @State private var freezePlayerCollapseUpdatesUntil = Date.distantPast @State private var commentComposeText = "" @State private var isCommentInternalOverlayActive = false @State private var commentComposerHeight: CGFloat = CommentComposerBar.compactHeight @@ -468,9 +467,6 @@ struct VideoDetailView: View { guard !gestureCoordinator.isHorizontalPagingActive else { return } - guard Date() >= freezePlayerCollapseUpdatesUntil else { - return - } let activeOffset = bottomScrollOffsetsByTab[selectedTab] ?? 0 updatePlayerCollapseOffset( activeTabOffset: activeOffset, @@ -480,7 +476,6 @@ struct VideoDetailView: View { } .onValueChange(of: selectedTab) { _ in lastSelectedTabChangeAt = Date() - freezePlayerCollapseUpdatesUntil = Date().addingTimeInterval(0.55) bottomScrollOffset = min(max(bottomScrollOffset, 0), collapseDistance) } } From 3928545ae2ca7689c4329c91ab2dac8123aa4e68 Mon Sep 17 00:00:00 2001 From: pgs666 <74764545+pgs666@users.noreply.github.com> Date: Wed, 3 Jun 2026 00:35:24 +0800 Subject: [PATCH 051/216] Probe tab pager animation jitter --- iosApp/VideoDetailView.swift | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/iosApp/VideoDetailView.swift b/iosApp/VideoDetailView.swift index f7131a2f..99ad1cc4 100644 --- a/iosApp/VideoDetailView.swift +++ b/iosApp/VideoDetailView.swift @@ -456,7 +456,6 @@ struct VideoDetailView: View { } } .frame(maxHeight: .infinity) - .animation(.easeInOut(duration: 0.2), value: selectedTab) } .frame(maxHeight: .infinity) .onPreferenceChange(BottomScrollOffsetPreferenceKey.self) { offsets in @@ -884,7 +883,6 @@ private struct VideoDetailTabPager: View { introduction() comments() } - .animation(.interactiveSpring(response: 0.28, dampingFraction: 0.86), value: selectedTab) .contentShape(Rectangle()) .clipped() .background( @@ -924,17 +922,13 @@ private struct VideoDetailTabPager: View { } if targetIndex != currentIndex { - withAnimation(.interactiveSpring(response: 0.28, dampingFraction: 0.86)) { - selectedTab = .page(at: targetIndex) - } + selectedTab = .page(at: targetIndex) } resetHorizontalPagingVisuals() } private func resetHorizontalPagingVisuals() { - withAnimation(.interactiveSpring(response: 0.28, dampingFraction: 0.86)) { - dragTranslation = 0 - } + dragTranslation = 0 } private func rubberBandedTranslation(_ translation: CGFloat) -> CGFloat { From 9f18a75e79f681974bc480639b6f7ecc11316a12 Mon Sep 17 00:00:00 2001 From: pgs666 <74764545+pgs666@users.noreply.github.com> Date: Wed, 3 Jun 2026 00:49:51 +0800 Subject: [PATCH 052/216] Disable tab scroll content animations --- iosApp/VideoDetailView.swift | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/iosApp/VideoDetailView.swift b/iosApp/VideoDetailView.swift index 99ad1cc4..2a7ac01a 100644 --- a/iosApp/VideoDetailView.swift +++ b/iosApp/VideoDetailView.swift @@ -456,6 +456,7 @@ struct VideoDetailView: View { } } .frame(maxHeight: .infinity) + .animation(.easeInOut(duration: 0.2), value: selectedTab) } .frame(maxHeight: .infinity) .onPreferenceChange(BottomScrollOffsetPreferenceKey.self) { offsets in @@ -544,6 +545,10 @@ struct VideoDetailView: View { } .coordinateSpace(name: tab.scrollCoordinateSpaceName) .scrollDismissesKeyboard(.interactively) + .transaction { transaction in + transaction.animation = nil + transaction.disablesAnimations = true + } .id(tab) } } @@ -883,6 +888,7 @@ private struct VideoDetailTabPager: View { introduction() comments() } + .animation(.interactiveSpring(response: 0.28, dampingFraction: 0.86), value: selectedTab) .contentShape(Rectangle()) .clipped() .background( @@ -922,13 +928,17 @@ private struct VideoDetailTabPager: View { } if targetIndex != currentIndex { - selectedTab = .page(at: targetIndex) + withAnimation(.interactiveSpring(response: 0.28, dampingFraction: 0.86)) { + selectedTab = .page(at: targetIndex) + } } resetHorizontalPagingVisuals() } private func resetHorizontalPagingVisuals() { - dragTranslation = 0 + withAnimation(.interactiveSpring(response: 0.28, dampingFraction: 0.86)) { + dragTranslation = 0 + } } private func rubberBandedTranslation(_ translation: CGFloat) -> CGFloat { From 8aec00419e88a1eec848e4880137ede43e2063be Mon Sep 17 00:00:00 2001 From: pgs666 <74764545+pgs666@users.noreply.github.com> Date: Wed, 3 Jun 2026 01:25:46 +0800 Subject: [PATCH 053/216] Limit tab animation suppression to content --- iosApp/VideoDetailView.swift | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/iosApp/VideoDetailView.swift b/iosApp/VideoDetailView.swift index 2a7ac01a..ddb9d324 100644 --- a/iosApp/VideoDetailView.swift +++ b/iosApp/VideoDetailView.swift @@ -536,6 +536,10 @@ struct VideoDetailView: View { .frame(height: 0) content() + .transaction { transaction in + transaction.animation = nil + transaction.disablesAnimations = true + } .frame(maxWidth: .infinity, minHeight: proxy.size.height, alignment: .top) .padding(.bottom, contentBottomPadding) .offset(y: collapseCompensation()) @@ -545,10 +549,6 @@ struct VideoDetailView: View { } .coordinateSpace(name: tab.scrollCoordinateSpaceName) .scrollDismissesKeyboard(.interactively) - .transaction { transaction in - transaction.animation = nil - transaction.disablesAnimations = true - } .id(tab) } } From e328c44c14d6428c76d49960243cc2660335ff23 Mon Sep 17 00:00:00 2001 From: pgs666 <74764545+pgs666@users.noreply.github.com> Date: Wed, 3 Jun 2026 03:41:58 +0800 Subject: [PATCH 054/216] Revert "Limit tab animation suppression to content" This reverts commit 8aec00419e88a1eec848e4880137ede43e2063be. --- iosApp/VideoDetailView.swift | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/iosApp/VideoDetailView.swift b/iosApp/VideoDetailView.swift index ddb9d324..2a7ac01a 100644 --- a/iosApp/VideoDetailView.swift +++ b/iosApp/VideoDetailView.swift @@ -536,10 +536,6 @@ struct VideoDetailView: View { .frame(height: 0) content() - .transaction { transaction in - transaction.animation = nil - transaction.disablesAnimations = true - } .frame(maxWidth: .infinity, minHeight: proxy.size.height, alignment: .top) .padding(.bottom, contentBottomPadding) .offset(y: collapseCompensation()) @@ -549,6 +545,10 @@ struct VideoDetailView: View { } .coordinateSpace(name: tab.scrollCoordinateSpaceName) .scrollDismissesKeyboard(.interactively) + .transaction { transaction in + transaction.animation = nil + transaction.disablesAnimations = true + } .id(tab) } } From 2045616c27ad8e51b0e39847f65786aebef953b7 Mon Sep 17 00:00:00 2001 From: pgs666 <74764545+pgs666@users.noreply.github.com> Date: Wed, 3 Jun 2026 03:41:58 +0800 Subject: [PATCH 055/216] Revert "Disable tab scroll content animations" This reverts commit 9f18a75e79f681974bc480639b6f7ecc11316a12. --- iosApp/VideoDetailView.swift | 14 ++------------ 1 file changed, 2 insertions(+), 12 deletions(-) diff --git a/iosApp/VideoDetailView.swift b/iosApp/VideoDetailView.swift index 2a7ac01a..99ad1cc4 100644 --- a/iosApp/VideoDetailView.swift +++ b/iosApp/VideoDetailView.swift @@ -456,7 +456,6 @@ struct VideoDetailView: View { } } .frame(maxHeight: .infinity) - .animation(.easeInOut(duration: 0.2), value: selectedTab) } .frame(maxHeight: .infinity) .onPreferenceChange(BottomScrollOffsetPreferenceKey.self) { offsets in @@ -545,10 +544,6 @@ struct VideoDetailView: View { } .coordinateSpace(name: tab.scrollCoordinateSpaceName) .scrollDismissesKeyboard(.interactively) - .transaction { transaction in - transaction.animation = nil - transaction.disablesAnimations = true - } .id(tab) } } @@ -888,7 +883,6 @@ private struct VideoDetailTabPager: View { introduction() comments() } - .animation(.interactiveSpring(response: 0.28, dampingFraction: 0.86), value: selectedTab) .contentShape(Rectangle()) .clipped() .background( @@ -928,17 +922,13 @@ private struct VideoDetailTabPager: View { } if targetIndex != currentIndex { - withAnimation(.interactiveSpring(response: 0.28, dampingFraction: 0.86)) { - selectedTab = .page(at: targetIndex) - } + selectedTab = .page(at: targetIndex) } resetHorizontalPagingVisuals() } private func resetHorizontalPagingVisuals() { - withAnimation(.interactiveSpring(response: 0.28, dampingFraction: 0.86)) { - dragTranslation = 0 - } + dragTranslation = 0 } private func rubberBandedTranslation(_ translation: CGFloat) -> CGFloat { From ac86ca0efe2b0e4babf927c8aa446267ff9deefa Mon Sep 17 00:00:00 2001 From: pgs666 <74764545+pgs666@users.noreply.github.com> Date: Wed, 3 Jun 2026 03:41:58 +0800 Subject: [PATCH 056/216] Revert "Probe tab pager animation jitter" This reverts commit 3928545ae2ca7689c4329c91ab2dac8123aa4e68. --- iosApp/VideoDetailView.swift | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/iosApp/VideoDetailView.swift b/iosApp/VideoDetailView.swift index 99ad1cc4..f7131a2f 100644 --- a/iosApp/VideoDetailView.swift +++ b/iosApp/VideoDetailView.swift @@ -456,6 +456,7 @@ struct VideoDetailView: View { } } .frame(maxHeight: .infinity) + .animation(.easeInOut(duration: 0.2), value: selectedTab) } .frame(maxHeight: .infinity) .onPreferenceChange(BottomScrollOffsetPreferenceKey.self) { offsets in @@ -883,6 +884,7 @@ private struct VideoDetailTabPager: View { introduction() comments() } + .animation(.interactiveSpring(response: 0.28, dampingFraction: 0.86), value: selectedTab) .contentShape(Rectangle()) .clipped() .background( @@ -922,13 +924,17 @@ private struct VideoDetailTabPager: View { } if targetIndex != currentIndex { - selectedTab = .page(at: targetIndex) + withAnimation(.interactiveSpring(response: 0.28, dampingFraction: 0.86)) { + selectedTab = .page(at: targetIndex) + } } resetHorizontalPagingVisuals() } private func resetHorizontalPagingVisuals() { - dragTranslation = 0 + withAnimation(.interactiveSpring(response: 0.28, dampingFraction: 0.86)) { + dragTranslation = 0 + } } private func rubberBandedTranslation(_ translation: CGFloat) -> CGFloat { From a61c0ce7a6729ffaee3437b01749b9183ccf0eab Mon Sep 17 00:00:00 2001 From: pgs666 <74764545+pgs666@users.noreply.github.com> Date: Wed, 3 Jun 2026 03:47:19 +0800 Subject: [PATCH 057/216] Use UIKit pager for video detail tabs --- iosApp/VideoDetailView.swift | 447 ++++++++++++----------------------- 1 file changed, 153 insertions(+), 294 deletions(-) diff --git a/iosApp/VideoDetailView.swift b/iosApp/VideoDetailView.swift index f7131a2f..21e05a8e 100644 --- a/iosApp/VideoDetailView.swift +++ b/iosApp/VideoDetailView.swift @@ -14,7 +14,6 @@ struct VideoDetailView: View { @State private var isPlayerFullscreen = false @State private var isPlayerCollapsed = false @State private var horizontalPagerExclusionFrames: [CGRect] = [] - @State private var gestureCoordinator = VideoDetailGestureCoordinator() /// True iff the player is currently playing (not paused / buffering). /// Driven from KSPlayerView via the @Binding below. Used to lock the /// player at full 16:9 height while playing — only paused state lets the @@ -415,7 +414,6 @@ struct VideoDetailView: View { VideoDetailTabPager( selectedTab: $selectedTab, - gestureCoordinator: gestureCoordinator, excludedDragStartFrames: horizontalPagerExclusionFrames ) { tabScroll(.introduction) { @@ -456,7 +454,6 @@ struct VideoDetailView: View { } } .frame(maxHeight: .infinity) - .animation(.easeInOut(duration: 0.2), value: selectedTab) } .frame(maxHeight: .infinity) .onPreferenceChange(BottomScrollOffsetPreferenceKey.self) { offsets in @@ -464,9 +461,6 @@ struct VideoDetailView: View { for (tab, offset) in offsets { bottomScrollOffsetsByTab[tab] = offset } - guard !gestureCoordinator.isHorizontalPagingActive else { - return - } let activeOffset = bottomScrollOffsetsByTab[selectedTab] ?? 0 updatePlayerCollapseOffset( activeTabOffset: activeOffset, @@ -524,7 +518,7 @@ struct VideoDetailView: View { GeometryReader { proxy in let collapseScrollSpacerHeight = collapseDistance() + 1 ScrollView { - BounceDisabledScrollViewConfigurator(gestureCoordinator: gestureCoordinator) + BounceDisabledScrollViewConfigurator() .frame(width: 0, height: 0) GeometryReader { proxy in @@ -701,10 +695,8 @@ private extension View { } private struct BounceDisabledScrollViewConfigurator: UIViewRepresentable { - let gestureCoordinator: VideoDetailGestureCoordinator - func makeUIView(context: Context) -> ConfiguringView { - let view = ConfiguringView(gestureCoordinator: gestureCoordinator) + let view = ConfiguringView() view.isUserInteractionEnabled = false return view } @@ -714,12 +706,10 @@ private struct BounceDisabledScrollViewConfigurator: UIViewRepresentable { } final class ConfiguringView: UIView { - private let gestureCoordinator: VideoDetailGestureCoordinator private var isConfigurationScheduled = false private var remainingRetries = 12 - init(gestureCoordinator: VideoDetailGestureCoordinator) { - self.gestureCoordinator = gestureCoordinator + init() { super.init(frame: .zero) } @@ -770,7 +760,6 @@ private struct BounceDisabledScrollViewConfigurator: UIViewRepresentable { scrollView.alwaysBounceVertical = false scrollView.alwaysBounceHorizontal = false scrollView.isDirectionalLockEnabled = true - gestureCoordinator.registerVerticalScrollView(scrollView) return true } current = view.superview @@ -852,295 +841,207 @@ private enum VideoPageTab: String, CaseIterable, Identifiable { } } -private struct VideoDetailTabPager: View { +private struct VideoDetailTabPager: UIViewControllerRepresentable { @Binding var selectedTab: VideoPageTab - let gestureCoordinator: VideoDetailGestureCoordinator let excludedDragStartFrames: [CGRect] let introduction: () -> Introduction let comments: () -> Comments - @State private var dragTranslation: CGFloat = 0 - init( selectedTab: Binding, - gestureCoordinator: VideoDetailGestureCoordinator, excludedDragStartFrames: [CGRect], @ViewBuilder introduction: @escaping () -> Introduction, @ViewBuilder comments: @escaping () -> Comments ) { _selectedTab = selectedTab - self.gestureCoordinator = gestureCoordinator self.excludedDragStartFrames = excludedDragStartFrames self.introduction = introduction self.comments = comments } - var body: some View { - GeometryReader { _ in - VideoDetailPagerLayout( - selectedIndex: selectedTab.pageIndex, - dragTranslation: dragTranslation - ) { - introduction() - comments() - } - .animation(.interactiveSpring(response: 0.28, dampingFraction: 0.86), value: selectedTab) - .contentShape(Rectangle()) - .clipped() - .background( - HorizontalPagingGestureConfigurator( - gestureCoordinator: gestureCoordinator, - excludedDragStartFrames: excludedDragStartFrames, - onChanged: { translation in - var transaction = Transaction() - transaction.disablesAnimations = true - withTransaction(transaction) { - dragTranslation = rubberBandedTranslation(translation) - } - }, - onEnded: { translation, velocity in - finishHorizontalPaging(translation: translation, velocity: velocity) - }, - onCancelled: { - resetHorizontalPagingVisuals() - } - ) - ) - } - } - - private func finishHorizontalPaging(translation: CGFloat, velocity: CGFloat) { - let threshold: CGFloat = 72 - let projected = translation + velocity * 0.18 - let currentIndex = selectedTab.pageIndex - let targetIndex: Int - - if projected < -threshold { - targetIndex = min(currentIndex + 1, VideoPageTab.allCases.count - 1) - } else if projected > threshold { - targetIndex = max(currentIndex - 1, 0) - } else { - targetIndex = currentIndex - } - - if targetIndex != currentIndex { - withAnimation(.interactiveSpring(response: 0.28, dampingFraction: 0.86)) { - selectedTab = .page(at: targetIndex) - } - } - resetHorizontalPagingVisuals() - } - - private func resetHorizontalPagingVisuals() { - withAnimation(.interactiveSpring(response: 0.28, dampingFraction: 0.86)) { - dragTranslation = 0 - } - } - - private func rubberBandedTranslation(_ translation: CGFloat) -> CGFloat { - let index = selectedTab.pageIndex - let isPullingBeforeFirst = index == 0 && translation > 0 - let isPullingAfterLast = index == VideoPageTab.allCases.count - 1 && translation < 0 - if isPullingBeforeFirst || isPullingAfterLast { - return translation * 0.28 - } - return translation - } -} - -private struct HorizontalPagingGestureConfigurator: UIViewRepresentable { - let gestureCoordinator: VideoDetailGestureCoordinator - let excludedDragStartFrames: [CGRect] - let onChanged: (CGFloat) -> Void - let onEnded: (CGFloat, CGFloat) -> Void - let onCancelled: () -> Void - func makeCoordinator() -> Coordinator { - Coordinator( - onChanged: onChanged, - onEnded: onEnded, - onCancelled: onCancelled - ) + Coordinator(selectedTab: $selectedTab) } - func makeUIView(context: Context) -> UIView { - let view = UIView() - view.isUserInteractionEnabled = false - return view + func makeUIViewController(context: Context) -> PagingViewController { + PagingViewController(coordinator: context.coordinator) } - func updateUIView(_ uiView: UIView, context: Context) { - context.coordinator.update( - onChanged: onChanged, - onEnded: onEnded, - onCancelled: onCancelled - ) - gestureCoordinator.configureHorizontalPaging( - excludedDragStartFrames: excludedDragStartFrames, - actions: context.coordinator + func updateUIViewController(_ uiViewController: PagingViewController, context: Context) { + context.coordinator.selectedTab = $selectedTab + context.coordinator.excludedDragStartFrames = excludedDragStartFrames + uiViewController.updatePages( + introduction: AnyView(introduction()), + comments: AnyView(comments()), + selectedIndex: selectedTab.pageIndex, + animated: context.coordinator.shouldAnimateProgrammaticSelection(to: selectedTab.pageIndex) ) } - final class Coordinator: VideoDetailHorizontalPagingActions { - private var onChanged: (CGFloat) -> Void - private var onEnded: (CGFloat, CGFloat) -> Void - private var onCancelled: () -> Void + final class Coordinator: NSObject, UIScrollViewDelegate, UIGestureRecognizerDelegate { + var selectedTab: Binding + var excludedDragStartFrames: [CGRect] = [] + private var lastProgrammaticIndex: Int? - init( - onChanged: @escaping (CGFloat) -> Void, - onEnded: @escaping (CGFloat, CGFloat) -> Void, - onCancelled: @escaping () -> Void - ) { - self.onChanged = onChanged - self.onEnded = onEnded - self.onCancelled = onCancelled + init(selectedTab: Binding) { + self.selectedTab = selectedTab } - func update( - onChanged: @escaping (CGFloat) -> Void, - onEnded: @escaping (CGFloat, CGFloat) -> Void, - onCancelled: @escaping () -> Void - ) { - self.onChanged = onChanged - self.onEnded = onEnded - self.onCancelled = onCancelled + func shouldAnimateProgrammaticSelection(to index: Int) -> Bool { + defer { lastProgrammaticIndex = index } + guard let lastProgrammaticIndex else { return false } + return lastProgrammaticIndex != index } - func horizontalPagingChanged(_ translation: CGFloat) { - onChanged(translation) + func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) { + updateSelectedTab(from: scrollView) } - func horizontalPagingEnded(translation: CGFloat, velocity: CGFloat) { - onEnded(translation, velocity) + func scrollViewDidEndScrollingAnimation(_ scrollView: UIScrollView) { + updateSelectedTab(from: scrollView) } - func horizontalPagingCancelled() { - onCancelled() + func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) { + if !decelerate { + updateSelectedTab(from: scrollView) + } } - } -} - -private protocol VideoDetailHorizontalPagingActions: AnyObject { - func horizontalPagingChanged(_ translation: CGFloat) - func horizontalPagingEnded(translation: CGFloat, velocity: CGFloat) - func horizontalPagingCancelled() -} - -private final class VideoDetailGestureCoordinator: NSObject, UIGestureRecognizerDelegate { - private(set) var isHorizontalPagingActive = false - private var verticalScrollViews: [WeakScrollView] = [] - private var excludedDragStartFrames: [CGRect] = [] - private weak var horizontalPagingActions: VideoDetailHorizontalPagingActions? - func configureHorizontalPaging( - excludedDragStartFrames: [CGRect], - actions: VideoDetailHorizontalPagingActions - ) { - self.excludedDragStartFrames = excludedDragStartFrames - self.horizontalPagingActions = actions + func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool { + guard let panGestureRecognizer = gestureRecognizer as? UIPanGestureRecognizer, + let view = gestureRecognizer.view else { + return true + } + let startLocation = panGestureRecognizer.location(in: view) + guard startLocation.x > 24 else { return false } + let globalStartLocation = view.convert(startLocation, to: nil) + guard !excludedDragStartFrames.contains(where: { $0.contains(globalStartLocation) }) else { + return false + } - verticalScrollViews.removeAll { $0.scrollView == nil } - for weakScrollView in verticalScrollViews { - installHorizontalPagingPanIfNeeded(on: weakScrollView) + let translation = panGestureRecognizer.translation(in: view) + let velocity = panGestureRecognizer.velocity(in: view) + let horizontal = max(abs(translation.x), abs(velocity.x) * 0.05) + let vertical = max(abs(translation.y), abs(velocity.y) * 0.05) + return horizontal > 8 && horizontal > vertical * 1.18 } - } - func setHorizontalPagingActive(_ isActive: Bool) { - guard isHorizontalPagingActive != isActive else { return } - isHorizontalPagingActive = isActive - updateVerticalScrollAvailability() - } - - func registerVerticalScrollView(_ scrollView: UIScrollView) { - verticalScrollViews.removeAll { $0.scrollView == nil } - if !verticalScrollViews.contains(where: { $0.scrollView === scrollView }) { - verticalScrollViews.append(WeakScrollView(scrollView)) - } - if let weakScrollView = verticalScrollViews.first(where: { $0.scrollView === scrollView }) { - installHorizontalPagingPanIfNeeded(on: weakScrollView) + private func updateSelectedTab(from scrollView: UIScrollView) { + let width = scrollView.bounds.width + guard width > 0 else { return } + let index = Int(round(scrollView.contentOffset.x / width)) + let tab = VideoPageTab.page(at: index) + if selectedTab.wrappedValue != tab { + selectedTab.wrappedValue = tab + } } - scrollView.isScrollEnabled = !isHorizontalPagingActive } - private func installHorizontalPagingPanIfNeeded(on weakScrollView: WeakScrollView) { - guard let scrollView = weakScrollView.scrollView else { return } - guard weakScrollView.horizontalPagingPanGestureRecognizer == nil else { return } - - let panGestureRecognizer = UIPanGestureRecognizer( - target: self, - action: #selector(handleHorizontalPagingPan(_:)) - ) - panGestureRecognizer.delegate = self - panGestureRecognizer.cancelsTouchesInView = true - panGestureRecognizer.delaysTouchesBegan = false - panGestureRecognizer.delaysTouchesEnded = false - - scrollView.addGestureRecognizer(panGestureRecognizer) - scrollView.panGestureRecognizer.require(toFail: panGestureRecognizer) - weakScrollView.horizontalPagingPanGestureRecognizer = panGestureRecognizer - } + final class PagingViewController: UIViewController { + private let coordinator: Coordinator + private let scrollView = UIScrollView() + private let contentView = UIView() + private let introductionHost = UIHostingController(rootView: AnyView(EmptyView())) + private let commentsHost = UIHostingController(rootView: AnyView(EmptyView())) + private var selectedIndex = 0 + private var pendingSelectedIndex: Int? - private func updateVerticalScrollAvailability() { - verticalScrollViews.removeAll { $0.scrollView == nil } - for weakScrollView in verticalScrollViews { - weakScrollView.scrollView?.isScrollEnabled = !isHorizontalPagingActive + init(coordinator: Coordinator) { + self.coordinator = coordinator + super.init(nibName: nil, bundle: nil) } - } - func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool { - guard let panGestureRecognizer = gestureRecognizer as? UIPanGestureRecognizer, - let view = gestureRecognizer.view else { - return false + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") } - let startLocation = panGestureRecognizer.location(in: view) - guard startLocation.x > 24 else { return false } - let globalStartLocation = view.convert(startLocation, to: nil) - guard !excludedDragStartFrames.contains(where: { $0.contains(globalStartLocation) }) else { - return false + + override func viewDidLoad() { + super.viewDidLoad() + view.backgroundColor = .clear + + scrollView.translatesAutoresizingMaskIntoConstraints = false + scrollView.backgroundColor = .clear + scrollView.isPagingEnabled = true + scrollView.bounces = false + scrollView.alwaysBounceHorizontal = false + scrollView.alwaysBounceVertical = false + scrollView.showsHorizontalScrollIndicator = false + scrollView.showsVerticalScrollIndicator = false + scrollView.isDirectionalLockEnabled = true + scrollView.contentInsetAdjustmentBehavior = .never + scrollView.delegate = coordinator + scrollView.panGestureRecognizer.delegate = coordinator + view.addSubview(scrollView) + + contentView.translatesAutoresizingMaskIntoConstraints = false + contentView.backgroundColor = .clear + scrollView.addSubview(contentView) + + addPage(introductionHost) + addPage(commentsHost) + + NSLayoutConstraint.activate([ + scrollView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + scrollView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + scrollView.topAnchor.constraint(equalTo: view.topAnchor), + scrollView.bottomAnchor.constraint(equalTo: view.bottomAnchor), + + contentView.leadingAnchor.constraint(equalTo: scrollView.contentLayoutGuide.leadingAnchor), + contentView.trailingAnchor.constraint(equalTo: scrollView.contentLayoutGuide.trailingAnchor), + contentView.topAnchor.constraint(equalTo: scrollView.contentLayoutGuide.topAnchor), + contentView.bottomAnchor.constraint(equalTo: scrollView.contentLayoutGuide.bottomAnchor), + contentView.heightAnchor.constraint(equalTo: scrollView.frameLayoutGuide.heightAnchor), + + introductionHost.view.leadingAnchor.constraint(equalTo: contentView.leadingAnchor), + introductionHost.view.topAnchor.constraint(equalTo: contentView.topAnchor), + introductionHost.view.bottomAnchor.constraint(equalTo: contentView.bottomAnchor), + introductionHost.view.widthAnchor.constraint(equalTo: scrollView.frameLayoutGuide.widthAnchor), + introductionHost.view.heightAnchor.constraint(equalTo: scrollView.frameLayoutGuide.heightAnchor), + + commentsHost.view.leadingAnchor.constraint(equalTo: introductionHost.view.trailingAnchor), + commentsHost.view.trailingAnchor.constraint(equalTo: contentView.trailingAnchor), + commentsHost.view.topAnchor.constraint(equalTo: contentView.topAnchor), + commentsHost.view.bottomAnchor.constraint(equalTo: contentView.bottomAnchor), + commentsHost.view.widthAnchor.constraint(equalTo: scrollView.frameLayoutGuide.widthAnchor), + commentsHost.view.heightAnchor.constraint(equalTo: scrollView.frameLayoutGuide.heightAnchor) + ]) + } + + override func viewDidLayoutSubviews() { + super.viewDidLayoutSubviews() + if let pendingSelectedIndex { + self.pendingSelectedIndex = nil + setSelectedIndex(pendingSelectedIndex, animated: false) + } else { + setSelectedIndex(selectedIndex, animated: false) + } } - let translation = panGestureRecognizer.translation(in: view) - let velocity = panGestureRecognizer.velocity(in: view) - let horizontal = max(abs(translation.x), abs(velocity.x) * 0.05) - let vertical = max(abs(translation.y), abs(velocity.y) * 0.05) - return horizontal > 12 && horizontal > vertical * 1.35 - } - - func gestureRecognizer( - _ gestureRecognizer: UIGestureRecognizer, - shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer - ) -> Bool { - false - } - - @objc private func handleHorizontalPagingPan(_ recognizer: UIPanGestureRecognizer) { - guard let view = recognizer.view else { return } - switch recognizer.state { - case .began: - setHorizontalPagingActive(true) - horizontalPagingActions?.horizontalPagingChanged(recognizer.translation(in: view).x) - case .changed: - horizontalPagingActions?.horizontalPagingChanged(recognizer.translation(in: view).x) - case .ended: - horizontalPagingActions?.horizontalPagingEnded( - translation: recognizer.translation(in: view).x, - velocity: recognizer.velocity(in: view).x - ) - setHorizontalPagingActive(false) - case .cancelled, .failed: - horizontalPagingActions?.horizontalPagingCancelled() - setHorizontalPagingActive(false) - default: - break + func updatePages(introduction: AnyView, comments: AnyView, selectedIndex: Int, animated: Bool) { + introductionHost.rootView = introduction + commentsHost.rootView = comments + setSelectedIndex(selectedIndex, animated: animated) } - } - private final class WeakScrollView { - weak var scrollView: UIScrollView? - weak var horizontalPagingPanGestureRecognizer: UIPanGestureRecognizer? + private func addPage(_ host: UIHostingController) { + addChild(host) + host.view.translatesAutoresizingMaskIntoConstraints = false + host.view.backgroundColor = .clear + contentView.addSubview(host.view) + host.didMove(toParent: self) + } - init(_ scrollView: UIScrollView) { - self.scrollView = scrollView + private func setSelectedIndex(_ index: Int, animated: Bool) { + selectedIndex = min(max(index, 0), VideoPageTab.allCases.count - 1) + let width = scrollView.bounds.width + guard width > 0 else { + pendingSelectedIndex = selectedIndex + return + } + let targetOffset = CGPoint(x: CGFloat(selectedIndex) * width, y: 0) + guard abs(scrollView.contentOffset.x - targetOffset.x) > 0.5 else { return } + scrollView.setContentOffset(targetOffset, animated: animated) } } } @@ -1200,48 +1101,6 @@ struct TapOnlyControl: View { } } -/// Horizontally pages two tab bodies while reporting only ONE page's width to -/// the parent vertical ScrollView. A plain HStack exposes its full 2× width -/// during sizeThatFits, which can make nested lazy grids compute enormous -/// minor geometry and eventually abort on allocation failure. -private struct VideoDetailPagerLayout: Layout { - let selectedIndex: Int - let dragTranslation: CGFloat - - func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize { - let width = resolvedWidth(proposal: proposal, subviews: subviews) - let pageProposal = ProposedViewSize(width: width, height: proposal.height) - let height: CGFloat - if let proposedHeight = proposal.height, proposedHeight.isFinite, proposedHeight > 0 { - height = proposedHeight - } else { - height = subviews.map { $0.sizeThatFits(pageProposal).height }.max() ?? 0 - } - return CGSize(width: width, height: height) - } - - func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) { - let width = bounds.width - let pageProposal = ProposedViewSize(width: width, height: bounds.height) - let originX = bounds.minX - CGFloat(selectedIndex) * width + dragTranslation - - for (index, subview) in subviews.enumerated() { - subview.place( - at: CGPoint(x: originX + CGFloat(index) * width, y: bounds.minY), - anchor: .topLeading, - proposal: pageProposal - ) - } - } - - private func resolvedWidth(proposal: ProposedViewSize, subviews: Subviews) -> CGFloat { - if let width = proposal.width, width.isFinite, width > 0 { - return width - } - return subviews.map { $0.sizeThatFits(.unspecified).width }.max() ?? 0 - } -} - private struct AndroidStyleIntroduction: View { let snapshot: VideoDetailScreenSnapshot let videoFeature: VideoFeature From 9b04b7f49ef13024959fe3e938bb36ae2968e47a Mon Sep 17 00:00:00 2001 From: pgs666 <74764545+pgs666@users.noreply.github.com> Date: Wed, 3 Jun 2026 04:01:40 +0800 Subject: [PATCH 058/216] Use UIKit vertical tab scrollers --- iosApp/VideoDetailView.swift | 406 ++++++++++++++++++++++++----------- 1 file changed, 280 insertions(+), 126 deletions(-) diff --git a/iosApp/VideoDetailView.swift b/iosApp/VideoDetailView.swift index 21e05a8e..1cfba141 100644 --- a/iosApp/VideoDetailView.swift +++ b/iosApp/VideoDetailView.swift @@ -401,6 +401,8 @@ struct VideoDetailView: View { collapseCompensation: CGFloat, composerContentClearance: CGFloat ) -> some View { + let contentRevision = tabContentRevision(snapshot: snapshot, showsRelated: showsRelated) + return VStack(spacing: 0) { Picker("Content", selection: $selectedTab) { ForEach(VideoPageTab.allCases) { tab in @@ -414,9 +416,13 @@ struct VideoDetailView: View { VideoDetailTabPager( selectedTab: $selectedTab, - excludedDragStartFrames: horizontalPagerExclusionFrames + excludedDragStartFrames: horizontalPagerExclusionFrames, + contentRevision: contentRevision ) { - tabScroll(.introduction) { + tabScroll( + .introduction, + contentUpdateRevision: contentRevision.introduction + ) { AndroidStyleIntroduction( snapshot: snapshot, videoFeature: videoFeature, @@ -438,7 +444,8 @@ struct VideoDetailView: View { } comments: { tabScroll( .comments, - contentBottomPadding: composerContentClearance + contentBottomPadding: composerContentClearance, + contentUpdateRevision: contentRevision.comments ) { CommentView( viewModel: commentViewModel, @@ -456,18 +463,6 @@ struct VideoDetailView: View { .frame(maxHeight: .infinity) } .frame(maxHeight: .infinity) - .onPreferenceChange(BottomScrollOffsetPreferenceKey.self) { offsets in - let previousActiveOffset = bottomScrollOffsetsByTab[selectedTab] - for (tab, offset) in offsets { - bottomScrollOffsetsByTab[tab] = offset - } - let activeOffset = bottomScrollOffsetsByTab[selectedTab] ?? 0 - updatePlayerCollapseOffset( - activeTabOffset: activeOffset, - previousActiveTabOffset: previousActiveOffset, - collapseDistance: collapseDistance - ) - } .onValueChange(of: selectedTab) { _ in lastSelectedTabChangeAt = Date() bottomScrollOffset = min(max(bottomScrollOffset, 0), collapseDistance) @@ -511,36 +506,30 @@ struct VideoDetailView: View { private func tabScroll( _ tab: VideoPageTab, contentBottomPadding: CGFloat = 24, + contentUpdateRevision: Int, @ViewBuilder content: @escaping () -> Content, collapseCompensation: @escaping () -> CGFloat = { 0 }, collapseDistance: @escaping () -> CGFloat = { 0 } ) -> some View { - GeometryReader { proxy in - let collapseScrollSpacerHeight = collapseDistance() + 1 - ScrollView { - BounceDisabledScrollViewConfigurator() - .frame(width: 0, height: 0) - - GeometryReader { proxy in - Color.clear.preference( - key: BottomScrollOffsetPreferenceKey.self, - value: [tab: -proxy.frame(in: .named(tab.scrollCoordinateSpaceName)).minY] - ) - } - .frame(height: 0) - - content() - .frame(maxWidth: .infinity, minHeight: proxy.size.height, alignment: .top) - .padding(.bottom, contentBottomPadding) - .offset(y: collapseCompensation()) - - Color.clear - .frame(height: collapseScrollSpacerHeight) - } - .coordinateSpace(name: tab.scrollCoordinateSpaceName) - .scrollDismissesKeyboard(.interactively) - .id(tab) - } + VideoDetailVerticalScrollPage( + tab: tab, + contentBottomPadding: contentBottomPadding, + collapseCompensation: collapseCompensation(), + collapseDistance: collapseDistance(), + contentUpdateRevision: contentUpdateRevision, + onOffsetChange: { tab, offset in + let previousActiveOffset = bottomScrollOffsetsByTab[selectedTab] + bottomScrollOffsetsByTab[tab] = offset + guard tab == selectedTab else { return } + updatePlayerCollapseOffset( + activeTabOffset: offset, + previousActiveTabOffset: previousActiveOffset, + collapseDistance: collapseDistance() + ) + }, + content: content + ) + .id(tab) } private func tabCollapseCompensation(for tab: VideoPageTab, collapseCompensation: CGFloat) -> CGFloat { @@ -571,6 +560,24 @@ struct VideoDetailView: View { .compactMap { $0 as? UIWindowScene } .first { $0.activationState == .foregroundActive } } + + private func tabContentRevision( + snapshot: VideoDetailScreenSnapshot, + showsRelated: Bool + ) -> VideoDetailTabContentRevision { + var introductionHasher = Hasher() + introductionHasher.combine(showsRelated) + introductionHasher.combine(viewModel.isActionRunning("artistSubscription")) + snapshot.hash(into: &introductionHasher) + + var commentsHasher = Hasher() + commentsHasher.combine(ObjectIdentifier(commentViewModel)) + + return VideoDetailTabContentRevision( + introduction: introductionHasher.finalize(), + comments: commentsHasher.finalize() + ) + } } private struct CommentComposerBar: View { @@ -694,90 +701,6 @@ private extension View { } } -private struct BounceDisabledScrollViewConfigurator: UIViewRepresentable { - func makeUIView(context: Context) -> ConfiguringView { - let view = ConfiguringView() - view.isUserInteractionEnabled = false - return view - } - - func updateUIView(_ uiView: ConfiguringView, context: Context) { - uiView.scheduleConfiguration() - } - - final class ConfiguringView: UIView { - private var isConfigurationScheduled = false - private var remainingRetries = 12 - - init() { - super.init(frame: .zero) - } - - @available(*, unavailable) - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - override func didMoveToSuperview() { - super.didMoveToSuperview() - remainingRetries = 12 - scheduleConfiguration() - } - - override func didMoveToWindow() { - super.didMoveToWindow() - remainingRetries = 12 - scheduleConfiguration() - } - - override func layoutSubviews() { - super.layoutSubviews() - scheduleConfiguration() - } - - func scheduleConfiguration() { - guard !isConfigurationScheduled else { return } - isConfigurationScheduled = true - DispatchQueue.main.async { [weak self] in - guard let self else { return } - self.isConfigurationScheduled = false - self.configureNearestScrollView() - if self.window != nil, self.remainingRetries > 0 { - self.remainingRetries -= 1 - DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { [weak self] in - self?.scheduleConfiguration() - } - } - } - } - - @discardableResult - private func configureNearestScrollView() -> Bool { - var current = superview - while let view = current { - if let scrollView = view as? UIScrollView { - scrollView.bounces = false - scrollView.alwaysBounceVertical = false - scrollView.alwaysBounceHorizontal = false - scrollView.isDirectionalLockEnabled = true - return true - } - current = view.superview - } - return false - } - } -} - -/// Reports each tab-owned ScrollView's vertical offset from its top so the -/// player area can shrink (B-station-style) based only on the active tab. -private struct BottomScrollOffsetPreferenceKey: PreferenceKey { - static var defaultValue: [VideoPageTab: CGFloat] = [:] - static func reduce(value: inout [VideoPageTab: CGFloat], nextValue: () -> [VideoPageTab: CGFloat]) { - value.merge(nextValue()) { _, new in new } - } -} - private enum VideoPlayerCollapseModel { static func nextCollapseOffset( currentCollapseOffset: CGFloat, @@ -841,20 +764,201 @@ private enum VideoPageTab: String, CaseIterable, Identifiable { } } +private struct VideoDetailTabContentRevision: Equatable { + let introduction: Int + let comments: Int +} + +private struct VideoDetailVerticalScrollPage: UIViewControllerRepresentable { + let tab: VideoPageTab + let contentBottomPadding: CGFloat + let collapseCompensation: CGFloat + let collapseDistance: CGFloat + let contentUpdateRevision: Int + let onOffsetChange: (VideoPageTab, CGFloat) -> Void + let content: () -> Content + + init( + tab: VideoPageTab, + contentBottomPadding: CGFloat, + collapseCompensation: CGFloat, + collapseDistance: CGFloat, + contentUpdateRevision: Int, + onOffsetChange: @escaping (VideoPageTab, CGFloat) -> Void, + @ViewBuilder content: @escaping () -> Content + ) { + self.tab = tab + self.contentBottomPadding = contentBottomPadding + self.collapseCompensation = collapseCompensation + self.collapseDistance = collapseDistance + self.contentUpdateRevision = contentUpdateRevision + self.onOffsetChange = onOffsetChange + self.content = content + } + + func makeCoordinator() -> Coordinator { + Coordinator(tab: tab, onOffsetChange: onOffsetChange) + } + + func makeUIViewController(context: Context) -> ScrollPageViewController { + ScrollPageViewController(coordinator: context.coordinator) + } + + func updateUIViewController(_ uiViewController: ScrollPageViewController, context: Context) { + context.coordinator.tab = tab + context.coordinator.onOffsetChange = onOffsetChange + uiViewController.update( + content: AnyView(content()), + contentBottomPadding: contentBottomPadding, + collapseCompensation: collapseCompensation, + collapseDistance: collapseDistance, + contentUpdateRevision: contentUpdateRevision + ) + } + + final class Coordinator: NSObject, UIScrollViewDelegate { + var tab: VideoPageTab + var onOffsetChange: (VideoPageTab, CGFloat) -> Void + + init(tab: VideoPageTab, onOffsetChange: @escaping (VideoPageTab, CGFloat) -> Void) { + self.tab = tab + self.onOffsetChange = onOffsetChange + } + + func scrollViewDidScroll(_ scrollView: UIScrollView) { + onOffsetChange(tab, max(0, scrollView.contentOffset.y)) + } + } + + final class ScrollPageViewController: UIViewController { + private let coordinator: Coordinator + private let scrollView = UIScrollView() + private let contentView = UIView() + private let bottomPaddingView = UIView() + private let collapseSpacerView = UIView() + private let host = UIHostingController(rootView: AnyView(EmptyView())) + private var hostTopConstraint: NSLayoutConstraint! + private var hostMinimumHeightConstraint: NSLayoutConstraint! + private var bottomPaddingHeightConstraint: NSLayoutConstraint! + private var collapseSpacerHeightConstraint: NSLayoutConstraint! + private var contentUpdateRevision: Int? + + init(coordinator: Coordinator) { + self.coordinator = coordinator + super.init(nibName: nil, bundle: nil) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + view.backgroundColor = .clear + + scrollView.translatesAutoresizingMaskIntoConstraints = false + scrollView.backgroundColor = .clear + scrollView.bounces = false + scrollView.alwaysBounceVertical = false + scrollView.alwaysBounceHorizontal = false + scrollView.showsHorizontalScrollIndicator = false + scrollView.showsVerticalScrollIndicator = false + scrollView.isDirectionalLockEnabled = true + scrollView.contentInsetAdjustmentBehavior = .never + scrollView.keyboardDismissMode = .interactive + scrollView.delegate = coordinator + view.addSubview(scrollView) + + contentView.translatesAutoresizingMaskIntoConstraints = false + contentView.backgroundColor = .clear + scrollView.addSubview(contentView) + + addChild(host) + host.view.translatesAutoresizingMaskIntoConstraints = false + host.view.backgroundColor = .clear + contentView.addSubview(host.view) + host.didMove(toParent: self) + + bottomPaddingView.translatesAutoresizingMaskIntoConstraints = false + bottomPaddingView.backgroundColor = .clear + contentView.addSubview(bottomPaddingView) + + collapseSpacerView.translatesAutoresizingMaskIntoConstraints = false + collapseSpacerView.backgroundColor = .clear + contentView.addSubview(collapseSpacerView) + + hostTopConstraint = host.view.topAnchor.constraint(equalTo: contentView.topAnchor) + hostMinimumHeightConstraint = host.view.heightAnchor.constraint(greaterThanOrEqualTo: scrollView.frameLayoutGuide.heightAnchor) + bottomPaddingHeightConstraint = bottomPaddingView.heightAnchor.constraint(equalToConstant: 24) + collapseSpacerHeightConstraint = collapseSpacerView.heightAnchor.constraint(equalToConstant: 1) + + NSLayoutConstraint.activate([ + scrollView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + scrollView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + scrollView.topAnchor.constraint(equalTo: view.topAnchor), + scrollView.bottomAnchor.constraint(equalTo: view.bottomAnchor), + + contentView.leadingAnchor.constraint(equalTo: scrollView.contentLayoutGuide.leadingAnchor), + contentView.trailingAnchor.constraint(equalTo: scrollView.contentLayoutGuide.trailingAnchor), + contentView.topAnchor.constraint(equalTo: scrollView.contentLayoutGuide.topAnchor), + contentView.bottomAnchor.constraint(equalTo: scrollView.contentLayoutGuide.bottomAnchor), + contentView.widthAnchor.constraint(equalTo: scrollView.frameLayoutGuide.widthAnchor), + + host.view.leadingAnchor.constraint(equalTo: contentView.leadingAnchor), + host.view.trailingAnchor.constraint(equalTo: contentView.trailingAnchor), + hostTopConstraint, + hostMinimumHeightConstraint, + + bottomPaddingView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor), + bottomPaddingView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor), + bottomPaddingView.topAnchor.constraint(equalTo: host.view.bottomAnchor), + bottomPaddingHeightConstraint, + + collapseSpacerView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor), + collapseSpacerView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor), + collapseSpacerView.topAnchor.constraint(equalTo: bottomPaddingView.bottomAnchor), + collapseSpacerView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor), + collapseSpacerHeightConstraint + ]) + } + + func update( + content: AnyView, + contentBottomPadding: CGFloat, + collapseCompensation: CGFloat, + collapseDistance: CGFloat, + contentUpdateRevision: Int + ) { + if self.contentUpdateRevision != contentUpdateRevision { + self.contentUpdateRevision = contentUpdateRevision + host.rootView = content + } + + hostTopConstraint.constant = collapseCompensation + bottomPaddingHeightConstraint.constant = contentBottomPadding + collapseSpacerHeightConstraint.constant = collapseDistance + 1 + } + } +} + private struct VideoDetailTabPager: UIViewControllerRepresentable { @Binding var selectedTab: VideoPageTab let excludedDragStartFrames: [CGRect] + let contentRevision: VideoDetailTabContentRevision let introduction: () -> Introduction let comments: () -> Comments init( selectedTab: Binding, excludedDragStartFrames: [CGRect], + contentRevision: VideoDetailTabContentRevision, @ViewBuilder introduction: @escaping () -> Introduction, @ViewBuilder comments: @escaping () -> Comments ) { _selectedTab = selectedTab self.excludedDragStartFrames = excludedDragStartFrames + self.contentRevision = contentRevision self.introduction = introduction self.comments = comments } @@ -874,6 +978,7 @@ private struct VideoDetailTabPager: UIViewCo introduction: AnyView(introduction()), comments: AnyView(comments()), selectedIndex: selectedTab.pageIndex, + contentRevision: contentRevision, animated: context.coordinator.shouldAnimateProgrammaticSelection(to: selectedTab.pageIndex) ) } @@ -1018,7 +1123,13 @@ private struct VideoDetailTabPager: UIViewCo } } - func updatePages(introduction: AnyView, comments: AnyView, selectedIndex: Int, animated: Bool) { + func updatePages( + introduction: AnyView, + comments: AnyView, + selectedIndex: Int, + contentRevision: VideoDetailTabContentRevision, + animated: Bool + ) { introductionHost.rootView = introduction commentsHost.rootView = comments setSelectedIndex(selectedIndex, animated: animated) @@ -1070,6 +1181,49 @@ extension View { } } +private extension VideoDetailScreenSnapshot { + func hash(into hasher: inout Hasher) { + hasher.combine(videoCode) + hasher.combine(title) + hasher.combine(chineseTitle) + hasher.combine(videoDescription) + hasher.combine(views) + hasher.combine(tagSummary) + hasher.combine(sourceCount) + hasher.combine(defaultSourceLabel) + hasher.combine(defaultSourceUrl) + hasher.combine(uploadDate) + hasher.combine(coverUrl) + hasher.combine(artist) + hasher.combine(favTimes) + hasher.combine(isFav) + hasher.combine(csrfToken) + hasher.combine(currentUserId) + hasher.combine(isWatchLater) + hasher.combine(originalComic) + hasher.combine(playbackPositionMillis) + hasher.combine(tags) + hasher.combine(playbackSources) + hasher.combine(playlistName) + playlistVideos.forEach { $0.hash(into: &hasher) } + hasher.combine(myListItems) + relatedVideos.forEach { $0.hash(into: &hasher) } + } +} + +private extension VideoRelatedRow { + func hash(into hasher: inout Hasher) { + hasher.combine(videoCode) + hasher.combine(title) + hasher.combine(coverUrl) + hasher.combine(duration) + hasher.combine(views) + hasher.combine(artist) + hasher.combine(uploadTime) + hasher.combine(isPlaying) + } +} + struct TapOnlyControl: View { let isDisabled: Bool let action: () -> Void From 6e1783017b1388ee487e2dc6044da37a90616e44 Mon Sep 17 00:00:00 2001 From: pgs666 <74764545+pgs666@users.noreply.github.com> Date: Wed, 3 Jun 2026 04:09:55 +0800 Subject: [PATCH 059/216] Preserve scroll compensation layout semantics --- iosApp/VideoDetailView.swift | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/iosApp/VideoDetailView.swift b/iosApp/VideoDetailView.swift index 1cfba141..8f496386 100644 --- a/iosApp/VideoDetailView.swift +++ b/iosApp/VideoDetailView.swift @@ -416,8 +416,7 @@ struct VideoDetailView: View { VideoDetailTabPager( selectedTab: $selectedTab, - excludedDragStartFrames: horizontalPagerExclusionFrames, - contentRevision: contentRevision + excludedDragStartFrames: horizontalPagerExclusionFrames ) { tabScroll( .introduction, @@ -935,7 +934,8 @@ private struct VideoDetailVerticalScrollPage: UIViewControllerRep host.rootView = content } - hostTopConstraint.constant = collapseCompensation + hostTopConstraint.constant = 0 + host.view.transform = CGAffineTransform(translationX: 0, y: collapseCompensation) bottomPaddingHeightConstraint.constant = contentBottomPadding collapseSpacerHeightConstraint.constant = collapseDistance + 1 } @@ -945,20 +945,17 @@ private struct VideoDetailVerticalScrollPage: UIViewControllerRep private struct VideoDetailTabPager: UIViewControllerRepresentable { @Binding var selectedTab: VideoPageTab let excludedDragStartFrames: [CGRect] - let contentRevision: VideoDetailTabContentRevision let introduction: () -> Introduction let comments: () -> Comments init( selectedTab: Binding, excludedDragStartFrames: [CGRect], - contentRevision: VideoDetailTabContentRevision, @ViewBuilder introduction: @escaping () -> Introduction, @ViewBuilder comments: @escaping () -> Comments ) { _selectedTab = selectedTab self.excludedDragStartFrames = excludedDragStartFrames - self.contentRevision = contentRevision self.introduction = introduction self.comments = comments } @@ -978,7 +975,6 @@ private struct VideoDetailTabPager: UIViewCo introduction: AnyView(introduction()), comments: AnyView(comments()), selectedIndex: selectedTab.pageIndex, - contentRevision: contentRevision, animated: context.coordinator.shouldAnimateProgrammaticSelection(to: selectedTab.pageIndex) ) } @@ -1127,7 +1123,6 @@ private struct VideoDetailTabPager: UIViewCo introduction: AnyView, comments: AnyView, selectedIndex: Int, - contentRevision: VideoDetailTabContentRevision, animated: Bool ) { introductionHost.rootView = introduction From 2fe6f9cd51b8931f0b4bf398ba701827f4cad499 Mon Sep 17 00:00:00 2001 From: pgs666 <74764545+pgs666@users.noreply.github.com> Date: Wed, 3 Jun 2026 04:19:51 +0800 Subject: [PATCH 060/216] Avoid rebuilding tab page content during scroll --- iosApp/VideoDetailView.swift | 343 ++++++++++++++++------------------- 1 file changed, 158 insertions(+), 185 deletions(-) diff --git a/iosApp/VideoDetailView.swift b/iosApp/VideoDetailView.swift index 8f496386..44b2a249 100644 --- a/iosApp/VideoDetailView.swift +++ b/iosApp/VideoDetailView.swift @@ -416,9 +416,8 @@ struct VideoDetailView: View { VideoDetailTabPager( selectedTab: $selectedTab, - excludedDragStartFrames: horizontalPagerExclusionFrames - ) { - tabScroll( + excludedDragStartFrames: horizontalPagerExclusionFrames, + introduction: tabPage( .introduction, contentUpdateRevision: contentRevision.introduction ) { @@ -439,9 +438,8 @@ struct VideoDetailView: View { tabCollapseCompensation(for: .introduction, collapseCompensation: collapseCompensation) } collapseDistance: { collapseDistance - } - } comments: { - tabScroll( + }, + comments: tabPage( .comments, contentBottomPadding: composerContentClearance, contentUpdateRevision: contentRevision.comments @@ -458,7 +456,7 @@ struct VideoDetailView: View { } collapseDistance: { collapseDistance } - } + ) .frame(maxHeight: .infinity) } .frame(maxHeight: .infinity) @@ -502,15 +500,15 @@ struct VideoDetailView: View { } } - private func tabScroll( + private func tabPage( _ tab: VideoPageTab, contentBottomPadding: CGFloat = 24, contentUpdateRevision: Int, @ViewBuilder content: @escaping () -> Content, collapseCompensation: @escaping () -> CGFloat = { 0 }, collapseDistance: @escaping () -> CGFloat = { 0 } - ) -> some View { - VideoDetailVerticalScrollPage( + ) -> VideoDetailTabPage { + VideoDetailTabPage( tab: tab, contentBottomPadding: contentBottomPadding, collapseCompensation: collapseCompensation(), @@ -528,7 +526,6 @@ struct VideoDetailView: View { }, content: content ) - .id(tab) } private func tabCollapseCompensation(for tab: VideoPageTab, collapseCompensation: CGFloat) -> CGFloat { @@ -768,16 +765,16 @@ private struct VideoDetailTabContentRevision: Equatable { let comments: Int } -private struct VideoDetailVerticalScrollPage: UIViewControllerRepresentable { +private struct VideoDetailTabPage { let tab: VideoPageTab let contentBottomPadding: CGFloat let collapseCompensation: CGFloat let collapseDistance: CGFloat let contentUpdateRevision: Int let onOffsetChange: (VideoPageTab, CGFloat) -> Void - let content: () -> Content + let content: () -> AnyView - init( + init( tab: VideoPageTab, contentBottomPadding: CGFloat, collapseCompensation: CGFloat, @@ -792,167 +789,143 @@ private struct VideoDetailVerticalScrollPage: UIViewControllerRep self.collapseDistance = collapseDistance self.contentUpdateRevision = contentUpdateRevision self.onOffsetChange = onOffsetChange - self.content = content - } - - func makeCoordinator() -> Coordinator { - Coordinator(tab: tab, onOffsetChange: onOffsetChange) + self.content = { AnyView(content()) } } +} - func makeUIViewController(context: Context) -> ScrollPageViewController { - ScrollPageViewController(coordinator: context.coordinator) - } +private final class VideoDetailVerticalScrollPageCoordinator: NSObject, UIScrollViewDelegate { + var tab: VideoPageTab + var onOffsetChange: (VideoPageTab, CGFloat) -> Void - func updateUIViewController(_ uiViewController: ScrollPageViewController, context: Context) { - context.coordinator.tab = tab - context.coordinator.onOffsetChange = onOffsetChange - uiViewController.update( - content: AnyView(content()), - contentBottomPadding: contentBottomPadding, - collapseCompensation: collapseCompensation, - collapseDistance: collapseDistance, - contentUpdateRevision: contentUpdateRevision - ) + init(tab: VideoPageTab, onOffsetChange: @escaping (VideoPageTab, CGFloat) -> Void) { + self.tab = tab + self.onOffsetChange = onOffsetChange } - final class Coordinator: NSObject, UIScrollViewDelegate { - var tab: VideoPageTab - var onOffsetChange: (VideoPageTab, CGFloat) -> Void - - init(tab: VideoPageTab, onOffsetChange: @escaping (VideoPageTab, CGFloat) -> Void) { - self.tab = tab - self.onOffsetChange = onOffsetChange - } - - func scrollViewDidScroll(_ scrollView: UIScrollView) { - onOffsetChange(tab, max(0, scrollView.contentOffset.y)) - } + func scrollViewDidScroll(_ scrollView: UIScrollView) { + onOffsetChange(tab, max(0, scrollView.contentOffset.y)) } +} - final class ScrollPageViewController: UIViewController { - private let coordinator: Coordinator - private let scrollView = UIScrollView() - private let contentView = UIView() - private let bottomPaddingView = UIView() - private let collapseSpacerView = UIView() - private let host = UIHostingController(rootView: AnyView(EmptyView())) - private var hostTopConstraint: NSLayoutConstraint! - private var hostMinimumHeightConstraint: NSLayoutConstraint! - private var bottomPaddingHeightConstraint: NSLayoutConstraint! - private var collapseSpacerHeightConstraint: NSLayoutConstraint! - private var contentUpdateRevision: Int? - - init(coordinator: Coordinator) { - self.coordinator = coordinator - super.init(nibName: nil, bundle: nil) - } - - @available(*, unavailable) - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - override func viewDidLoad() { - super.viewDidLoad() - view.backgroundColor = .clear - - scrollView.translatesAutoresizingMaskIntoConstraints = false - scrollView.backgroundColor = .clear - scrollView.bounces = false - scrollView.alwaysBounceVertical = false - scrollView.alwaysBounceHorizontal = false - scrollView.showsHorizontalScrollIndicator = false - scrollView.showsVerticalScrollIndicator = false - scrollView.isDirectionalLockEnabled = true - scrollView.contentInsetAdjustmentBehavior = .never - scrollView.keyboardDismissMode = .interactive - scrollView.delegate = coordinator - view.addSubview(scrollView) - - contentView.translatesAutoresizingMaskIntoConstraints = false - contentView.backgroundColor = .clear - scrollView.addSubview(contentView) - - addChild(host) - host.view.translatesAutoresizingMaskIntoConstraints = false - host.view.backgroundColor = .clear - contentView.addSubview(host.view) - host.didMove(toParent: self) - - bottomPaddingView.translatesAutoresizingMaskIntoConstraints = false - bottomPaddingView.backgroundColor = .clear - contentView.addSubview(bottomPaddingView) - - collapseSpacerView.translatesAutoresizingMaskIntoConstraints = false - collapseSpacerView.backgroundColor = .clear - contentView.addSubview(collapseSpacerView) - - hostTopConstraint = host.view.topAnchor.constraint(equalTo: contentView.topAnchor) - hostMinimumHeightConstraint = host.view.heightAnchor.constraint(greaterThanOrEqualTo: scrollView.frameLayoutGuide.heightAnchor) - bottomPaddingHeightConstraint = bottomPaddingView.heightAnchor.constraint(equalToConstant: 24) - collapseSpacerHeightConstraint = collapseSpacerView.heightAnchor.constraint(equalToConstant: 1) - - NSLayoutConstraint.activate([ - scrollView.leadingAnchor.constraint(equalTo: view.leadingAnchor), - scrollView.trailingAnchor.constraint(equalTo: view.trailingAnchor), - scrollView.topAnchor.constraint(equalTo: view.topAnchor), - scrollView.bottomAnchor.constraint(equalTo: view.bottomAnchor), - - contentView.leadingAnchor.constraint(equalTo: scrollView.contentLayoutGuide.leadingAnchor), - contentView.trailingAnchor.constraint(equalTo: scrollView.contentLayoutGuide.trailingAnchor), - contentView.topAnchor.constraint(equalTo: scrollView.contentLayoutGuide.topAnchor), - contentView.bottomAnchor.constraint(equalTo: scrollView.contentLayoutGuide.bottomAnchor), - contentView.widthAnchor.constraint(equalTo: scrollView.frameLayoutGuide.widthAnchor), - - host.view.leadingAnchor.constraint(equalTo: contentView.leadingAnchor), - host.view.trailingAnchor.constraint(equalTo: contentView.trailingAnchor), - hostTopConstraint, - hostMinimumHeightConstraint, - - bottomPaddingView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor), - bottomPaddingView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor), - bottomPaddingView.topAnchor.constraint(equalTo: host.view.bottomAnchor), - bottomPaddingHeightConstraint, - - collapseSpacerView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor), - collapseSpacerView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor), - collapseSpacerView.topAnchor.constraint(equalTo: bottomPaddingView.bottomAnchor), - collapseSpacerView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor), - collapseSpacerHeightConstraint - ]) +private final class VideoDetailVerticalScrollPageViewController: UIViewController { + private let coordinator: VideoDetailVerticalScrollPageCoordinator + private let scrollView = UIScrollView() + private let contentView = UIView() + private let bottomPaddingView = UIView() + private let collapseSpacerView = UIView() + private let host = UIHostingController(rootView: AnyView(EmptyView())) + private var hostTopConstraint: NSLayoutConstraint! + private var hostMinimumHeightConstraint: NSLayoutConstraint! + private var bottomPaddingHeightConstraint: NSLayoutConstraint! + private var collapseSpacerHeightConstraint: NSLayoutConstraint! + private var contentUpdateRevision: Int? + + init(tab: VideoPageTab) { + coordinator = VideoDetailVerticalScrollPageCoordinator(tab: tab, onOffsetChange: { _, _ in }) + super.init(nibName: nil, bundle: nil) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + view.backgroundColor = .clear + + scrollView.translatesAutoresizingMaskIntoConstraints = false + scrollView.backgroundColor = .clear + scrollView.bounces = false + scrollView.alwaysBounceVertical = false + scrollView.alwaysBounceHorizontal = false + scrollView.showsHorizontalScrollIndicator = false + scrollView.showsVerticalScrollIndicator = false + scrollView.isDirectionalLockEnabled = true + scrollView.contentInsetAdjustmentBehavior = .never + scrollView.keyboardDismissMode = .interactive + scrollView.delegate = coordinator + view.addSubview(scrollView) + + contentView.translatesAutoresizingMaskIntoConstraints = false + contentView.backgroundColor = .clear + scrollView.addSubview(contentView) + + addChild(host) + host.view.translatesAutoresizingMaskIntoConstraints = false + host.view.backgroundColor = .clear + contentView.addSubview(host.view) + host.didMove(toParent: self) + + bottomPaddingView.translatesAutoresizingMaskIntoConstraints = false + bottomPaddingView.backgroundColor = .clear + contentView.addSubview(bottomPaddingView) + + collapseSpacerView.translatesAutoresizingMaskIntoConstraints = false + collapseSpacerView.backgroundColor = .clear + contentView.addSubview(collapseSpacerView) + + hostTopConstraint = host.view.topAnchor.constraint(equalTo: contentView.topAnchor) + hostMinimumHeightConstraint = host.view.heightAnchor.constraint(greaterThanOrEqualTo: scrollView.frameLayoutGuide.heightAnchor) + bottomPaddingHeightConstraint = bottomPaddingView.heightAnchor.constraint(equalToConstant: 24) + collapseSpacerHeightConstraint = collapseSpacerView.heightAnchor.constraint(equalToConstant: 1) + + NSLayoutConstraint.activate([ + scrollView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + scrollView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + scrollView.topAnchor.constraint(equalTo: view.topAnchor), + scrollView.bottomAnchor.constraint(equalTo: view.bottomAnchor), + + contentView.leadingAnchor.constraint(equalTo: scrollView.contentLayoutGuide.leadingAnchor), + contentView.trailingAnchor.constraint(equalTo: scrollView.contentLayoutGuide.trailingAnchor), + contentView.topAnchor.constraint(equalTo: scrollView.contentLayoutGuide.topAnchor), + contentView.bottomAnchor.constraint(equalTo: scrollView.contentLayoutGuide.bottomAnchor), + contentView.widthAnchor.constraint(equalTo: scrollView.frameLayoutGuide.widthAnchor), + + host.view.leadingAnchor.constraint(equalTo: contentView.leadingAnchor), + host.view.trailingAnchor.constraint(equalTo: contentView.trailingAnchor), + hostTopConstraint, + hostMinimumHeightConstraint, + + bottomPaddingView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor), + bottomPaddingView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor), + bottomPaddingView.topAnchor.constraint(equalTo: host.view.bottomAnchor), + bottomPaddingHeightConstraint, + + collapseSpacerView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor), + collapseSpacerView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor), + collapseSpacerView.topAnchor.constraint(equalTo: bottomPaddingView.bottomAnchor), + collapseSpacerView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor), + collapseSpacerHeightConstraint + ]) + } + + func update(page: VideoDetailTabPage) { + coordinator.tab = page.tab + coordinator.onOffsetChange = page.onOffsetChange + if contentUpdateRevision != page.contentUpdateRevision { + contentUpdateRevision = page.contentUpdateRevision + host.rootView = page.content() } - func update( - content: AnyView, - contentBottomPadding: CGFloat, - collapseCompensation: CGFloat, - collapseDistance: CGFloat, - contentUpdateRevision: Int - ) { - if self.contentUpdateRevision != contentUpdateRevision { - self.contentUpdateRevision = contentUpdateRevision - host.rootView = content - } - - hostTopConstraint.constant = 0 - host.view.transform = CGAffineTransform(translationX: 0, y: collapseCompensation) - bottomPaddingHeightConstraint.constant = contentBottomPadding - collapseSpacerHeightConstraint.constant = collapseDistance + 1 - } + hostTopConstraint.constant = 0 + host.view.transform = CGAffineTransform(translationX: 0, y: page.collapseCompensation) + bottomPaddingHeightConstraint.constant = page.contentBottomPadding + collapseSpacerHeightConstraint.constant = page.collapseDistance + 1 } } -private struct VideoDetailTabPager: UIViewControllerRepresentable { +private struct VideoDetailTabPager: UIViewControllerRepresentable { @Binding var selectedTab: VideoPageTab let excludedDragStartFrames: [CGRect] - let introduction: () -> Introduction - let comments: () -> Comments + let introduction: VideoDetailTabPage + let comments: VideoDetailTabPage init( selectedTab: Binding, excludedDragStartFrames: [CGRect], - @ViewBuilder introduction: @escaping () -> Introduction, - @ViewBuilder comments: @escaping () -> Comments + introduction: VideoDetailTabPage, + comments: VideoDetailTabPage ) { _selectedTab = selectedTab self.excludedDragStartFrames = excludedDragStartFrames @@ -972,8 +945,8 @@ private struct VideoDetailTabPager: UIViewCo context.coordinator.selectedTab = $selectedTab context.coordinator.excludedDragStartFrames = excludedDragStartFrames uiViewController.updatePages( - introduction: AnyView(introduction()), - comments: AnyView(comments()), + introduction: introduction, + comments: comments, selectedIndex: selectedTab.pageIndex, animated: context.coordinator.shouldAnimateProgrammaticSelection(to: selectedTab.pageIndex) ) @@ -1042,8 +1015,8 @@ private struct VideoDetailTabPager: UIViewCo private let coordinator: Coordinator private let scrollView = UIScrollView() private let contentView = UIView() - private let introductionHost = UIHostingController(rootView: AnyView(EmptyView())) - private let commentsHost = UIHostingController(rootView: AnyView(EmptyView())) + private let introductionPage = VideoDetailVerticalScrollPageViewController(tab: .introduction) + private let commentsPage = VideoDetailVerticalScrollPageViewController(tab: .comments) private var selectedIndex = 0 private var pendingSelectedIndex: Int? @@ -1079,8 +1052,8 @@ private struct VideoDetailTabPager: UIViewCo contentView.backgroundColor = .clear scrollView.addSubview(contentView) - addPage(introductionHost) - addPage(commentsHost) + addPage(introductionPage) + addPage(commentsPage) NSLayoutConstraint.activate([ scrollView.leadingAnchor.constraint(equalTo: view.leadingAnchor), @@ -1094,18 +1067,18 @@ private struct VideoDetailTabPager: UIViewCo contentView.bottomAnchor.constraint(equalTo: scrollView.contentLayoutGuide.bottomAnchor), contentView.heightAnchor.constraint(equalTo: scrollView.frameLayoutGuide.heightAnchor), - introductionHost.view.leadingAnchor.constraint(equalTo: contentView.leadingAnchor), - introductionHost.view.topAnchor.constraint(equalTo: contentView.topAnchor), - introductionHost.view.bottomAnchor.constraint(equalTo: contentView.bottomAnchor), - introductionHost.view.widthAnchor.constraint(equalTo: scrollView.frameLayoutGuide.widthAnchor), - introductionHost.view.heightAnchor.constraint(equalTo: scrollView.frameLayoutGuide.heightAnchor), - - commentsHost.view.leadingAnchor.constraint(equalTo: introductionHost.view.trailingAnchor), - commentsHost.view.trailingAnchor.constraint(equalTo: contentView.trailingAnchor), - commentsHost.view.topAnchor.constraint(equalTo: contentView.topAnchor), - commentsHost.view.bottomAnchor.constraint(equalTo: contentView.bottomAnchor), - commentsHost.view.widthAnchor.constraint(equalTo: scrollView.frameLayoutGuide.widthAnchor), - commentsHost.view.heightAnchor.constraint(equalTo: scrollView.frameLayoutGuide.heightAnchor) + introductionPage.view.leadingAnchor.constraint(equalTo: contentView.leadingAnchor), + introductionPage.view.topAnchor.constraint(equalTo: contentView.topAnchor), + introductionPage.view.bottomAnchor.constraint(equalTo: contentView.bottomAnchor), + introductionPage.view.widthAnchor.constraint(equalTo: scrollView.frameLayoutGuide.widthAnchor), + introductionPage.view.heightAnchor.constraint(equalTo: scrollView.frameLayoutGuide.heightAnchor), + + commentsPage.view.leadingAnchor.constraint(equalTo: introductionPage.view.trailingAnchor), + commentsPage.view.trailingAnchor.constraint(equalTo: contentView.trailingAnchor), + commentsPage.view.topAnchor.constraint(equalTo: contentView.topAnchor), + commentsPage.view.bottomAnchor.constraint(equalTo: contentView.bottomAnchor), + commentsPage.view.widthAnchor.constraint(equalTo: scrollView.frameLayoutGuide.widthAnchor), + commentsPage.view.heightAnchor.constraint(equalTo: scrollView.frameLayoutGuide.heightAnchor) ]) } @@ -1120,22 +1093,22 @@ private struct VideoDetailTabPager: UIViewCo } func updatePages( - introduction: AnyView, - comments: AnyView, + introduction: VideoDetailTabPage, + comments: VideoDetailTabPage, selectedIndex: Int, animated: Bool ) { - introductionHost.rootView = introduction - commentsHost.rootView = comments + introductionPage.update(page: introduction) + commentsPage.update(page: comments) setSelectedIndex(selectedIndex, animated: animated) } - private func addPage(_ host: UIHostingController) { - addChild(host) - host.view.translatesAutoresizingMaskIntoConstraints = false - host.view.backgroundColor = .clear - contentView.addSubview(host.view) - host.didMove(toParent: self) + private func addPage(_ page: VideoDetailVerticalScrollPageViewController) { + addChild(page) + page.view.translatesAutoresizingMaskIntoConstraints = false + page.view.backgroundColor = .clear + contentView.addSubview(page.view) + page.didMove(toParent: self) } private func setSelectedIndex(_ index: Int, animated: Bool) { From 154c9f4b3c9406d5b0253a074d6344849ea430e6 Mon Sep 17 00:00:00 2001 From: pgs666 <74764545+pgs666@users.noreply.github.com> Date: Wed, 3 Jun 2026 04:26:26 +0800 Subject: [PATCH 061/216] Use eager comment stack in UIKit scroller --- iosApp/CommentView.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/iosApp/CommentView.swift b/iosApp/CommentView.swift index 214952a7..00ac3743 100644 --- a/iosApp/CommentView.swift +++ b/iosApp/CommentView.swift @@ -175,7 +175,7 @@ struct CommentView: View { .frame(maxWidth: .infinity) .padding(.vertical, 60) } else { - LazyVStack(alignment: .leading, spacing: 12) { + VStack(alignment: .leading, spacing: 12) { ForEach(comments) { comment in CommentRowView( comment: comment, From b2b8bcc0226d6c2290931ded31108f30f19d5e3a Mon Sep 17 00:00:00 2001 From: pgs666 <74764545+pgs666@users.noreply.github.com> Date: Wed, 3 Jun 2026 04:33:00 +0800 Subject: [PATCH 062/216] Match tab content padding offset semantics --- iosApp/VideoDetailView.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/iosApp/VideoDetailView.swift b/iosApp/VideoDetailView.swift index 44b2a249..4e6372cb 100644 --- a/iosApp/VideoDetailView.swift +++ b/iosApp/VideoDetailView.swift @@ -910,6 +910,7 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle hostTopConstraint.constant = 0 host.view.transform = CGAffineTransform(translationX: 0, y: page.collapseCompensation) + bottomPaddingView.transform = CGAffineTransform(translationX: 0, y: page.collapseCompensation) bottomPaddingHeightConstraint.constant = page.contentBottomPadding collapseSpacerHeightConstraint.constant = page.collapseDistance + 1 } From 93e4208039a93d69434ceb9014428bc3983592c5 Mon Sep 17 00:00:00 2001 From: pgs666 <74764545+pgs666@users.noreply.github.com> Date: Wed, 3 Jun 2026 04:41:31 +0800 Subject: [PATCH 063/216] Ensure tab scroll controllers are loaded before update --- iosApp/VideoDetailView.swift | 2 ++ 1 file changed, 2 insertions(+) diff --git a/iosApp/VideoDetailView.swift b/iosApp/VideoDetailView.swift index 4e6372cb..4dcab9c3 100644 --- a/iosApp/VideoDetailView.swift +++ b/iosApp/VideoDetailView.swift @@ -901,6 +901,7 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle } func update(page: VideoDetailTabPage) { + loadViewIfNeeded() coordinator.tab = page.tab coordinator.onOffsetChange = page.onOffsetChange if contentUpdateRevision != page.contentUpdateRevision { @@ -1099,6 +1100,7 @@ private struct VideoDetailTabPager: UIViewControllerRepresentable { selectedIndex: Int, animated: Bool ) { + loadViewIfNeeded() introductionPage.update(page: introduction) commentsPage.update(page: comments) setSelectedIndex(selectedIndex, animated: animated) From c5130ae03a2eba377fc78fa9a29e79e5812bc2aa Mon Sep 17 00:00:00 2001 From: pgs666 <74764545+pgs666@users.noreply.github.com> Date: Wed, 3 Jun 2026 04:53:03 +0800 Subject: [PATCH 064/216] Avoid setting UIScrollView pan delegate --- iosApp/VideoDetailView.swift | 32 ++++++++++++++++++++++++-------- 1 file changed, 24 insertions(+), 8 deletions(-) diff --git a/iosApp/VideoDetailView.swift b/iosApp/VideoDetailView.swift index 4dcab9c3..4b1b334f 100644 --- a/iosApp/VideoDetailView.swift +++ b/iosApp/VideoDetailView.swift @@ -954,7 +954,7 @@ private struct VideoDetailTabPager: UIViewControllerRepresentable { ) } - final class Coordinator: NSObject, UIScrollViewDelegate, UIGestureRecognizerDelegate { + final class Coordinator: NSObject, UIScrollViewDelegate { var selectedTab: Binding var excludedDragStartFrames: [CGRect] = [] private var lastProgrammaticIndex: Int? @@ -983,11 +983,10 @@ private struct VideoDetailTabPager: UIViewControllerRepresentable { } } - func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool { - guard let panGestureRecognizer = gestureRecognizer as? UIPanGestureRecognizer, - let view = gestureRecognizer.view else { - return true - } + func shouldBeginHorizontalPagingPan( + panGestureRecognizer: UIPanGestureRecognizer, + in view: UIView + ) -> Bool { let startLocation = panGestureRecognizer.location(in: view) guard startLocation.x > 24 else { return false } let globalStartLocation = view.convert(startLocation, to: nil) @@ -1015,7 +1014,7 @@ private struct VideoDetailTabPager: UIViewControllerRepresentable { final class PagingViewController: UIViewController { private let coordinator: Coordinator - private let scrollView = UIScrollView() + private let scrollView = PagingScrollView() private let contentView = UIView() private let introductionPage = VideoDetailVerticalScrollPageViewController(tab: .introduction) private let commentsPage = VideoDetailVerticalScrollPageViewController(tab: .comments) @@ -1047,7 +1046,12 @@ private struct VideoDetailTabPager: UIViewControllerRepresentable { scrollView.isDirectionalLockEnabled = true scrollView.contentInsetAdjustmentBehavior = .never scrollView.delegate = coordinator - scrollView.panGestureRecognizer.delegate = coordinator + scrollView.shouldBeginPagingPan = { [weak self] panGestureRecognizer, view in + self?.coordinator.shouldBeginHorizontalPagingPan( + panGestureRecognizer: panGestureRecognizer, + in: view + ) ?? true + } view.addSubview(scrollView) contentView.translatesAutoresizingMaskIntoConstraints = false @@ -1126,6 +1130,18 @@ private struct VideoDetailTabPager: UIViewControllerRepresentable { scrollView.setContentOffset(targetOffset, animated: animated) } } + + final class PagingScrollView: UIScrollView { + var shouldBeginPagingPan: ((UIPanGestureRecognizer, UIView) -> Bool)? + + override func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool { + if let panGestureRecognizer = gestureRecognizer as? UIPanGestureRecognizer, + panGestureRecognizer === self.panGestureRecognizer { + return shouldBeginPagingPan?(panGestureRecognizer, self) ?? true + } + return super.gestureRecognizerShouldBegin(gestureRecognizer) + } + } } private struct HorizontalPagerExclusionFramePreferenceKey: PreferenceKey { From c4cf5918ee3c63e5c4ee5e7314c9863fd747f60d Mon Sep 17 00:00:00 2001 From: pgs666 <74764545+pgs666@users.noreply.github.com> Date: Wed, 3 Jun 2026 08:29:06 +0800 Subject: [PATCH 065/216] Stabilize keyboard and pause scroll transitions --- iosApp/VideoDetailView.swift | 31 ++++++++++++++++++++----------- 1 file changed, 20 insertions(+), 11 deletions(-) diff --git a/iosApp/VideoDetailView.swift b/iosApp/VideoDetailView.swift index 4b1b334f..ec3a55c4 100644 --- a/iosApp/VideoDetailView.swift +++ b/iosApp/VideoDetailView.swift @@ -62,6 +62,7 @@ struct VideoDetailView: View { ZStack(alignment: .bottom) { content .ignoresSafeArea(.container, edges: ignoredContainerSafeAreaEdges) + .ignoresSafeArea(.keyboard, edges: .bottom) rootCommentComposer(layoutSize: proxy.size) } @@ -259,18 +260,19 @@ struct VideoDetailView: View { let leftWidth = leftPanelWidth(for: proxy.size) HStack(alignment: .top, spacing: 0) { + let currentPlayerCollapseDistance = playerCollapseDistance(panelWidth: leftWidth) + let currentPlayerHeight = playerHeight( + panelWidth: leftWidth, + parentHeight: proxy.size.height + ) + let currentPlayerShrink = playerVisualShrink(panelWidth: leftWidth) + let currentBottomScrollHeight = proxy.size.height - ( + isPlayerCollapsed || isPlayerPlaying + ? currentPlayerHeight + : playerMinimumHeight(panelWidth: leftWidth) + ) + ZStack(alignment: .top) { - let currentPlayerCollapseDistance = playerCollapseDistance(panelWidth: leftWidth) - let currentPlayerHeight = playerHeight( - panelWidth: leftWidth, - parentHeight: proxy.size.height - ) - let currentPlayerShrink = playerVisualShrink(panelWidth: leftWidth) - let currentBottomScrollHeight = proxy.size.height - ( - isPlayerCollapsed || isPlayerPlaying - ? currentPlayerHeight - : playerMinimumHeight(panelWidth: leftWidth) - ) playerArea(snapshot: snapshot) .frame( width: leftWidth, @@ -296,6 +298,13 @@ struct VideoDetailView: View { } .frame(width: leftWidth, height: proxy.size.height, alignment: .top) .clipped() + .onValueChange(of: isPlayerPlaying) { isPlaying in + guard !isPlaying, !isPlayerCollapsed, !isPlayerFullscreen else { return } + bottomScrollOffset = min( + max(bottomScrollOffsetsByTab[selectedTab] ?? 0, 0), + currentPlayerCollapseDistance + ) + } if isWide { Divider() From 83d63b187a28d0886ccfd634be9bce7f28965765 Mon Sep 17 00:00:00 2001 From: pgs666 <74764545+pgs666@users.noreply.github.com> Date: Thu, 4 Jun 2026 02:44:24 +0800 Subject: [PATCH 066/216] Respect horizontal sections in tab pager --- iosApp/RelatedVideoComponents.swift | 3 ++- iosApp/VideoDetailView.swift | 19 +++++++++++++++++++ 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/iosApp/RelatedVideoComponents.swift b/iosApp/RelatedVideoComponents.swift index 214c9d6b..125f228a 100644 --- a/iosApp/RelatedVideoComponents.swift +++ b/iosApp/RelatedVideoComponents.swift @@ -300,6 +300,8 @@ struct RelatedVideoCard: View { .padding(.horizontal, 7) .padding(.bottom, 5) + } + .overlay(alignment: .topLeading) { if showPlaying && video.isPlaying { Text("正在播放") .font(.caption2.weight(.bold)) @@ -307,7 +309,6 @@ struct RelatedVideoCard: View { .padding(.horizontal, 8) .padding(.vertical, 4) .background(.regularMaterial, in: Capsule()) - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) .padding(6) } } diff --git a/iosApp/VideoDetailView.swift b/iosApp/VideoDetailView.swift index ec3a55c4..393c77bb 100644 --- a/iosApp/VideoDetailView.swift +++ b/iosApp/VideoDetailView.swift @@ -998,6 +998,9 @@ private struct VideoDetailTabPager: UIViewControllerRepresentable { ) -> Bool { let startLocation = panGestureRecognizer.location(in: view) guard startLocation.x > 24 else { return false } + guard !view.hasScrollableHorizontalDescendant(at: startLocation, excluding: view) else { + return false + } let globalStartLocation = view.convert(startLocation, to: nil) guard !excludedDragStartFrames.contains(where: { $0.contains(globalStartLocation) }) else { return false @@ -1153,6 +1156,22 @@ private struct VideoDetailTabPager: UIViewControllerRepresentable { } } +private extension UIView { + func hasScrollableHorizontalDescendant(at location: CGPoint, excluding excludedView: UIView) -> Bool { + guard let hitView = hitTest(location, with: nil) else { return false } + var current: UIView? = hitView + while let view = current, view !== excludedView { + if let scrollView = view as? UIScrollView, + scrollView.contentSize.width > scrollView.bounds.width + 1, + scrollView.isScrollEnabled { + return true + } + current = view.superview + } + return false + } +} + private struct HorizontalPagerExclusionFramePreferenceKey: PreferenceKey { static var defaultValue: [CGRect] = [] static func reduce(value: inout [CGRect], nextValue: () -> [CGRect]) { From 83aaf1b807855eb92bf4759d9519ef514733ea6e Mon Sep 17 00:00:00 2001 From: pgs666 <74764545+pgs666@users.noreply.github.com> Date: Thu, 4 Jun 2026 02:59:49 +0800 Subject: [PATCH 067/216] Revert "Respect horizontal sections in tab pager" This reverts commit 83d63b187a28d0886ccfd634be9bce7f28965765. --- iosApp/RelatedVideoComponents.swift | 3 +-- iosApp/VideoDetailView.swift | 19 ------------------- 2 files changed, 1 insertion(+), 21 deletions(-) diff --git a/iosApp/RelatedVideoComponents.swift b/iosApp/RelatedVideoComponents.swift index 125f228a..214c9d6b 100644 --- a/iosApp/RelatedVideoComponents.swift +++ b/iosApp/RelatedVideoComponents.swift @@ -300,8 +300,6 @@ struct RelatedVideoCard: View { .padding(.horizontal, 7) .padding(.bottom, 5) - } - .overlay(alignment: .topLeading) { if showPlaying && video.isPlaying { Text("正在播放") .font(.caption2.weight(.bold)) @@ -309,6 +307,7 @@ struct RelatedVideoCard: View { .padding(.horizontal, 8) .padding(.vertical, 4) .background(.regularMaterial, in: Capsule()) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) .padding(6) } } diff --git a/iosApp/VideoDetailView.swift b/iosApp/VideoDetailView.swift index 393c77bb..ec3a55c4 100644 --- a/iosApp/VideoDetailView.swift +++ b/iosApp/VideoDetailView.swift @@ -998,9 +998,6 @@ private struct VideoDetailTabPager: UIViewControllerRepresentable { ) -> Bool { let startLocation = panGestureRecognizer.location(in: view) guard startLocation.x > 24 else { return false } - guard !view.hasScrollableHorizontalDescendant(at: startLocation, excluding: view) else { - return false - } let globalStartLocation = view.convert(startLocation, to: nil) guard !excludedDragStartFrames.contains(where: { $0.contains(globalStartLocation) }) else { return false @@ -1156,22 +1153,6 @@ private struct VideoDetailTabPager: UIViewControllerRepresentable { } } -private extension UIView { - func hasScrollableHorizontalDescendant(at location: CGPoint, excluding excludedView: UIView) -> Bool { - guard let hitView = hitTest(location, with: nil) else { return false } - var current: UIView? = hitView - while let view = current, view !== excludedView { - if let scrollView = view as? UIScrollView, - scrollView.contentSize.width > scrollView.bounds.width + 1, - scrollView.isScrollEnabled { - return true - } - current = view.superview - } - return false - } -} - private struct HorizontalPagerExclusionFramePreferenceKey: PreferenceKey { static var defaultValue: [CGRect] = [] static func reduce(value: inout [CGRect], nextValue: () -> [CGRect]) { From 1976d1a4d559d3fec37d1f397eeea6c6cd3ff5c3 Mon Sep 17 00:00:00 2001 From: pgs666 <74764545+pgs666@users.noreply.github.com> Date: Thu, 4 Jun 2026 03:16:43 +0800 Subject: [PATCH 068/216] Revert "Stabilize keyboard and pause scroll transitions" This reverts commit c4cf5918ee3c63e5c4ee5e7314c9863fd747f60d. --- iosApp/VideoDetailView.swift | 31 +++++++++++-------------------- 1 file changed, 11 insertions(+), 20 deletions(-) diff --git a/iosApp/VideoDetailView.swift b/iosApp/VideoDetailView.swift index ec3a55c4..4b1b334f 100644 --- a/iosApp/VideoDetailView.swift +++ b/iosApp/VideoDetailView.swift @@ -62,7 +62,6 @@ struct VideoDetailView: View { ZStack(alignment: .bottom) { content .ignoresSafeArea(.container, edges: ignoredContainerSafeAreaEdges) - .ignoresSafeArea(.keyboard, edges: .bottom) rootCommentComposer(layoutSize: proxy.size) } @@ -260,19 +259,18 @@ struct VideoDetailView: View { let leftWidth = leftPanelWidth(for: proxy.size) HStack(alignment: .top, spacing: 0) { - let currentPlayerCollapseDistance = playerCollapseDistance(panelWidth: leftWidth) - let currentPlayerHeight = playerHeight( - panelWidth: leftWidth, - parentHeight: proxy.size.height - ) - let currentPlayerShrink = playerVisualShrink(panelWidth: leftWidth) - let currentBottomScrollHeight = proxy.size.height - ( - isPlayerCollapsed || isPlayerPlaying - ? currentPlayerHeight - : playerMinimumHeight(panelWidth: leftWidth) - ) - ZStack(alignment: .top) { + let currentPlayerCollapseDistance = playerCollapseDistance(panelWidth: leftWidth) + let currentPlayerHeight = playerHeight( + panelWidth: leftWidth, + parentHeight: proxy.size.height + ) + let currentPlayerShrink = playerVisualShrink(panelWidth: leftWidth) + let currentBottomScrollHeight = proxy.size.height - ( + isPlayerCollapsed || isPlayerPlaying + ? currentPlayerHeight + : playerMinimumHeight(panelWidth: leftWidth) + ) playerArea(snapshot: snapshot) .frame( width: leftWidth, @@ -298,13 +296,6 @@ struct VideoDetailView: View { } .frame(width: leftWidth, height: proxy.size.height, alignment: .top) .clipped() - .onValueChange(of: isPlayerPlaying) { isPlaying in - guard !isPlaying, !isPlayerCollapsed, !isPlayerFullscreen else { return } - bottomScrollOffset = min( - max(bottomScrollOffsetsByTab[selectedTab] ?? 0, 0), - currentPlayerCollapseDistance - ) - } if isWide { Divider() From cf4712b9d890b2e9fed81e7a4ee67ed7d57cf80b Mon Sep 17 00:00:00 2001 From: pgs666 <74764545+pgs666@users.noreply.github.com> Date: Thu, 4 Jun 2026 03:20:14 +0800 Subject: [PATCH 069/216] Render comments incrementally after load --- iosApp/CommentView.swift | 59 ++++++++++++++++++++++++++++++++++++---- 1 file changed, 53 insertions(+), 6 deletions(-) diff --git a/iosApp/CommentView.swift b/iosApp/CommentView.swift index 00ac3743..a2bc9619 100644 --- a/iosApp/CommentView.swift +++ b/iosApp/CommentView.swift @@ -8,6 +8,9 @@ struct CommentView: View { @State private var replyText = "" @State private var reportTarget: CommentRow? @State private var repliesTarget: CommentRow? + @State private var renderedCommentCount = 24 + @State private var commentRenderSignature = "" + @State private var commentRenderTask: Task? init( viewModel: CommentViewModel, @@ -34,6 +37,8 @@ struct CommentView: View { } .onDisappear { onOverlayActivityChanged(false) + commentRenderTask?.cancel() + commentRenderTask = nil } .alert("提示", isPresented: actionMessageBinding) { Button("好", role: .cancel) { @@ -176,7 +181,7 @@ struct CommentView: View { .padding(.vertical, 60) } else { VStack(alignment: .leading, spacing: 12) { - ForEach(comments) { comment in + ForEach(Array(comments.prefix(renderedCommentCount))) { comment in CommentRowView( comment: comment, isRunningLike: viewModel.runningActionIDs.contains("like-\(comment.id)"), @@ -199,13 +204,25 @@ struct CommentView: View { ) } - Text("comment.no_more") - .font(.footnote) - .foregroundStyle(.secondary) - .frame(maxWidth: .infinity) - .padding(.vertical, 12) + if renderedCommentCount < comments.count { + ProgressView() + .frame(maxWidth: .infinity) + .padding(.vertical, 12) + } else { + Text("comment.no_more") + .font(.footnote) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity) + .padding(.vertical, 12) + } } .id(viewModel.sortMode.id) + .onAppear { + scheduleCommentRendering(for: comments) + } + .onValueChange(of: commentRenderSignature(for: comments)) { _ in + scheduleCommentRendering(for: comments) + } } } } @@ -227,6 +244,36 @@ struct CommentView: View { private func notifyOverlayActivityChanged() { onOverlayActivityChanged(replyTarget != nil || repliesTarget != nil) } + + private func commentRenderSignature(for comments: [CommentRow]) -> String { + [ + viewModel.sortMode.id, + "\(comments.count)", + comments.first?.id ?? "", + comments.last?.id ?? "" + ].joined(separator: "|") + } + + private func scheduleCommentRendering(for comments: [CommentRow]) { + let signature = commentRenderSignature(for: comments) + guard commentRenderSignature != signature else { return } + commentRenderSignature = signature + commentRenderTask?.cancel() + renderedCommentCount = min(24, comments.count) + guard renderedCommentCount < comments.count else { + commentRenderTask = nil + return + } + + commentRenderTask = Task { @MainActor in + while !Task.isCancelled, renderedCommentCount < comments.count { + try? await Task.sleep(nanoseconds: 45_000_000) + guard !Task.isCancelled else { return } + renderedCommentCount = min(renderedCommentCount + 16, comments.count) + } + commentRenderTask = nil + } + } } private struct CommentRowView: View { From e6aef8d154f7793b3969a65fabd3296d84fd0fde Mon Sep 17 00:00:00 2001 From: pgs666 <74764545+pgs666@users.noreply.github.com> Date: Thu, 4 Jun 2026 03:38:19 +0800 Subject: [PATCH 070/216] Use table view for video comments --- iosApp/CommentView.swift | 414 ++++++++++++++++++++++------------- iosApp/VideoDetailView.swift | 106 +++++++-- 2 files changed, 346 insertions(+), 174 deletions(-) diff --git a/iosApp/CommentView.swift b/iosApp/CommentView.swift index a2bc9619..3d5f63f3 100644 --- a/iosApp/CommentView.swift +++ b/iosApp/CommentView.swift @@ -1,31 +1,61 @@ import SwiftUI +import UIKit import Han1meShared struct CommentView: View { @ObservedObject private var viewModel: CommentViewModel private let onOverlayActivityChanged: (Bool) -> Void + private let contentBottomPadding: CGFloat + private let collapseDistance: CGFloat + private let onScrollOffsetChange: (CGFloat) -> Void @State private var replyTarget: CommentRow? @State private var replyText = "" @State private var reportTarget: CommentRow? @State private var repliesTarget: CommentRow? - @State private var renderedCommentCount = 24 - @State private var commentRenderSignature = "" - @State private var commentRenderTask: Task? init( viewModel: CommentViewModel, + contentBottomPadding: CGFloat = 24, + collapseDistance: CGFloat = 0, + onScrollOffsetChange: @escaping (CGFloat) -> Void = { _ in }, onOverlayActivityChanged: @escaping (Bool) -> Void = { _ in } ) { _viewModel = ObservedObject(wrappedValue: viewModel) + self.contentBottomPadding = contentBottomPadding + self.collapseDistance = collapseDistance + self.onScrollOffsetChange = onScrollOffsetChange self.onOverlayActivityChanged = onOverlayActivityChanged } var body: some View { - VStack(alignment: .leading, spacing: 12) { - header - content - } - .padding(.horizontal, 16) + CommentTableView( + state: viewModel.state, + sortMode: viewModel.sortMode, + sortedComments: viewModel.sortedComments, + runningActionIDs: viewModel.runningActionIDs, + contentBottomPadding: contentBottomPadding, + collapseDistance: collapseDistance, + onSortModeChange: { viewModel.changeSortMode($0) }, + onRefresh: { viewModel.load() }, + onRetry: { viewModel.load() }, + onReply: { comment in + replyText = "@\(comment.username) " + replyTarget = comment + }, + onShowReplies: { comment in + repliesTarget = comment + }, + onLike: { comment in + viewModel.like(comment: comment, isPositive: true) + }, + onDislike: { comment in + viewModel.like(comment: comment, isPositive: false) + }, + onReport: { comment in + reportTarget = comment + }, + onScrollOffsetChange: onScrollOffsetChange + ) .task { viewModel.loadIfNeeded() } @@ -37,8 +67,6 @@ struct CommentView: View { } .onDisappear { onOverlayActivityChanged(false) - commentRenderTask?.cancel() - commentRenderTask = nil } .alert("提示", isPresented: actionMessageBinding) { Button("好", role: .cancel) { @@ -88,14 +116,190 @@ struct CommentView: View { } } - private var header: some View { + private var actionMessageBinding: Binding { + Binding( + get: { viewModel.actionMessage != nil }, + set: { if !$0 { viewModel.actionMessage = nil } } + ) + } + + private var reportDialogBinding: Binding { + Binding( + get: { reportTarget != nil }, + set: { if !$0 { reportTarget = nil } } + ) + } + + private func notifyOverlayActivityChanged() { + onOverlayActivityChanged(replyTarget != nil || repliesTarget != nil) + } +} + +private struct CommentTableView: UIViewRepresentable { + let state: CommentViewModel.State + let sortMode: CommentViewModel.SortMode + let sortedComments: [CommentRow] + let runningActionIDs: Set + let contentBottomPadding: CGFloat + let collapseDistance: CGFloat + let onSortModeChange: (CommentViewModel.SortMode) -> Void + let onRefresh: () -> Void + let onRetry: () -> Void + let onReply: (CommentRow) -> Void + let onShowReplies: (CommentRow) -> Void + let onLike: (CommentRow) -> Void + let onDislike: (CommentRow) -> Void + let onReport: (CommentRow) -> Void + let onScrollOffsetChange: (CGFloat) -> Void + + func makeCoordinator() -> Coordinator { + Coordinator(parent: self) + } + + func makeUIView(context: Context) -> UITableView { + let tableView = UITableView(frame: .zero, style: .plain) + tableView.backgroundColor = .clear + tableView.separatorStyle = .none + tableView.showsVerticalScrollIndicator = false + tableView.contentInsetAdjustmentBehavior = .never + tableView.keyboardDismissMode = .interactive + tableView.estimatedRowHeight = 120 + tableView.rowHeight = UITableView.automaticDimension + tableView.dataSource = context.coordinator + tableView.delegate = context.coordinator + tableView.register(UITableViewCell.self, forCellReuseIdentifier: Coordinator.cellReuseIdentifier) + return tableView + } + + func updateUIView(_ tableView: UITableView, context: Context) { + context.coordinator.parent = self + context.coordinator.rows = rows + tableView.contentInset.bottom = contentBottomPadding + collapseDistance + 1 + tableView.verticalScrollIndicatorInsets.bottom = contentBottomPadding + tableView.reloadData() + } + + private var rows: [Row] { + var rows: [Row] = [.header] + switch state { + case .idle, .loading: + rows.append(.loading) + case .failed(let message): + rows.append(.failed(message)) + case .loaded: + if sortedComments.isEmpty { + rows.append(.empty) + } else { + rows.append(contentsOf: sortedComments.map(Row.comment)) + rows.append(.footer) + } + } + return rows + } + + enum Row { + case header + case loading + case failed(String) + case empty + case comment(CommentRow) + case footer + } + + final class Coordinator: NSObject, UITableViewDataSource, UITableViewDelegate { + static let cellReuseIdentifier = "CommentTableCell" + + var parent: CommentTableView + var rows: [Row] = [] + + init(parent: CommentTableView) { + self.parent = parent + } + + func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + rows.count + } + + func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + let cell = tableView.dequeueReusableCell(withIdentifier: Self.cellReuseIdentifier, for: indexPath) + cell.backgroundColor = .clear + cell.selectionStyle = .none + cell.contentConfiguration = UIHostingConfiguration { + view(for: rows[indexPath.row]) + } + .margins(.all, 0) + return cell + } + + func scrollViewDidScroll(_ scrollView: UIScrollView) { + parent.onScrollOffsetChange(max(0, scrollView.contentOffset.y)) + } + + @ViewBuilder + private func view(for row: Row) -> some View { + switch row { + case .header: + CommentHeaderView( + sortMode: parent.sortMode, + onSortModeChange: parent.onSortModeChange, + onRefresh: parent.onRefresh + ) + .padding(.top, 16) + .padding(.horizontal, 16) + .padding(.bottom, 12) + case .loading: + HStack { + Spacer() + ProgressView() + Spacer() + } + .padding(.vertical, 60) + .padding(.horizontal, 16) + case .failed(let message): + CommentFailureView(message: message, onRetry: parent.onRetry) + .padding(.horizontal, 16) + .padding(.vertical, 60) + case .empty: + CommentEmptyView() + .padding(.horizontal, 16) + .padding(.vertical, 60) + case .comment(let comment): + CommentRowView( + comment: comment, + isRunningLike: parent.runningActionIDs.contains("like-\(comment.id)"), + onReply: { self.parent.onReply(comment) }, + onShowReplies: { self.parent.onShowReplies(comment) }, + onLike: { self.parent.onLike(comment) }, + onDislike: { self.parent.onDislike(comment) }, + onReport: { self.parent.onReport(comment) } + ) + .padding(.horizontal, 16) + .padding(.bottom, 12) + case .footer: + Text("comment.no_more") + .font(.footnote) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity) + .padding(.horizontal, 16) + .padding(.vertical, 12) + } + } + } +} + +private struct CommentHeaderView: View { + let sortMode: CommentViewModel.SortMode + let onSortModeChange: (CommentViewModel.SortMode) -> Void + let onRefresh: () -> Void + + var body: some View { HStack(spacing: 12) { Menu { ForEach(CommentViewModel.SortMode.allCases) { mode in Button { - viewModel.changeSortMode(mode) + onSortModeChange(mode) } label: { - if mode == viewModel.sortMode { + if mode == sortMode { Label(mode.title, systemImage: "checkmark") } else { Text(mode.title) @@ -103,21 +307,18 @@ struct CommentView: View { } } } label: { - Label(viewModel.sortMode.title, systemImage: "arrow.up.arrow.down") + Label(sortMode.title, systemImage: "arrow.up.arrow.down") .font(.subheadline.weight(.semibold)) .foregroundStyle(Color.accentColor) .padding(.horizontal, 12) .padding(.vertical, 7) .background(Color.accentColor.opacity(0.12), in: RoundedRectangle(cornerRadius: 8, style: .continuous)) } - // Temporarily disabled for scroll-jank CI probe: reporting global - // frames from inside the vertical ScrollView invalidates parent - // layout continuously while scrolling. Spacer() TapOnlyControl { - viewModel.load() + onRefresh() } label: { Image(systemName: "arrow.clockwise") .font(.subheadline.weight(.semibold)) @@ -128,151 +329,52 @@ struct CommentView: View { } } } +} - @ViewBuilder - private var content: some View { - switch viewModel.state { - case .idle, .loading: - HStack { - Spacer() - ProgressView() - Spacer() - } - .padding(.vertical, 60) - case .failed(let message): - VStack(spacing: 12) { - Image(systemName: "text.bubble") - .font(.largeTitle) - .foregroundStyle(.secondary) - Text("评论加载失败") - .font(.headline) - Text(message) - .font(.subheadline) - .foregroundStyle(.secondary) - .multilineTextAlignment(.center) - TapOnlyControl { - viewModel.load() - } label: { - Text("重试") - .font(.subheadline.weight(.semibold)) - .foregroundStyle(.white) - .padding(.horizontal, 14) - .padding(.vertical, 8) - .background(Color.accentColor, in: RoundedRectangle(cornerRadius: 8, style: .continuous)) - } - CloudflareVerifyButton(errorMessage: message) - } - .frame(maxWidth: .infinity) - .padding(.vertical, 60) - case .loaded: - let comments = viewModel.sortedComments - if comments.isEmpty { - VStack(spacing: 12) { - Image(systemName: "bubble.left.and.bubble.right") - .font(.largeTitle) - .foregroundStyle(.secondary) - Text("暂无评论") - .font(.headline) - Text("成为第一个评论的人。") - .font(.subheadline) - .foregroundStyle(.secondary) - } - .frame(maxWidth: .infinity) - .padding(.vertical, 60) - } else { - VStack(alignment: .leading, spacing: 12) { - ForEach(Array(comments.prefix(renderedCommentCount))) { comment in - CommentRowView( - comment: comment, - isRunningLike: viewModel.runningActionIDs.contains("like-\(comment.id)"), - onReply: { - replyText = "@\(comment.username) " - replyTarget = comment - }, - onShowReplies: { - repliesTarget = comment - }, - onLike: { - viewModel.like(comment: comment, isPositive: true) - }, - onDislike: { - viewModel.like(comment: comment, isPositive: false) - }, - onReport: { - reportTarget = comment - } - ) - } +private struct CommentFailureView: View { + let message: String + let onRetry: () -> Void - if renderedCommentCount < comments.count { - ProgressView() - .frame(maxWidth: .infinity) - .padding(.vertical, 12) - } else { - Text("comment.no_more") - .font(.footnote) - .foregroundStyle(.secondary) - .frame(maxWidth: .infinity) - .padding(.vertical, 12) - } - } - .id(viewModel.sortMode.id) - .onAppear { - scheduleCommentRendering(for: comments) - } - .onValueChange(of: commentRenderSignature(for: comments)) { _ in - scheduleCommentRendering(for: comments) - } + var body: some View { + VStack(spacing: 12) { + Image(systemName: "text.bubble") + .font(.largeTitle) + .foregroundStyle(.secondary) + Text("评论加载失败") + .font(.headline) + Text(message) + .font(.subheadline) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + TapOnlyControl { + onRetry() + } label: { + Text("重试") + .font(.subheadline.weight(.semibold)) + .foregroundStyle(.white) + .padding(.horizontal, 14) + .padding(.vertical, 8) + .background(Color.accentColor, in: RoundedRectangle(cornerRadius: 8, style: .continuous)) } + CloudflareVerifyButton(errorMessage: message) } + .frame(maxWidth: .infinity) } +} - private var actionMessageBinding: Binding { - Binding( - get: { viewModel.actionMessage != nil }, - set: { if !$0 { viewModel.actionMessage = nil } } - ) - } - - private var reportDialogBinding: Binding { - Binding( - get: { reportTarget != nil }, - set: { if !$0 { reportTarget = nil } } - ) - } - - private func notifyOverlayActivityChanged() { - onOverlayActivityChanged(replyTarget != nil || repliesTarget != nil) - } - - private func commentRenderSignature(for comments: [CommentRow]) -> String { - [ - viewModel.sortMode.id, - "\(comments.count)", - comments.first?.id ?? "", - comments.last?.id ?? "" - ].joined(separator: "|") - } - - private func scheduleCommentRendering(for comments: [CommentRow]) { - let signature = commentRenderSignature(for: comments) - guard commentRenderSignature != signature else { return } - commentRenderSignature = signature - commentRenderTask?.cancel() - renderedCommentCount = min(24, comments.count) - guard renderedCommentCount < comments.count else { - commentRenderTask = nil - return - } - - commentRenderTask = Task { @MainActor in - while !Task.isCancelled, renderedCommentCount < comments.count { - try? await Task.sleep(nanoseconds: 45_000_000) - guard !Task.isCancelled else { return } - renderedCommentCount = min(renderedCommentCount + 16, comments.count) - } - commentRenderTask = nil +private struct CommentEmptyView: View { + var body: some View { + VStack(spacing: 12) { + Image(systemName: "bubble.left.and.bubble.right") + .font(.largeTitle) + .foregroundStyle(.secondary) + Text("暂无评论") + .font(.headline) + Text("成为第一个评论的人。") + .font(.subheadline) + .foregroundStyle(.secondary) } + .frame(maxWidth: .infinity) } } diff --git a/iosApp/VideoDetailView.swift b/iosApp/VideoDetailView.swift index 4b1b334f..8760442b 100644 --- a/iosApp/VideoDetailView.swift +++ b/iosApp/VideoDetailView.swift @@ -439,22 +439,26 @@ struct VideoDetailView: View { } collapseDistance: { collapseDistance }, - comments: tabPage( - .comments, + comments: selfScrollingCommentsTabPage( contentBottomPadding: composerContentClearance, - contentUpdateRevision: contentRevision.comments + contentUpdateRevision: commentsContentRevision( + baseRevision: contentRevision.comments, + contentBottomPadding: composerContentClearance, + collapseDistance: collapseDistance + ), + collapseDistance: collapseDistance ) { CommentView( viewModel: commentViewModel, + contentBottomPadding: composerContentClearance, + collapseDistance: collapseDistance, + onScrollOffsetChange: { offset in + updateTabOffset(.comments, offset: offset, collapseDistance: collapseDistance) + }, onOverlayActivityChanged: { isActive in isCommentInternalOverlayActive = isActive } ) - .padding(.top, 16) - } collapseCompensation: { - tabCollapseCompensation(for: .comments, collapseCompensation: collapseCompensation) - } collapseDistance: { - collapseDistance } ) .frame(maxHeight: .infinity) @@ -515,19 +519,54 @@ struct VideoDetailView: View { collapseDistance: collapseDistance(), contentUpdateRevision: contentUpdateRevision, onOffsetChange: { tab, offset in - let previousActiveOffset = bottomScrollOffsetsByTab[selectedTab] - bottomScrollOffsetsByTab[tab] = offset - guard tab == selectedTab else { return } - updatePlayerCollapseOffset( - activeTabOffset: offset, - previousActiveTabOffset: previousActiveOffset, - collapseDistance: collapseDistance() - ) + updateTabOffset(tab, offset: offset, collapseDistance: collapseDistance()) }, content: content ) } + private func selfScrollingCommentsTabPage( + contentBottomPadding: CGFloat, + contentUpdateRevision: Int, + collapseDistance: CGFloat, + @ViewBuilder content: @escaping () -> Content + ) -> VideoDetailTabPage { + VideoDetailTabPage( + tab: .comments, + contentBottomPadding: contentBottomPadding, + collapseCompensation: 0, + collapseDistance: collapseDistance, + contentUpdateRevision: contentUpdateRevision, + onOffsetChange: { tab, offset in + updateTabOffset(tab, offset: offset, collapseDistance: collapseDistance) + }, + content: content + ) + } + + private func commentsContentRevision( + baseRevision: Int, + contentBottomPadding: CGFloat, + collapseDistance: CGFloat + ) -> Int { + var hasher = Hasher() + hasher.combine(baseRevision) + hasher.combine(Int((contentBottomPadding * 100).rounded())) + hasher.combine(Int((collapseDistance * 100).rounded())) + return hasher.finalize() + } + + private func updateTabOffset(_ tab: VideoPageTab, offset: CGFloat, collapseDistance: CGFloat) { + let previousActiveOffset = bottomScrollOffsetsByTab[selectedTab] + bottomScrollOffsetsByTab[tab] = offset + guard tab == selectedTab else { return } + updatePlayerCollapseOffset( + activeTabOffset: offset, + previousActiveTabOffset: previousActiveOffset, + collapseDistance: collapseDistance + ) + } + private func tabCollapseCompensation(for tab: VideoPageTab, collapseCompensation: CGFloat) -> CGFloat { min(max(bottomScrollOffsetsByTab[tab] ?? 0, 0), collapseCompensation) } @@ -917,6 +956,37 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle } } +private final class VideoDetailHostedTabPageViewController: UIViewController { + private let host = UIHostingController(rootView: AnyView(EmptyView())) + private var contentUpdateRevision: Int? + + override func viewDidLoad() { + super.viewDidLoad() + view.backgroundColor = .clear + + addChild(host) + host.view.translatesAutoresizingMaskIntoConstraints = false + host.view.backgroundColor = .clear + view.addSubview(host.view) + host.didMove(toParent: self) + + NSLayoutConstraint.activate([ + host.view.leadingAnchor.constraint(equalTo: view.leadingAnchor), + host.view.trailingAnchor.constraint(equalTo: view.trailingAnchor), + host.view.topAnchor.constraint(equalTo: view.topAnchor), + host.view.bottomAnchor.constraint(equalTo: view.bottomAnchor) + ]) + } + + func update(page: VideoDetailTabPage) { + loadViewIfNeeded() + if contentUpdateRevision != page.contentUpdateRevision { + contentUpdateRevision = page.contentUpdateRevision + host.rootView = page.content() + } + } +} + private struct VideoDetailTabPager: UIViewControllerRepresentable { @Binding var selectedTab: VideoPageTab let excludedDragStartFrames: [CGRect] @@ -1017,7 +1087,7 @@ private struct VideoDetailTabPager: UIViewControllerRepresentable { private let scrollView = PagingScrollView() private let contentView = UIView() private let introductionPage = VideoDetailVerticalScrollPageViewController(tab: .introduction) - private let commentsPage = VideoDetailVerticalScrollPageViewController(tab: .comments) + private let commentsPage = VideoDetailHostedTabPageViewController() private var selectedIndex = 0 private var pendingSelectedIndex: Int? @@ -1110,7 +1180,7 @@ private struct VideoDetailTabPager: UIViewControllerRepresentable { setSelectedIndex(selectedIndex, animated: animated) } - private func addPage(_ page: VideoDetailVerticalScrollPageViewController) { + private func addPage(_ page: UIViewController) { addChild(page) page.view.translatesAutoresizingMaskIntoConstraints = false page.view.backgroundColor = .clear From 71e9485837a2fa1e07d0ddb04e63c1995029591c Mon Sep 17 00:00:00 2001 From: pgs666 <74764545+pgs666@users.noreply.github.com> Date: Fri, 5 Jun 2026 07:06:33 +0800 Subject: [PATCH 071/216] Restore comment collapse compensation --- iosApp/RelatedVideoComponents.swift | 4 ++-- iosApp/VideoDetailView.swift | 8 +++++++- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/iosApp/RelatedVideoComponents.swift b/iosApp/RelatedVideoComponents.swift index 214c9d6b..126f33d8 100644 --- a/iosApp/RelatedVideoComponents.swift +++ b/iosApp/RelatedVideoComponents.swift @@ -299,7 +299,8 @@ struct RelatedVideoCard: View { .lineLimit(1) .padding(.horizontal, 7) .padding(.bottom, 5) - + } + .overlay(alignment: .topLeading) { if showPlaying && video.isPlaying { Text("正在播放") .font(.caption2.weight(.bold)) @@ -307,7 +308,6 @@ struct RelatedVideoCard: View { .padding(.horizontal, 8) .padding(.vertical, 4) .background(.regularMaterial, in: Capsule()) - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) .padding(6) } } diff --git a/iosApp/VideoDetailView.swift b/iosApp/VideoDetailView.swift index 8760442b..a51d201e 100644 --- a/iosApp/VideoDetailView.swift +++ b/iosApp/VideoDetailView.swift @@ -446,6 +446,10 @@ struct VideoDetailView: View { contentBottomPadding: composerContentClearance, collapseDistance: collapseDistance ), + collapseCompensation: tabCollapseCompensation( + for: .comments, + collapseCompensation: collapseCompensation + ), collapseDistance: collapseDistance ) { CommentView( @@ -528,13 +532,14 @@ struct VideoDetailView: View { private func selfScrollingCommentsTabPage( contentBottomPadding: CGFloat, contentUpdateRevision: Int, + collapseCompensation: CGFloat, collapseDistance: CGFloat, @ViewBuilder content: @escaping () -> Content ) -> VideoDetailTabPage { VideoDetailTabPage( tab: .comments, contentBottomPadding: contentBottomPadding, - collapseCompensation: 0, + collapseCompensation: collapseCompensation, collapseDistance: collapseDistance, contentUpdateRevision: contentUpdateRevision, onOffsetChange: { tab, offset in @@ -984,6 +989,7 @@ private final class VideoDetailHostedTabPageViewController: UIViewController { contentUpdateRevision = page.contentUpdateRevision host.rootView = page.content() } + host.view.transform = CGAffineTransform(translationX: 0, y: page.collapseCompensation) } } From f425c952d4f0268be33b4f98fe0a697a65913993 Mon Sep 17 00:00:00 2001 From: pgs666 <74764545+pgs666@users.noreply.github.com> Date: Fri, 5 Jun 2026 07:39:18 +0800 Subject: [PATCH 072/216] Apply comment compensation inside table --- iosApp/VideoDetailView.swift | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/iosApp/VideoDetailView.swift b/iosApp/VideoDetailView.swift index a51d201e..b7206b1b 100644 --- a/iosApp/VideoDetailView.swift +++ b/iosApp/VideoDetailView.swift @@ -989,7 +989,25 @@ private final class VideoDetailHostedTabPageViewController: UIViewController { contentUpdateRevision = page.contentUpdateRevision host.rootView = page.content() } - host.view.transform = CGAffineTransform(translationX: 0, y: page.collapseCompensation) + updateEmbeddedScrollCompensation(page.collapseCompensation) + } + + private func updateEmbeddedScrollCompensation(_ compensation: CGFloat) { + guard let scrollView = firstScrollView(in: host.view) else { return } + scrollView.contentInset.top = compensation + scrollView.verticalScrollIndicatorInsets.top = compensation + } + + private func firstScrollView(in view: UIView) -> UIScrollView? { + if let scrollView = view as? UIScrollView { + return scrollView + } + for subview in view.subviews { + if let scrollView = firstScrollView(in: subview) { + return scrollView + } + } + return nil } } From bba13bc619bb215fb17b72e31c150dd6f1b64220 Mon Sep 17 00:00:00 2001 From: pgs666 <74764545+pgs666@users.noreply.github.com> Date: Fri, 5 Jun 2026 07:50:36 +0800 Subject: [PATCH 073/216] Preserve comment offset during inset compensation --- iosApp/VideoDetailView.swift | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/iosApp/VideoDetailView.swift b/iosApp/VideoDetailView.swift index b7206b1b..a2ae02e6 100644 --- a/iosApp/VideoDetailView.swift +++ b/iosApp/VideoDetailView.swift @@ -964,6 +964,7 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle private final class VideoDetailHostedTabPageViewController: UIViewController { private let host = UIHostingController(rootView: AnyView(EmptyView())) private var contentUpdateRevision: Int? + private var embeddedScrollTopInset: CGFloat = 0 override func viewDidLoad() { super.viewDidLoad() @@ -994,8 +995,12 @@ private final class VideoDetailHostedTabPageViewController: UIViewController { private func updateEmbeddedScrollCompensation(_ compensation: CGFloat) { guard let scrollView = firstScrollView(in: host.view) else { return } + guard abs(embeddedScrollTopInset - compensation) > 0.5 else { return } + let previousOffset = scrollView.contentOffset + embeddedScrollTopInset = compensation scrollView.contentInset.top = compensation scrollView.verticalScrollIndicatorInsets.top = compensation + scrollView.setContentOffset(previousOffset, animated: false) } private func firstScrollView(in view: UIView) -> UIScrollView? { From 2ffe51cc19ed31c599ab11b92bc173f0eb55de41 Mon Sep 17 00:00:00 2001 From: pgs666 <74764545+pgs666@users.noreply.github.com> Date: Fri, 5 Jun 2026 08:01:23 +0800 Subject: [PATCH 074/216] Coordinate comment scroll collapse in table view --- iosApp/CommentView.swift | 42 +++++++++++++++++++++++++++++++++++- iosApp/VideoDetailView.swift | 24 --------------------- 2 files changed, 41 insertions(+), 25 deletions(-) diff --git a/iosApp/CommentView.swift b/iosApp/CommentView.swift index 3d5f63f3..c7b37254 100644 --- a/iosApp/CommentView.swift +++ b/iosApp/CommentView.swift @@ -163,6 +163,7 @@ private struct CommentTableView: UIViewRepresentable { tableView.showsVerticalScrollIndicator = false tableView.contentInsetAdjustmentBehavior = .never tableView.keyboardDismissMode = .interactive + tableView.alwaysBounceVertical = true tableView.estimatedRowHeight = 120 tableView.rowHeight = UITableView.automaticDimension tableView.dataSource = context.coordinator @@ -174,6 +175,7 @@ private struct CommentTableView: UIViewRepresentable { func updateUIView(_ tableView: UITableView, context: Context) { context.coordinator.parent = self context.coordinator.rows = rows + context.coordinator.syncCollapseDistance(with: self, tableView: tableView) tableView.contentInset.bottom = contentBottomPadding + collapseDistance + 1 tableView.verticalScrollIndicatorInsets.bottom = contentBottomPadding tableView.reloadData() @@ -211,11 +213,22 @@ private struct CommentTableView: UIViewRepresentable { var parent: CommentTableView var rows: [Row] = [] + private var logicalContentOffsetY: CGFloat = 0 + private var appliedContentOffsetY: CGFloat = 0 + private var isApplyingContentOffset = false init(parent: CommentTableView) { self.parent = parent } + func syncCollapseDistance(with parent: CommentTableView, tableView: UITableView) { + let physicalOffsetY = max(0, tableView.contentOffset.y) + if logicalContentOffsetY <= 0, physicalOffsetY > 0 { + logicalContentOffsetY = physicalOffsetY + max(parent.collapseDistance, 0) + } + applyPhysicalOffsetIfNeeded(to: tableView) + } + func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { rows.count } @@ -232,7 +245,34 @@ private struct CommentTableView: UIViewRepresentable { } func scrollViewDidScroll(_ scrollView: UIScrollView) { - parent.onScrollOffsetChange(max(0, scrollView.contentOffset.y)) + guard !isApplyingContentOffset else { return } + + let observedContentOffsetY = scrollView.contentOffset.y + let deltaY = observedContentOffsetY - appliedContentOffsetY + guard abs(deltaY) > 0.01 else { + parent.onScrollOffsetChange(logicalContentOffsetY) + return + } + + logicalContentOffsetY = max(0, logicalContentOffsetY + deltaY) + parent.onScrollOffsetChange(logicalContentOffsetY) + applyPhysicalOffsetIfNeeded(to: scrollView) + } + + private func applyPhysicalOffsetIfNeeded(to scrollView: UIScrollView) { + let targetContentOffsetY = max(0, logicalContentOffsetY - max(parent.collapseDistance, 0)) + guard abs(scrollView.contentOffset.y - targetContentOffsetY) > 0.5 else { + appliedContentOffsetY = scrollView.contentOffset.y + return + } + + isApplyingContentOffset = true + scrollView.setContentOffset( + CGPoint(x: scrollView.contentOffset.x, y: targetContentOffsetY), + animated: false + ) + isApplyingContentOffset = false + appliedContentOffsetY = targetContentOffsetY } @ViewBuilder diff --git a/iosApp/VideoDetailView.swift b/iosApp/VideoDetailView.swift index a2ae02e6..7b964024 100644 --- a/iosApp/VideoDetailView.swift +++ b/iosApp/VideoDetailView.swift @@ -964,7 +964,6 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle private final class VideoDetailHostedTabPageViewController: UIViewController { private let host = UIHostingController(rootView: AnyView(EmptyView())) private var contentUpdateRevision: Int? - private var embeddedScrollTopInset: CGFloat = 0 override func viewDidLoad() { super.viewDidLoad() @@ -990,29 +989,6 @@ private final class VideoDetailHostedTabPageViewController: UIViewController { contentUpdateRevision = page.contentUpdateRevision host.rootView = page.content() } - updateEmbeddedScrollCompensation(page.collapseCompensation) - } - - private func updateEmbeddedScrollCompensation(_ compensation: CGFloat) { - guard let scrollView = firstScrollView(in: host.view) else { return } - guard abs(embeddedScrollTopInset - compensation) > 0.5 else { return } - let previousOffset = scrollView.contentOffset - embeddedScrollTopInset = compensation - scrollView.contentInset.top = compensation - scrollView.verticalScrollIndicatorInsets.top = compensation - scrollView.setContentOffset(previousOffset, animated: false) - } - - private func firstScrollView(in view: UIView) -> UIScrollView? { - if let scrollView = view as? UIScrollView { - return scrollView - } - for subview in view.subviews { - if let scrollView = firstScrollView(in: subview) { - return scrollView - } - } - return nil } } From e70df43c86877e05b90c6b305f4e18a815c489fa Mon Sep 17 00:00:00 2001 From: pgs666 <74764545+pgs666@users.noreply.github.com> Date: Fri, 5 Jun 2026 08:25:58 +0800 Subject: [PATCH 075/216] Freeze comment offset while player collapses --- iosApp/CommentView.swift | 55 +++++++++++++++++++++--------------- iosApp/VideoDetailView.swift | 25 ++++++++++++++++ 2 files changed, 58 insertions(+), 22 deletions(-) diff --git a/iosApp/CommentView.swift b/iosApp/CommentView.swift index c7b37254..bd570e73 100644 --- a/iosApp/CommentView.swift +++ b/iosApp/CommentView.swift @@ -2,11 +2,16 @@ import SwiftUI import UIKit import Han1meShared +final class VideoDetailCommentTableView: UITableView { + var playerCollapseOffset: CGFloat = 0 +} + struct CommentView: View { @ObservedObject private var viewModel: CommentViewModel private let onOverlayActivityChanged: (Bool) -> Void private let contentBottomPadding: CGFloat private let collapseDistance: CGFloat + private let collapseOffset: CGFloat private let onScrollOffsetChange: (CGFloat) -> Void @State private var replyTarget: CommentRow? @State private var replyText = "" @@ -17,12 +22,14 @@ struct CommentView: View { viewModel: CommentViewModel, contentBottomPadding: CGFloat = 24, collapseDistance: CGFloat = 0, + collapseOffset: CGFloat = 0, onScrollOffsetChange: @escaping (CGFloat) -> Void = { _ in }, onOverlayActivityChanged: @escaping (Bool) -> Void = { _ in } ) { _viewModel = ObservedObject(wrappedValue: viewModel) self.contentBottomPadding = contentBottomPadding self.collapseDistance = collapseDistance + self.collapseOffset = collapseOffset self.onScrollOffsetChange = onScrollOffsetChange self.onOverlayActivityChanged = onOverlayActivityChanged } @@ -35,6 +42,7 @@ struct CommentView: View { runningActionIDs: viewModel.runningActionIDs, contentBottomPadding: contentBottomPadding, collapseDistance: collapseDistance, + collapseOffset: collapseOffset, onSortModeChange: { viewModel.changeSortMode($0) }, onRefresh: { viewModel.load() }, onRetry: { viewModel.load() }, @@ -142,6 +150,7 @@ private struct CommentTableView: UIViewRepresentable { let runningActionIDs: Set let contentBottomPadding: CGFloat let collapseDistance: CGFloat + let collapseOffset: CGFloat let onSortModeChange: (CommentViewModel.SortMode) -> Void let onRefresh: () -> Void let onRetry: () -> Void @@ -157,7 +166,7 @@ private struct CommentTableView: UIViewRepresentable { } func makeUIView(context: Context) -> UITableView { - let tableView = UITableView(frame: .zero, style: .plain) + let tableView = VideoDetailCommentTableView(frame: .zero, style: .plain) tableView.backgroundColor = .clear tableView.separatorStyle = .none tableView.showsVerticalScrollIndicator = false @@ -213,7 +222,7 @@ private struct CommentTableView: UIViewRepresentable { var parent: CommentTableView var rows: [Row] = [] - private var logicalContentOffsetY: CGFloat = 0 + private var reportedContentOffsetY: CGFloat = 0 private var appliedContentOffsetY: CGFloat = 0 private var isApplyingContentOffset = false @@ -223,10 +232,10 @@ private struct CommentTableView: UIViewRepresentable { func syncCollapseDistance(with parent: CommentTableView, tableView: UITableView) { let physicalOffsetY = max(0, tableView.contentOffset.y) - if logicalContentOffsetY <= 0, physicalOffsetY > 0 { - logicalContentOffsetY = physicalOffsetY + max(parent.collapseDistance, 0) + if reportedContentOffsetY <= 0, physicalOffsetY > 0 { + reportedContentOffsetY = physicalOffsetY + max(parent.collapseOffset, 0) } - applyPhysicalOffsetIfNeeded(to: tableView) + appliedContentOffsetY = tableView.contentOffset.y } func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { @@ -250,28 +259,30 @@ private struct CommentTableView: UIViewRepresentable { let observedContentOffsetY = scrollView.contentOffset.y let deltaY = observedContentOffsetY - appliedContentOffsetY guard abs(deltaY) > 0.01 else { - parent.onScrollOffsetChange(logicalContentOffsetY) + parent.onScrollOffsetChange(reportedContentOffsetY) return } - logicalContentOffsetY = max(0, logicalContentOffsetY + deltaY) - parent.onScrollOffsetChange(logicalContentOffsetY) - applyPhysicalOffsetIfNeeded(to: scrollView) - } - - private func applyPhysicalOffsetIfNeeded(to scrollView: UIScrollView) { - let targetContentOffsetY = max(0, logicalContentOffsetY - max(parent.collapseDistance, 0)) - guard abs(scrollView.contentOffset.y - targetContentOffsetY) > 0.5 else { - appliedContentOffsetY = scrollView.contentOffset.y - return + var targetContentOffsetY = observedContentOffsetY + let currentCollapseOffset = (scrollView as? VideoDetailCommentTableView)?.playerCollapseOffset + ?? parent.collapseOffset + let remainingCollapseDistance = max(parent.collapseDistance - currentCollapseOffset, 0) + if deltaY > 0, remainingCollapseDistance > 0.5 { + let consumedByCollapse = min(deltaY, remainingCollapseDistance) + targetContentOffsetY = appliedContentOffsetY + deltaY - consumedByCollapse } - isApplyingContentOffset = true - scrollView.setContentOffset( - CGPoint(x: scrollView.contentOffset.x, y: targetContentOffsetY), - animated: false - ) - isApplyingContentOffset = false + reportedContentOffsetY = max(0, reportedContentOffsetY + deltaY) + parent.onScrollOffsetChange(reportedContentOffsetY) + + if abs(scrollView.contentOffset.y - targetContentOffsetY) > 0.5 { + isApplyingContentOffset = true + scrollView.setContentOffset( + CGPoint(x: scrollView.contentOffset.x, y: targetContentOffsetY), + animated: false + ) + isApplyingContentOffset = false + } appliedContentOffsetY = targetContentOffsetY } diff --git a/iosApp/VideoDetailView.swift b/iosApp/VideoDetailView.swift index 7b964024..c257b088 100644 --- a/iosApp/VideoDetailView.swift +++ b/iosApp/VideoDetailView.swift @@ -456,6 +456,10 @@ struct VideoDetailView: View { viewModel: commentViewModel, contentBottomPadding: composerContentClearance, collapseDistance: collapseDistance, + collapseOffset: tabCollapseCompensation( + for: .comments, + collapseCompensation: collapseCompensation + ), onScrollOffsetChange: { offset in updateTabOffset(.comments, offset: offset, collapseDistance: collapseDistance) }, @@ -964,6 +968,7 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle private final class VideoDetailHostedTabPageViewController: UIViewController { private let host = UIHostingController(rootView: AnyView(EmptyView())) private var contentUpdateRevision: Int? + private weak var commentTableView: VideoDetailCommentTableView? override func viewDidLoad() { super.viewDidLoad() @@ -989,6 +994,26 @@ private final class VideoDetailHostedTabPageViewController: UIViewController { contentUpdateRevision = page.contentUpdateRevision host.rootView = page.content() } + updateCommentTableCollapseOffset(page.collapseCompensation) + } + + private func updateCommentTableCollapseOffset(_ collapseOffset: CGFloat) { + let tableView = commentTableView ?? firstCommentTableView(in: host.view) + guard let tableView else { return } + commentTableView = tableView + tableView.playerCollapseOffset = collapseOffset + } + + private func firstCommentTableView(in view: UIView) -> VideoDetailCommentTableView? { + if let tableView = view as? VideoDetailCommentTableView { + return tableView + } + for subview in view.subviews { + if let tableView = firstCommentTableView(in: subview) { + return tableView + } + } + return nil } } From de575894f2d5402af4141710b35cbed95aa6274d Mon Sep 17 00:00:00 2001 From: pgs666 <74764545+pgs666@users.noreply.github.com> Date: Fri, 5 Jun 2026 08:33:30 +0800 Subject: [PATCH 076/216] Coordinate comment scroll expansion --- iosApp/CommentView.swift | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/iosApp/CommentView.swift b/iosApp/CommentView.swift index bd570e73..da6f71b3 100644 --- a/iosApp/CommentView.swift +++ b/iosApp/CommentView.swift @@ -172,7 +172,8 @@ private struct CommentTableView: UIViewRepresentable { tableView.showsVerticalScrollIndicator = false tableView.contentInsetAdjustmentBehavior = .never tableView.keyboardDismissMode = .interactive - tableView.alwaysBounceVertical = true + tableView.bounces = false + tableView.alwaysBounceVertical = false tableView.estimatedRowHeight = 120 tableView.rowHeight = UITableView.automaticDimension tableView.dataSource = context.coordinator @@ -270,6 +271,9 @@ private struct CommentTableView: UIViewRepresentable { if deltaY > 0, remainingCollapseDistance > 0.5 { let consumedByCollapse = min(deltaY, remainingCollapseDistance) targetContentOffsetY = appliedContentOffsetY + deltaY - consumedByCollapse + } else if deltaY < 0, currentCollapseOffset > 0.5 { + let consumedByExpansion = max(deltaY, -currentCollapseOffset) + targetContentOffsetY = appliedContentOffsetY + deltaY - consumedByExpansion } reportedContentOffsetY = max(0, reportedContentOffsetY + deltaY) From 0b083f53b76e7f11ce76cd45ef01ac4af453ddfe Mon Sep 17 00:00:00 2001 From: pgs666 <74764545+pgs666@users.noreply.github.com> Date: Fri, 5 Jun 2026 08:34:44 +0800 Subject: [PATCH 077/216] Let horizontal video lists own their pan --- iosApp/VideoDetailView.swift | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/iosApp/VideoDetailView.swift b/iosApp/VideoDetailView.swift index c257b088..6033ab5f 100644 --- a/iosApp/VideoDetailView.swift +++ b/iosApp/VideoDetailView.swift @@ -1089,6 +1089,9 @@ private struct VideoDetailTabPager: UIViewControllerRepresentable { ) -> Bool { let startLocation = panGestureRecognizer.location(in: view) guard startLocation.x > 24 else { return false } + guard !view.hasScrollableHorizontalDescendant(at: startLocation, excluding: view) else { + return false + } let globalStartLocation = view.convert(startLocation, to: nil) guard !excludedDragStartFrames.contains(where: { $0.contains(globalStartLocation) }) else { return false @@ -1244,6 +1247,22 @@ private struct VideoDetailTabPager: UIViewControllerRepresentable { } } +private extension UIView { + func hasScrollableHorizontalDescendant(at location: CGPoint, excluding excludedView: UIView) -> Bool { + guard let hitView = hitTest(location, with: nil) else { return false } + var current: UIView? = hitView + while let view = current, view !== excludedView { + if let scrollView = view as? UIScrollView, + scrollView.isScrollEnabled, + scrollView.contentSize.width > scrollView.bounds.width + 1 { + return true + } + current = view.superview + } + return false + } +} + private struct HorizontalPagerExclusionFramePreferenceKey: PreferenceKey { static var defaultValue: [CGRect] = [] static func reduce(value: inout [CGRect], nextValue: () -> [CGRect]) { From b8cb17e59efbfe3f1d398b9a465cf2014166ed23 Mon Sep 17 00:00:00 2001 From: pgs666 <74764545+pgs666@users.noreply.github.com> Date: Fri, 5 Jun 2026 08:49:27 +0800 Subject: [PATCH 078/216] Detect nested horizontal scroll regions recursively --- iosApp/VideoDetailView.swift | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/iosApp/VideoDetailView.swift b/iosApp/VideoDetailView.swift index 6033ab5f..070381bd 100644 --- a/iosApp/VideoDetailView.swift +++ b/iosApp/VideoDetailView.swift @@ -1249,15 +1249,18 @@ private struct VideoDetailTabPager: UIViewControllerRepresentable { private extension UIView { func hasScrollableHorizontalDescendant(at location: CGPoint, excluding excludedView: UIView) -> Bool { - guard let hitView = hitTest(location, with: nil) else { return false } - var current: UIView? = hitView - while let view = current, view !== excludedView { - if let scrollView = view as? UIScrollView, + for subview in subviews.reversed() where subview !== excludedView && !subview.isHidden && subview.alpha > 0.01 { + let localLocation = subview.convert(location, from: self) + guard subview.point(inside: localLocation, with: nil) else { continue } + if let scrollView = subview as? UIScrollView, scrollView.isScrollEnabled, + scrollView.panGestureRecognizer.isEnabled, scrollView.contentSize.width > scrollView.bounds.width + 1 { return true } - current = view.superview + if subview.hasScrollableHorizontalDescendant(at: localLocation, excluding: excludedView) { + return true + } } return false } From 76f2c6efbff15e2ad1b7206c6c485028bc6b95c2 Mon Sep 17 00:00:00 2001 From: pgs666 <74764545+pgs666@users.noreply.github.com> Date: Fri, 5 Jun 2026 08:56:03 +0800 Subject: [PATCH 079/216] Stabilize related video card title height --- iosApp/RelatedVideoComponents.swift | 13 +++---------- 1 file changed, 3 insertions(+), 10 deletions(-) diff --git a/iosApp/RelatedVideoComponents.swift b/iosApp/RelatedVideoComponents.swift index 126f33d8..3f2a0075 100644 --- a/iosApp/RelatedVideoComponents.swift +++ b/iosApp/RelatedVideoComponents.swift @@ -313,18 +313,11 @@ struct RelatedVideoCard: View { } .clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous)) - Group { - if #available(iOS 17.0, *) { - Text(video.title) - .lineLimit(2, reservesSpace: true) - } else { - Text(video.title) - .lineLimit(2) - } - } + Text(video.title) + .lineLimit(2) .font(.subheadline.weight(.semibold)) .foregroundStyle(.primary) - .frame(maxWidth: .infinity, alignment: .leading) + .frame(maxWidth: .infinity, minHeight: 40, alignment: .topLeading) if showsMetadataFooter { HStack(spacing: 6) { From 86ae67c390eb3994ccdd0e801a72d7833e904a5a Mon Sep 17 00:00:00 2001 From: pgs666 <74764545+pgs666@users.noreply.github.com> Date: Fri, 5 Jun 2026 09:04:02 +0800 Subject: [PATCH 080/216] Let nested horizontal lists bypass vertical scroll pan --- iosApp/VideoDetailView.swift | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/iosApp/VideoDetailView.swift b/iosApp/VideoDetailView.swift index 070381bd..b61b9bfa 100644 --- a/iosApp/VideoDetailView.swift +++ b/iosApp/VideoDetailView.swift @@ -857,7 +857,7 @@ private final class VideoDetailVerticalScrollPageCoordinator: NSObject, UIScroll private final class VideoDetailVerticalScrollPageViewController: UIViewController { private let coordinator: VideoDetailVerticalScrollPageCoordinator - private let scrollView = UIScrollView() + private let scrollView = VerticalScrollView() private let contentView = UIView() private let bottomPaddingView = UIView() private let collapseSpacerView = UIView() @@ -893,6 +893,12 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle scrollView.contentInsetAdjustmentBehavior = .never scrollView.keyboardDismissMode = .interactive scrollView.delegate = coordinator + scrollView.shouldBeginVerticalPan = { panGestureRecognizer, view in + let velocity = panGestureRecognizer.velocity(in: view) + guard abs(velocity.x) > abs(velocity.y) * 1.05 else { return true } + let startLocation = panGestureRecognizer.location(in: view) + return !view.hasScrollableHorizontalDescendant(at: startLocation, excluding: view) + } view.addSubview(scrollView) contentView.translatesAutoresizingMaskIntoConstraints = false @@ -965,6 +971,18 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle } } +private final class VerticalScrollView: UIScrollView { + var shouldBeginVerticalPan: ((UIPanGestureRecognizer, UIView) -> Bool)? + + override func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool { + if let panGestureRecognizer = gestureRecognizer as? UIPanGestureRecognizer, + panGestureRecognizer === self.panGestureRecognizer { + return shouldBeginVerticalPan?(panGestureRecognizer, self) ?? true + } + return super.gestureRecognizerShouldBegin(gestureRecognizer) + } +} + private final class VideoDetailHostedTabPageViewController: UIViewController { private let host = UIHostingController(rootView: AnyView(EmptyView())) private var contentUpdateRevision: Int? From 7252521799f34129bbd7f521763d74fd35c30f9f Mon Sep 17 00:00:00 2001 From: pgs666 <74764545+pgs666@users.noreply.github.com> Date: Fri, 5 Jun 2026 09:24:35 +0800 Subject: [PATCH 081/216] Scroll detail tabs over fixed player --- iosApp/VideoDetailView.swift | 46 ++++++++++-------------------------- 1 file changed, 13 insertions(+), 33 deletions(-) diff --git a/iosApp/VideoDetailView.swift b/iosApp/VideoDetailView.swift index b61b9bfa..7a871bcc 100644 --- a/iosApp/VideoDetailView.swift +++ b/iosApp/VideoDetailView.swift @@ -265,12 +265,11 @@ struct VideoDetailView: View { panelWidth: leftWidth, parentHeight: proxy.size.height ) - let currentPlayerShrink = playerVisualShrink(panelWidth: leftWidth) - let currentBottomScrollHeight = proxy.size.height - ( - isPlayerCollapsed || isPlayerPlaying - ? currentPlayerHeight - : playerMinimumHeight(panelWidth: leftWidth) - ) + let currentPlayerScrollAway = playerVisualShrink(panelWidth: leftWidth) + let currentBottomScrollOffset = isPlayerCollapsed + ? currentPlayerHeight + : max(currentPlayerHeight - currentPlayerScrollAway, 0) + let currentBottomScrollHeight = proxy.size.height - currentBottomScrollOffset playerArea(snapshot: snapshot) .frame( width: leftWidth, @@ -284,14 +283,14 @@ struct VideoDetailView: View { belowPlayerScroll( snapshot: snapshot, showsRelated: !isWide, - collapseDistance: isPlayerCollapsed || isPlayerPlaying ? 0 : currentPlayerCollapseDistance, - collapseCompensation: isPlayerCollapsed || isPlayerPlaying ? 0 : currentPlayerShrink, + collapseDistance: isPlayerCollapsed ? 0 : currentPlayerCollapseDistance, + collapseCompensation: isPlayerCollapsed ? 0 : currentPlayerScrollAway, composerContentClearance: commentComposerContentClearance( safeAreaBottom: proxy.safeAreaInsets.bottom ) ) .frame(height: max(0, currentBottomScrollHeight)) - .offset(y: currentPlayerHeight) + .offset(y: currentBottomScrollOffset) } } .frame(width: leftWidth, height: proxy.size.height, alignment: .top) @@ -331,28 +330,16 @@ struct VideoDetailView: View { /// Player 高度: /// - 全屏:撑满整个父容器 - /// - 折叠:50pt 标题 strip - /// - inline:左 panel 宽度的 16:9(不再依赖父容器 height) + /// - 手动折叠:50pt 标题 strip + /// - inline:固定为左 panel 宽度的 16:9,不随底部内容滚动缩小 private func playerHeight(panelWidth: CGFloat, parentHeight: CGFloat) -> CGFloat { if isPlayerFullscreen { return parentHeight } if isPlayerCollapsed { return 50 } - let baseHeight = panelWidth * 9 / 16 - // While playing, lock to full 16:9 — never shrink with scroll. - if isPlayerPlaying { return baseHeight } - // Paused: follow the user's scroll. As bottomScrollOffset grows - // (content scrolled up), the player shrinks proportionally, never - // below playerCollapsedFollowMinHeight so its overlay controls - // remain at least partly visible. - return baseHeight - playerVisualShrink(panelWidth: panelWidth) + return panelWidth * 9 / 16 } private func playerCollapseDistance(panelWidth: CGFloat) -> CGFloat { - let baseHeight = panelWidth * 9 / 16 - return max(baseHeight - playerMinimumHeight(panelWidth: panelWidth), 1) - } - - private func playerMinimumHeight(panelWidth: CGFloat) -> CGFloat { - max((panelWidth * 9 / 16) * 0.32, 80) + max(panelWidth * 9 / 16, 1) } private func playerVisualShrink(panelWidth: CGFloat) -> CGFloat { @@ -360,13 +347,6 @@ struct VideoDetailView: View { } private func playerArea(snapshot: VideoDetailScreenSnapshot) -> some View { - // Shrunken iff the follow-finger collapse has actually engaged - // (paused, not fullscreen / strip-collapsed, and the user has - // scrolled the bottom content up). - let shrunken = !isPlayerFullscreen - && !isPlayerCollapsed - && !isPlayerPlaying - && bottomScrollOffset > 1 return KSPlayerView( snapshot: snapshot, isFullscreen: $isPlayerFullscreen, @@ -379,7 +359,7 @@ struct VideoDetailView: View { } }, onBack: { dismiss() }, - isShrunken: shrunken, + isShrunken: false, onRequestExpand: { // First tap on a shrunken player expands it back to 16:9 // by zeroing the scroll-driven shrink amount — and animate From df4997534575d1c7cc7d3f7d7a7cc69b9dd86c58 Mon Sep 17 00:00:00 2001 From: pgs666 <74764545+pgs666@users.noreply.github.com> Date: Fri, 5 Jun 2026 09:28:28 +0800 Subject: [PATCH 082/216] Scroll detail panel over fixed player --- iosApp/VideoDetailView.swift | 55 +++++++++++++++++++++++++++++++++--- 1 file changed, 51 insertions(+), 4 deletions(-) diff --git a/iosApp/VideoDetailView.swift b/iosApp/VideoDetailView.swift index 7a871bcc..23a24a91 100644 --- a/iosApp/VideoDetailView.swift +++ b/iosApp/VideoDetailView.swift @@ -8,6 +8,7 @@ struct VideoDetailView: View { private let commentFeature: CommentFeature private let tabletLeftMinimumWidth: CGFloat = 620 private let tabletSidebarMinimumWidth: CGFloat = 360 + private let playerContinuationStripHeight: CGFloat = 56 @StateObject private var viewModel: VideoDetailViewModel @StateObject private var commentViewModel: CommentViewModel @State private var selectedTab = VideoPageTab.introduction @@ -270,6 +271,10 @@ struct VideoDetailView: View { ? currentPlayerHeight : max(currentPlayerHeight - currentPlayerScrollAway, 0) let currentBottomScrollHeight = proxy.size.height - currentBottomScrollOffset + let currentContinuationProgress = continuationStripProgress( + scrollAway: currentPlayerScrollAway, + panelWidth: leftWidth + ) playerArea(snapshot: snapshot) .frame( width: leftWidth, @@ -292,6 +297,13 @@ struct VideoDetailView: View { .frame(height: max(0, currentBottomScrollHeight)) .offset(y: currentBottomScrollOffset) } + + if !isPlayerFullscreen, + !isPlayerCollapsed, + currentContinuationProgress > 0 { + continuePlayingStrip(snapshot: snapshot, progress: currentContinuationProgress) + .frame(width: leftWidth, height: playerContinuationStripHeight) + } } .frame(width: leftWidth, height: proxy.size.height, alignment: .top) .clipped() @@ -339,13 +351,20 @@ struct VideoDetailView: View { } private func playerCollapseDistance(panelWidth: CGFloat) -> CGFloat { - max(panelWidth * 9 / 16, 1) + max(panelWidth * 9 / 16 - playerContinuationStripHeight, 1) } private func playerVisualShrink(panelWidth: CGFloat) -> CGFloat { min(max(bottomScrollOffset, 0), playerCollapseDistance(panelWidth: panelWidth)) } + private func continuationStripProgress(scrollAway: CGFloat, panelWidth: CGFloat) -> CGFloat { + let collapseDistance = playerCollapseDistance(panelWidth: panelWidth) + let fadeDistance: CGFloat = 72 + guard collapseDistance > 1 else { return 0 } + return min(max((scrollAway - (collapseDistance - fadeDistance)) / fadeDistance, 0), 1) + } + private func playerArea(snapshot: VideoDetailScreenSnapshot) -> some View { return KSPlayerView( snapshot: snapshot, @@ -361,9 +380,9 @@ struct VideoDetailView: View { onBack: { dismiss() }, isShrunken: false, onRequestExpand: { - // First tap on a shrunken player expands it back to 16:9 - // by zeroing the scroll-driven shrink amount — and animate - // it so the player smoothly grows. + // Reveal the full player when an older collapse state asks + // for expansion. The player itself no longer shrinks; the + // lower detail panel scrolls over it instead. withAnimation(.easeInOut(duration: 0.25)) { bottomScrollOffset = 0 } @@ -374,6 +393,34 @@ struct VideoDetailView: View { ) } + private func continuePlayingStrip(snapshot: VideoDetailScreenSnapshot, progress: CGFloat) -> some View { + Button { + withAnimation(.easeInOut(duration: 0.25)) { + bottomScrollOffset = 0 + } + } label: { + HStack(spacing: 10) { + Image(systemName: "play.circle.fill") + .font(.title3) + Text("继续播放") + .font(.subheadline.weight(.semibold)) + Text(snapshot.title) + .font(.caption) + .lineLimit(1) + .foregroundStyle(.secondary) + Spacer(minLength: 0) + } + .foregroundStyle(.primary) + .padding(.horizontal, 16) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(.ultraThinMaterial) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .opacity(progress) + .offset(y: -8 * (1 - progress)) + } + private func belowPlayerScroll( snapshot: VideoDetailScreenSnapshot, showsRelated: Bool, From 8785e0e7402809925e78936f74fb0b1b2c984344 Mon Sep 17 00:00:00 2001 From: pgs666 <74764545+pgs666@users.noreply.github.com> Date: Fri, 5 Jun 2026 09:42:49 +0800 Subject: [PATCH 083/216] Clean comment panel scroll handling --- iosApp/CommentView.swift | 116 ++++++++++++++++++++--------------- iosApp/VideoDetailView.swift | 15 +---- 2 files changed, 71 insertions(+), 60 deletions(-) diff --git a/iosApp/CommentView.swift b/iosApp/CommentView.swift index da6f71b3..8915a866 100644 --- a/iosApp/CommentView.swift +++ b/iosApp/CommentView.swift @@ -3,7 +3,24 @@ import UIKit import Han1meShared final class VideoDetailCommentTableView: UITableView { - var playerCollapseOffset: CGFloat = 0 + var playerCollapseOffset: CGFloat = 0 { + didSet { + guard abs(playerCollapseOffset - oldValue) > 0.1 else { return } + applyPlayerCollapseCompensation() + } + } + + override func layoutSubviews() { + super.layoutSubviews() + applyPlayerCollapseCompensation() + } + + func applyPlayerCollapseCompensation() { + let transform = CGAffineTransform(translationX: 0, y: max(playerCollapseOffset, 0)) + visibleCells.forEach { cell in + cell.transform = transform + } + } } struct CommentView: View { @@ -167,13 +184,13 @@ private struct CommentTableView: UIViewRepresentable { func makeUIView(context: Context) -> UITableView { let tableView = VideoDetailCommentTableView(frame: .zero, style: .plain) - tableView.backgroundColor = .clear + tableView.backgroundColor = .systemGroupedBackground tableView.separatorStyle = .none tableView.showsVerticalScrollIndicator = false tableView.contentInsetAdjustmentBehavior = .never tableView.keyboardDismissMode = .interactive - tableView.bounces = false - tableView.alwaysBounceVertical = false + tableView.bounces = true + tableView.alwaysBounceVertical = true tableView.estimatedRowHeight = 120 tableView.rowHeight = UITableView.automaticDimension tableView.dataSource = context.coordinator @@ -184,11 +201,11 @@ private struct CommentTableView: UIViewRepresentable { func updateUIView(_ tableView: UITableView, context: Context) { context.coordinator.parent = self - context.coordinator.rows = rows - context.coordinator.syncCollapseDistance(with: self, tableView: tableView) + tableView.playerCollapseOffset = collapseOffset tableView.contentInset.bottom = contentBottomPadding + collapseDistance + 1 tableView.verticalScrollIndicatorInsets.bottom = contentBottomPadding - tableView.reloadData() + context.coordinator.reloadIfNeeded(on: tableView, rows: rows, reloadKey: reloadKey) + tableView.applyPlayerCollapseCompensation() } private var rows: [Row] { @@ -209,6 +226,36 @@ private struct CommentTableView: UIViewRepresentable { return rows } + private var reloadKey: [String] { + var key = ["sort:\(sortMode.rawValue)", "running:\(runningActionIDs.sorted().joined(separator: ","))"] + switch state { + case .idle: + key.append("state:idle") + case .loading: + key.append("state:loading") + case .failed(let message): + key.append("state:failed:\(message)") + case .loaded: + key.append("state:loaded") + } + key.append(contentsOf: sortedComments.map { comment in + [ + "id=\(comment.id)", + "avatar=\(comment.avatarUrl)", + "user=\(comment.username)", + "date=\(comment.date)", + "content=\(comment.content)", + "thumb=\(comment.thumbUp ?? -1)", + "child=\(comment.isChildComment)", + "more=\(comment.hasMoreReplies)", + "replies=\(comment.replyCount ?? -1)", + "liked=\(comment.likeCommentStatus)", + "disliked=\(comment.unlikeCommentStatus)" + ].joined(separator: "|") + }) + return key + } + enum Row { case header case loading @@ -223,20 +270,17 @@ private struct CommentTableView: UIViewRepresentable { var parent: CommentTableView var rows: [Row] = [] - private var reportedContentOffsetY: CGFloat = 0 - private var appliedContentOffsetY: CGFloat = 0 - private var isApplyingContentOffset = false + private var reloadKey: [String] = [] init(parent: CommentTableView) { self.parent = parent } - func syncCollapseDistance(with parent: CommentTableView, tableView: UITableView) { - let physicalOffsetY = max(0, tableView.contentOffset.y) - if reportedContentOffsetY <= 0, physicalOffsetY > 0 { - reportedContentOffsetY = physicalOffsetY + max(parent.collapseOffset, 0) - } - appliedContentOffsetY = tableView.contentOffset.y + func reloadIfNeeded(on tableView: UITableView, rows: [Row], reloadKey: [String]) { + guard self.reloadKey != reloadKey else { return } + self.reloadKey = reloadKey + self.rows = rows + tableView.reloadData() } func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { @@ -255,39 +299,15 @@ private struct CommentTableView: UIViewRepresentable { } func scrollViewDidScroll(_ scrollView: UIScrollView) { - guard !isApplyingContentOffset else { return } - - let observedContentOffsetY = scrollView.contentOffset.y - let deltaY = observedContentOffsetY - appliedContentOffsetY - guard abs(deltaY) > 0.01 else { - parent.onScrollOffsetChange(reportedContentOffsetY) - return - } - - var targetContentOffsetY = observedContentOffsetY - let currentCollapseOffset = (scrollView as? VideoDetailCommentTableView)?.playerCollapseOffset - ?? parent.collapseOffset - let remainingCollapseDistance = max(parent.collapseDistance - currentCollapseOffset, 0) - if deltaY > 0, remainingCollapseDistance > 0.5 { - let consumedByCollapse = min(deltaY, remainingCollapseDistance) - targetContentOffsetY = appliedContentOffsetY + deltaY - consumedByCollapse - } else if deltaY < 0, currentCollapseOffset > 0.5 { - let consumedByExpansion = max(deltaY, -currentCollapseOffset) - targetContentOffsetY = appliedContentOffsetY + deltaY - consumedByExpansion - } - - reportedContentOffsetY = max(0, reportedContentOffsetY + deltaY) - parent.onScrollOffsetChange(reportedContentOffsetY) + parent.onScrollOffsetChange(max(0, scrollView.contentOffset.y)) + (scrollView as? VideoDetailCommentTableView)?.applyPlayerCollapseCompensation() + } - if abs(scrollView.contentOffset.y - targetContentOffsetY) > 0.5 { - isApplyingContentOffset = true - scrollView.setContentOffset( - CGPoint(x: scrollView.contentOffset.x, y: targetContentOffsetY), - animated: false - ) - isApplyingContentOffset = false - } - appliedContentOffsetY = targetContentOffsetY + func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) { + cell.transform = CGAffineTransform( + translationX: 0, + y: max((tableView as? VideoDetailCommentTableView)?.playerCollapseOffset ?? parent.collapseOffset, 0) + ) } @ViewBuilder diff --git a/iosApp/VideoDetailView.swift b/iosApp/VideoDetailView.swift index 23a24a91..a494da8c 100644 --- a/iosApp/VideoDetailView.swift +++ b/iosApp/VideoDetailView.swift @@ -15,11 +15,6 @@ struct VideoDetailView: View { @State private var isPlayerFullscreen = false @State private var isPlayerCollapsed = false @State private var horizontalPagerExclusionFrames: [CGRect] = [] - /// True iff the player is currently playing (not paused / buffering). - /// Driven from KSPlayerView via the @Binding below. Used to lock the - /// player at full 16:9 height while playing — only paused state lets the - /// scroll-driven shrink behaviour engage. - @State private var isPlayerPlaying = false /// Global collapse offset for the inline player, decoupled from any /// single tab's ScrollView offset. If one tab has already collapsed the /// player, switching to another tab must not snap it back open just @@ -372,11 +367,6 @@ struct VideoDetailView: View { isCollapsed: $isPlayerCollapsed, onProgress: { viewModel.recordPlaybackPosition(seconds: $0) }, onPlaybackEnded: { viewModel.recordPlaybackPosition(seconds: 0) }, - onPlayingChanged: { newValue in - if isPlayerPlaying != newValue { - isPlayerPlaying = newValue - } - }, onBack: { dismiss() }, isShrunken: false, onRequestExpand: { @@ -499,6 +489,7 @@ struct VideoDetailView: View { .frame(maxHeight: .infinity) } .frame(maxHeight: .infinity) + .background(Color(.systemGroupedBackground)) .onValueChange(of: selectedTab) { _ in lastSelectedTabChangeAt = Date() bottomScrollOffset = min(max(bottomScrollOffset, 0), collapseDistance) @@ -911,8 +902,8 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle scrollView.translatesAutoresizingMaskIntoConstraints = false scrollView.backgroundColor = .clear - scrollView.bounces = false - scrollView.alwaysBounceVertical = false + scrollView.bounces = true + scrollView.alwaysBounceVertical = true scrollView.alwaysBounceHorizontal = false scrollView.showsHorizontalScrollIndicator = false scrollView.showsVerticalScrollIndicator = false From fb5f505daf8b053cce4bb0204e7f5c032601f225 Mon Sep 17 00:00:00 2001 From: pgs666 <74764545+pgs666@users.noreply.github.com> Date: Fri, 5 Jun 2026 09:48:36 +0800 Subject: [PATCH 084/216] Remove manual player collapse --- iosApp/KSPlayerView.swift | 94 ++++++------------------------- iosApp/LocalVideoPlayerView.swift | 2 - iosApp/VideoDetailView.swift | 40 ++++++------- 3 files changed, 39 insertions(+), 97 deletions(-) diff --git a/iosApp/KSPlayerView.swift b/iosApp/KSPlayerView.swift index c561c9fc..215896f6 100644 --- a/iosApp/KSPlayerView.swift +++ b/iosApp/KSPlayerView.swift @@ -16,19 +16,17 @@ import AVFoundation /// 隔离**,会让外层整个 app 进入 dark mode。`KSVideoPlayerViewBuilder` 是 internal enum /// 也用不了。 /// -/// 通过 `@Binding isFullscreen` / `@Binding isCollapsed` 让外部容器(VideoDetailView) -/// 控制 player 形态。**关键**:始终在 SwiftUI view tree 同一位置,仅靠外层 frame 切换大小, +/// 通过 `@Binding isFullscreen` 让外部容器(VideoDetailView)控制 player 形态。 +/// **关键**:始终在 SwiftUI view tree 同一位置,仅靠外层 frame 切换大小, /// view identity 不变 → KSPlayerLayer 复用 → 视频不重新加载,进度不丢失。 @MainActor struct KSPlayerView: View { let snapshot: VideoDetailScreenSnapshot @Binding var isFullscreen: Bool - @Binding var isCollapsed: Bool let onProgress: (TimeInterval) -> Void let onPlaybackEnded: () -> Void /// Optional: invoked whenever the player's playing/paused state flips. - /// Used by the parent to decide whether to allow scroll-driven shrink - /// of the player area (only paused state shrinks). + /// Used by the parent for layout policy that depends on active playback. let onPlayingChanged: (Bool) -> Void /// Optional: invoked whenever the controls overlay shows / hides. Lets /// the parent slide the navigation bar in / out together with the @@ -39,17 +37,7 @@ struct KSPlayerView: View { /// removed (parent hides the navigation bar entirely), so this is the /// player's only way back. let onBack: () -> Void - /// True when the parent has shrunk the player below its 16:9 size via - /// the follow-finger collapse (paused + scrolled). When this is true, - /// a single tap on the video does NOT toggle the controls overlay — - /// it instead asks the parent to expand the player back to 16:9. This - /// is the user-requested workaround for the visible "video + controls - /// pulse" that occurred when the controls overlay materialised on - /// top of a shrunken player. - let isShrunken: Bool - /// Tap handler invoked when the user taps a shrunken player; parent is - /// expected to expand the player back to its full size. - let onRequestExpand: () -> Void + let playRequestToken: Int /// Optional: invoked the first time the underlying media reports a /// non-zero natural size. Lets the parent decide whether the video is /// landscape or portrait so it can pick the right fullscreen @@ -181,35 +169,29 @@ struct KSPlayerView: View { init( snapshot: VideoDetailScreenSnapshot, isFullscreen: Binding, - isCollapsed: Binding, onProgress: @escaping (TimeInterval) -> Void = { _ in }, onPlaybackEnded: @escaping () -> Void = {}, onPlayingChanged: @escaping (Bool) -> Void = { _ in }, onControlsVisibilityChanged: @escaping (Bool) -> Void = { _ in }, onBack: @escaping () -> Void = {}, - isShrunken: Bool = false, - onRequestExpand: @escaping () -> Void = {}, + playRequestToken: Int = 0, onNaturalSize: @escaping (CGSize) -> Void = { _ in } ) { self.snapshot = snapshot self._isFullscreen = isFullscreen - self._isCollapsed = isCollapsed self.onProgress = onProgress self.onPlaybackEnded = onPlaybackEnded self.onPlayingChanged = onPlayingChanged self.onControlsVisibilityChanged = onControlsVisibilityChanged self.onBack = onBack - self.isShrunken = isShrunken - self.onRequestExpand = onRequestExpand + self.playRequestToken = playRequestToken self.onNaturalSize = onNaturalSize } var body: some View { let _ = Self.configureKSPlayerGlobalsOnce Group { - if isCollapsed { - collapsedStrip - } else if let activeSource = activeSource, + if let activeSource = activeSource, let url = URL(string: activeSource.url) { playerWithControls(url: url) } else { @@ -218,6 +200,9 @@ struct KSPlayerView: View { } .background(Color.black) .clipped() + .onValueChange(of: playRequestToken) { _ in + play() + } } // MARK: - Player + controls @@ -358,16 +343,6 @@ struct KSPlayerView: View { } .onTapGesture(count: 1) { if isBoosted { endBoost() } - if isShrunken { - // Player is currently shrunk by scroll. First - // tap restores it to 16:9 instead of opening the - // controls — avoids the visible layout pulse - // that would otherwise happen when the controls - // overlay tries to materialise on top of a - // mid-collapse-animation player. - onRequestExpand() - return - } withAnimation(.easeInOut(duration: 0.18)) { showsControls.toggle() } AppLogger.log("gesture: tap controls=\(showsControls ? "show" : "hide")") onControlsVisibilityChanged(showsControls) @@ -498,18 +473,6 @@ struct KSPlayerView: View { coordinator.playerLayer?.pause() setPlayingState(false) } - .onValueChange(of: isShrunken) { newValue in - // The moment the parent reports the player has begun shrinking - // (paused user starts scrolling content up), hide the controls - // overlay. Otherwise the HUD would persist over a steadily - // shrinking player and look stuck / out-of-sync. - guard newValue, showsControls else { return } - withAnimation(.easeInOut(duration: 0.18)) { - showsControls = false - } - onControlsVisibilityChanged(false) - hideControlsTask?.cancel() - } .onValueChange(of: isPlayerDragGestureActive) { isActive in guard !isActive else { return } let shouldResumeAutoHide = hasMovedToSwipe || dragState != .none || isBoosted @@ -755,12 +718,6 @@ struct KSPlayerView: View { ) { coordinator.isMuted.toggle() } - // 收起按钮(暂停 + 非全屏时显示,跟 mute 等并排) - if !isFullscreen, !isPlaying { - iconButton(systemImage: "chevron.up", label: "收起播放器") { - withAnimation(.easeInOut(duration: 0.25)) { isCollapsed = true } - } - } // 比例 fit/fill iconButton( systemImage: coordinator.isScaleAspectFill @@ -772,7 +729,7 @@ struct KSPlayerView: View { } // Fullscreen toggle has been moved into bottomBar (right of the // playback-rate menu) — see `bottomBar`. Keeping the cluster - // {mute, collapse, aspect} on the right of topBar. + // {mute, aspect} on the right of topBar. } } @@ -1049,28 +1006,6 @@ struct KSPlayerView: View { } } - // MARK: - Collapsed strip - - private var collapsedStrip: some View { - HStack(spacing: 10) { - Image(systemName: "play.rectangle.fill") - .foregroundStyle(.white) - .font(.title2) - Text(snapshot.title) - .font(.subheadline.weight(.semibold)) - .foregroundStyle(.white) - .lineLimit(1) - Spacer() - iconButton(systemImage: "chevron.down", label: "展开播放器") { - withAnimation(.easeInOut(duration: 0.25)) { isCollapsed = false } - } - } - .padding(.horizontal, 16) - .padding(.vertical, 8) - .frame(maxWidth: .infinity) - .background(Color.black) - } - // MARK: - Helpers private func primarySource() -> VideoPlaybackSourceRow? { @@ -1273,6 +1208,13 @@ struct KSPlayerView: View { setPlayingState(shouldPlay) } + private func play() { + guard let layer = coordinator.playerLayer else { return } + layer.play() + setPlayingState(true) + scheduleAutoHide() + } + private func setPlayingState(_ nowPlaying: Bool) { guard nowPlaying != isPlaying else { return } isPlaying = nowPlaying diff --git a/iosApp/LocalVideoPlayerView.swift b/iosApp/LocalVideoPlayerView.swift index 17fedc6b..511a11b2 100644 --- a/iosApp/LocalVideoPlayerView.swift +++ b/iosApp/LocalVideoPlayerView.swift @@ -13,7 +13,6 @@ struct LocalVideoPlayerView: View { let fileURL: URL @State private var isFullscreen = false - @State private var isCollapsed = false @State private var videoNaturalSize: CGSize? /// Mirrors the streaming detail page's preference so portrait videos /// can stay portrait in fullscreen. @@ -36,7 +35,6 @@ struct LocalVideoPlayerView: View { KSPlayerView( snapshot: snapshot, isFullscreen: $isFullscreen, - isCollapsed: $isCollapsed, onProgress: { seconds in DownloadManager.shared.updatePlaybackPosition( videoCode: videoCode, diff --git a/iosApp/VideoDetailView.swift b/iosApp/VideoDetailView.swift index a494da8c..13758c26 100644 --- a/iosApp/VideoDetailView.swift +++ b/iosApp/VideoDetailView.swift @@ -13,8 +13,9 @@ struct VideoDetailView: View { @StateObject private var commentViewModel: CommentViewModel @State private var selectedTab = VideoPageTab.introduction @State private var isPlayerFullscreen = false - @State private var isPlayerCollapsed = false + @State private var isPlayerPlaying = false @State private var horizontalPagerExclusionFrames: [CGRect] = [] + @State private var playerPlayRequestToken = 0 /// Global collapse offset for the inline player, decoupled from any /// single tab's ScrollView offset. If one tab has already collapsed the /// player, switching to another tab must not snap it back open just @@ -261,10 +262,10 @@ struct VideoDetailView: View { panelWidth: leftWidth, parentHeight: proxy.size.height ) - let currentPlayerScrollAway = playerVisualShrink(panelWidth: leftWidth) - let currentBottomScrollOffset = isPlayerCollapsed - ? currentPlayerHeight - : max(currentPlayerHeight - currentPlayerScrollAway, 0) + let currentPlayerScrollAway = isPlayerPlaying + ? 0 + : playerVisualShrink(panelWidth: leftWidth) + let currentBottomScrollOffset = max(currentPlayerHeight - currentPlayerScrollAway, 0) let currentBottomScrollHeight = proxy.size.height - currentBottomScrollOffset let currentContinuationProgress = continuationStripProgress( scrollAway: currentPlayerScrollAway, @@ -283,8 +284,8 @@ struct VideoDetailView: View { belowPlayerScroll( snapshot: snapshot, showsRelated: !isWide, - collapseDistance: isPlayerCollapsed ? 0 : currentPlayerCollapseDistance, - collapseCompensation: isPlayerCollapsed ? 0 : currentPlayerScrollAway, + collapseDistance: currentPlayerCollapseDistance, + collapseCompensation: currentPlayerScrollAway, composerContentClearance: commentComposerContentClearance( safeAreaBottom: proxy.safeAreaInsets.bottom ) @@ -294,7 +295,6 @@ struct VideoDetailView: View { } if !isPlayerFullscreen, - !isPlayerCollapsed, currentContinuationProgress > 0 { continuePlayingStrip(snapshot: snapshot, progress: currentContinuationProgress) .frame(width: leftWidth, height: playerContinuationStripHeight) @@ -337,11 +337,9 @@ struct VideoDetailView: View { /// Player 高度: /// - 全屏:撑满整个父容器 - /// - 手动折叠:50pt 标题 strip /// - inline:固定为左 panel 宽度的 16:9,不随底部内容滚动缩小 private func playerHeight(panelWidth: CGFloat, parentHeight: CGFloat) -> CGFloat { if isPlayerFullscreen { return parentHeight } - if isPlayerCollapsed { return 50 } return panelWidth * 9 / 16 } @@ -364,19 +362,18 @@ struct VideoDetailView: View { return KSPlayerView( snapshot: snapshot, isFullscreen: $isPlayerFullscreen, - isCollapsed: $isPlayerCollapsed, onProgress: { viewModel.recordPlaybackPosition(seconds: $0) }, onPlaybackEnded: { viewModel.recordPlaybackPosition(seconds: 0) }, - onBack: { dismiss() }, - isShrunken: false, - onRequestExpand: { - // Reveal the full player when an older collapse state asks - // for expansion. The player itself no longer shrinks; the - // lower detail panel scrolls over it instead. - withAnimation(.easeInOut(duration: 0.25)) { - bottomScrollOffset = 0 + onPlayingChanged: { newValue in + if isPlayerPlaying != newValue { + isPlayerPlaying = newValue + if newValue { + bottomScrollOffset = 0 + } } }, + onBack: { dismiss() }, + playRequestToken: playerPlayRequestToken, onNaturalSize: { size in videoNaturalSize = size } @@ -388,6 +385,7 @@ struct VideoDetailView: View { withAnimation(.easeInOut(duration: 0.25)) { bottomScrollOffset = 0 } + playerPlayRequestToken &+= 1 } label: { HStack(spacing: 10) { Image(systemName: "play.circle.fill") @@ -501,6 +499,10 @@ struct VideoDetailView: View { previousActiveTabOffset: CGFloat?, collapseDistance: CGFloat ) { + guard !isPlayerPlaying else { + bottomScrollOffset = 0 + return + } bottomScrollOffset = VideoPlayerCollapseModel.nextCollapseOffset( currentCollapseOffset: bottomScrollOffset, activeTabOffset: activeTabOffset, From 8b208e78c0979eaefe45d5e60f14cf7d17441678 Mon Sep 17 00:00:00 2001 From: pgs666 <74764545+pgs666@users.noreply.github.com> Date: Fri, 5 Jun 2026 09:53:19 +0800 Subject: [PATCH 085/216] Fix comment table collapse type --- iosApp/CommentView.swift | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/iosApp/CommentView.swift b/iosApp/CommentView.swift index 8915a866..50c08fcf 100644 --- a/iosApp/CommentView.swift +++ b/iosApp/CommentView.swift @@ -201,11 +201,12 @@ private struct CommentTableView: UIViewRepresentable { func updateUIView(_ tableView: UITableView, context: Context) { context.coordinator.parent = self - tableView.playerCollapseOffset = collapseOffset + let commentTableView = tableView as? VideoDetailCommentTableView + commentTableView?.playerCollapseOffset = collapseOffset tableView.contentInset.bottom = contentBottomPadding + collapseDistance + 1 tableView.verticalScrollIndicatorInsets.bottom = contentBottomPadding context.coordinator.reloadIfNeeded(on: tableView, rows: rows, reloadKey: reloadKey) - tableView.applyPlayerCollapseCompensation() + commentTableView?.applyPlayerCollapseCompensation() } private var rows: [Row] { From d736daff475733fc21ceaa834899e18ca872faa8 Mon Sep 17 00:00:00 2001 From: pgs666 <74764545+pgs666@users.noreply.github.com> Date: Fri, 5 Jun 2026 10:12:45 +0800 Subject: [PATCH 086/216] Clamp scroll collapse offset during bounce --- iosApp/CommentView.swift | 11 ++++++++++- iosApp/VideoDetailView.swift | 11 ++++++++++- 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/iosApp/CommentView.swift b/iosApp/CommentView.swift index 50c08fcf..a97185d3 100644 --- a/iosApp/CommentView.swift +++ b/iosApp/CommentView.swift @@ -21,6 +21,13 @@ final class VideoDetailCommentTableView: UITableView { cell.transform = transform } } + + var verticalContentOffsetExcludingBounce: CGFloat { + let inset = adjustedContentInset + let minOffsetY = -inset.top + let maxOffsetY = max(minOffsetY, contentSize.height - bounds.height + inset.bottom) + return min(max(contentOffset.y, minOffsetY), maxOffsetY) + inset.top + } } struct CommentView: View { @@ -300,7 +307,9 @@ private struct CommentTableView: UIViewRepresentable { } func scrollViewDidScroll(_ scrollView: UIScrollView) { - parent.onScrollOffsetChange(max(0, scrollView.contentOffset.y)) + let offset = (scrollView as? VideoDetailCommentTableView)?.verticalContentOffsetExcludingBounce + ?? max(0, scrollView.contentOffset.y) + parent.onScrollOffsetChange(offset) (scrollView as? VideoDetailCommentTableView)?.applyPlayerCollapseCompensation() } diff --git a/iosApp/VideoDetailView.swift b/iosApp/VideoDetailView.swift index 13758c26..b7f46940 100644 --- a/iosApp/VideoDetailView.swift +++ b/iosApp/VideoDetailView.swift @@ -871,7 +871,7 @@ private final class VideoDetailVerticalScrollPageCoordinator: NSObject, UIScroll } func scrollViewDidScroll(_ scrollView: UIScrollView) { - onOffsetChange(tab, max(0, scrollView.contentOffset.y)) + onOffsetChange(tab, scrollView.verticalContentOffsetExcludingBounce) } } @@ -1003,6 +1003,15 @@ private final class VerticalScrollView: UIScrollView { } } +private extension UIScrollView { + var verticalContentOffsetExcludingBounce: CGFloat { + let inset = adjustedContentInset + let minOffsetY = -inset.top + let maxOffsetY = max(minOffsetY, contentSize.height - bounds.height + inset.bottom) + return min(max(contentOffset.y, minOffsetY), maxOffsetY) + inset.top + } +} + private final class VideoDetailHostedTabPageViewController: UIViewController { private let host = UIHostingController(rootView: AnyView(EmptyView())) private var contentUpdateRevision: Int? From 0cb9b4b3625c2f61f1dc3d14b74c57fb4d4e29b0 Mon Sep 17 00:00:00 2001 From: pgs666 <74764545+pgs666@users.noreply.github.com> Date: Fri, 5 Jun 2026 10:54:23 +0800 Subject: [PATCH 087/216] Use SwiftUI comments in detail pager --- iosApp/CommentView.swift | 497 +++++++++++------------------------ iosApp/VideoDetailView.swift | 114 +------- 2 files changed, 162 insertions(+), 449 deletions(-) diff --git a/iosApp/CommentView.swift b/iosApp/CommentView.swift index a97185d3..6471f4ca 100644 --- a/iosApp/CommentView.swift +++ b/iosApp/CommentView.swift @@ -1,93 +1,31 @@ import SwiftUI -import UIKit import Han1meShared -final class VideoDetailCommentTableView: UITableView { - var playerCollapseOffset: CGFloat = 0 { - didSet { - guard abs(playerCollapseOffset - oldValue) > 0.1 else { return } - applyPlayerCollapseCompensation() - } - } - - override func layoutSubviews() { - super.layoutSubviews() - applyPlayerCollapseCompensation() - } - - func applyPlayerCollapseCompensation() { - let transform = CGAffineTransform(translationX: 0, y: max(playerCollapseOffset, 0)) - visibleCells.forEach { cell in - cell.transform = transform - } - } - - var verticalContentOffsetExcludingBounce: CGFloat { - let inset = adjustedContentInset - let minOffsetY = -inset.top - let maxOffsetY = max(minOffsetY, contentSize.height - bounds.height + inset.bottom) - return min(max(contentOffset.y, minOffsetY), maxOffsetY) + inset.top - } -} - struct CommentView: View { @ObservedObject private var viewModel: CommentViewModel private let onOverlayActivityChanged: (Bool) -> Void - private let contentBottomPadding: CGFloat - private let collapseDistance: CGFloat - private let collapseOffset: CGFloat - private let onScrollOffsetChange: (CGFloat) -> Void @State private var replyTarget: CommentRow? @State private var replyText = "" @State private var reportTarget: CommentRow? @State private var repliesTarget: CommentRow? + @State private var renderedCommentCount = 24 + @State private var commentRenderSignature = "" + @State private var commentRenderTask: Task? init( viewModel: CommentViewModel, - contentBottomPadding: CGFloat = 24, - collapseDistance: CGFloat = 0, - collapseOffset: CGFloat = 0, - onScrollOffsetChange: @escaping (CGFloat) -> Void = { _ in }, onOverlayActivityChanged: @escaping (Bool) -> Void = { _ in } ) { _viewModel = ObservedObject(wrappedValue: viewModel) - self.contentBottomPadding = contentBottomPadding - self.collapseDistance = collapseDistance - self.collapseOffset = collapseOffset - self.onScrollOffsetChange = onScrollOffsetChange self.onOverlayActivityChanged = onOverlayActivityChanged } var body: some View { - CommentTableView( - state: viewModel.state, - sortMode: viewModel.sortMode, - sortedComments: viewModel.sortedComments, - runningActionIDs: viewModel.runningActionIDs, - contentBottomPadding: contentBottomPadding, - collapseDistance: collapseDistance, - collapseOffset: collapseOffset, - onSortModeChange: { viewModel.changeSortMode($0) }, - onRefresh: { viewModel.load() }, - onRetry: { viewModel.load() }, - onReply: { comment in - replyText = "@\(comment.username) " - replyTarget = comment - }, - onShowReplies: { comment in - repliesTarget = comment - }, - onLike: { comment in - viewModel.like(comment: comment, isPositive: true) - }, - onDislike: { comment in - viewModel.like(comment: comment, isPositive: false) - }, - onReport: { comment in - reportTarget = comment - }, - onScrollOffsetChange: onScrollOffsetChange - ) + VStack(alignment: .leading, spacing: 12) { + header + content + } + .padding(.horizontal, 16) .task { viewModel.loadIfNeeded() } @@ -99,6 +37,8 @@ struct CommentView: View { } .onDisappear { onOverlayActivityChanged(false) + commentRenderTask?.cancel() + commentRenderTask = nil } .alert("提示", isPresented: actionMessageBinding) { Button("好", role: .cancel) { @@ -148,243 +88,14 @@ struct CommentView: View { } } - private var actionMessageBinding: Binding { - Binding( - get: { viewModel.actionMessage != nil }, - set: { if !$0 { viewModel.actionMessage = nil } } - ) - } - - private var reportDialogBinding: Binding { - Binding( - get: { reportTarget != nil }, - set: { if !$0 { reportTarget = nil } } - ) - } - - private func notifyOverlayActivityChanged() { - onOverlayActivityChanged(replyTarget != nil || repliesTarget != nil) - } -} - -private struct CommentTableView: UIViewRepresentable { - let state: CommentViewModel.State - let sortMode: CommentViewModel.SortMode - let sortedComments: [CommentRow] - let runningActionIDs: Set - let contentBottomPadding: CGFloat - let collapseDistance: CGFloat - let collapseOffset: CGFloat - let onSortModeChange: (CommentViewModel.SortMode) -> Void - let onRefresh: () -> Void - let onRetry: () -> Void - let onReply: (CommentRow) -> Void - let onShowReplies: (CommentRow) -> Void - let onLike: (CommentRow) -> Void - let onDislike: (CommentRow) -> Void - let onReport: (CommentRow) -> Void - let onScrollOffsetChange: (CGFloat) -> Void - - func makeCoordinator() -> Coordinator { - Coordinator(parent: self) - } - - func makeUIView(context: Context) -> UITableView { - let tableView = VideoDetailCommentTableView(frame: .zero, style: .plain) - tableView.backgroundColor = .systemGroupedBackground - tableView.separatorStyle = .none - tableView.showsVerticalScrollIndicator = false - tableView.contentInsetAdjustmentBehavior = .never - tableView.keyboardDismissMode = .interactive - tableView.bounces = true - tableView.alwaysBounceVertical = true - tableView.estimatedRowHeight = 120 - tableView.rowHeight = UITableView.automaticDimension - tableView.dataSource = context.coordinator - tableView.delegate = context.coordinator - tableView.register(UITableViewCell.self, forCellReuseIdentifier: Coordinator.cellReuseIdentifier) - return tableView - } - - func updateUIView(_ tableView: UITableView, context: Context) { - context.coordinator.parent = self - let commentTableView = tableView as? VideoDetailCommentTableView - commentTableView?.playerCollapseOffset = collapseOffset - tableView.contentInset.bottom = contentBottomPadding + collapseDistance + 1 - tableView.verticalScrollIndicatorInsets.bottom = contentBottomPadding - context.coordinator.reloadIfNeeded(on: tableView, rows: rows, reloadKey: reloadKey) - commentTableView?.applyPlayerCollapseCompensation() - } - - private var rows: [Row] { - var rows: [Row] = [.header] - switch state { - case .idle, .loading: - rows.append(.loading) - case .failed(let message): - rows.append(.failed(message)) - case .loaded: - if sortedComments.isEmpty { - rows.append(.empty) - } else { - rows.append(contentsOf: sortedComments.map(Row.comment)) - rows.append(.footer) - } - } - return rows - } - - private var reloadKey: [String] { - var key = ["sort:\(sortMode.rawValue)", "running:\(runningActionIDs.sorted().joined(separator: ","))"] - switch state { - case .idle: - key.append("state:idle") - case .loading: - key.append("state:loading") - case .failed(let message): - key.append("state:failed:\(message)") - case .loaded: - key.append("state:loaded") - } - key.append(contentsOf: sortedComments.map { comment in - [ - "id=\(comment.id)", - "avatar=\(comment.avatarUrl)", - "user=\(comment.username)", - "date=\(comment.date)", - "content=\(comment.content)", - "thumb=\(comment.thumbUp ?? -1)", - "child=\(comment.isChildComment)", - "more=\(comment.hasMoreReplies)", - "replies=\(comment.replyCount ?? -1)", - "liked=\(comment.likeCommentStatus)", - "disliked=\(comment.unlikeCommentStatus)" - ].joined(separator: "|") - }) - return key - } - - enum Row { - case header - case loading - case failed(String) - case empty - case comment(CommentRow) - case footer - } - - final class Coordinator: NSObject, UITableViewDataSource, UITableViewDelegate { - static let cellReuseIdentifier = "CommentTableCell" - - var parent: CommentTableView - var rows: [Row] = [] - private var reloadKey: [String] = [] - - init(parent: CommentTableView) { - self.parent = parent - } - - func reloadIfNeeded(on tableView: UITableView, rows: [Row], reloadKey: [String]) { - guard self.reloadKey != reloadKey else { return } - self.reloadKey = reloadKey - self.rows = rows - tableView.reloadData() - } - - func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { - rows.count - } - - func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { - let cell = tableView.dequeueReusableCell(withIdentifier: Self.cellReuseIdentifier, for: indexPath) - cell.backgroundColor = .clear - cell.selectionStyle = .none - cell.contentConfiguration = UIHostingConfiguration { - view(for: rows[indexPath.row]) - } - .margins(.all, 0) - return cell - } - - func scrollViewDidScroll(_ scrollView: UIScrollView) { - let offset = (scrollView as? VideoDetailCommentTableView)?.verticalContentOffsetExcludingBounce - ?? max(0, scrollView.contentOffset.y) - parent.onScrollOffsetChange(offset) - (scrollView as? VideoDetailCommentTableView)?.applyPlayerCollapseCompensation() - } - - func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) { - cell.transform = CGAffineTransform( - translationX: 0, - y: max((tableView as? VideoDetailCommentTableView)?.playerCollapseOffset ?? parent.collapseOffset, 0) - ) - } - - @ViewBuilder - private func view(for row: Row) -> some View { - switch row { - case .header: - CommentHeaderView( - sortMode: parent.sortMode, - onSortModeChange: parent.onSortModeChange, - onRefresh: parent.onRefresh - ) - .padding(.top, 16) - .padding(.horizontal, 16) - .padding(.bottom, 12) - case .loading: - HStack { - Spacer() - ProgressView() - Spacer() - } - .padding(.vertical, 60) - .padding(.horizontal, 16) - case .failed(let message): - CommentFailureView(message: message, onRetry: parent.onRetry) - .padding(.horizontal, 16) - .padding(.vertical, 60) - case .empty: - CommentEmptyView() - .padding(.horizontal, 16) - .padding(.vertical, 60) - case .comment(let comment): - CommentRowView( - comment: comment, - isRunningLike: parent.runningActionIDs.contains("like-\(comment.id)"), - onReply: { self.parent.onReply(comment) }, - onShowReplies: { self.parent.onShowReplies(comment) }, - onLike: { self.parent.onLike(comment) }, - onDislike: { self.parent.onDislike(comment) }, - onReport: { self.parent.onReport(comment) } - ) - .padding(.horizontal, 16) - .padding(.bottom, 12) - case .footer: - Text("comment.no_more") - .font(.footnote) - .foregroundStyle(.secondary) - .frame(maxWidth: .infinity) - .padding(.horizontal, 16) - .padding(.vertical, 12) - } - } - } -} - -private struct CommentHeaderView: View { - let sortMode: CommentViewModel.SortMode - let onSortModeChange: (CommentViewModel.SortMode) -> Void - let onRefresh: () -> Void - - var body: some View { + private var header: some View { HStack(spacing: 12) { Menu { ForEach(CommentViewModel.SortMode.allCases) { mode in Button { - onSortModeChange(mode) + viewModel.changeSortMode(mode) } label: { - if mode == sortMode { + if mode == viewModel.sortMode { Label(mode.title, systemImage: "checkmark") } else { Text(mode.title) @@ -392,18 +103,17 @@ private struct CommentHeaderView: View { } } } label: { - Label(sortMode.title, systemImage: "arrow.up.arrow.down") + Label(viewModel.sortMode.title, systemImage: "arrow.up.arrow.down") .font(.subheadline.weight(.semibold)) .foregroundStyle(Color.accentColor) .padding(.horizontal, 12) .padding(.vertical, 7) .background(Color.accentColor.opacity(0.12), in: RoundedRectangle(cornerRadius: 8, style: .continuous)) } - Spacer() TapOnlyControl { - onRefresh() + viewModel.load() } label: { Image(systemName: "arrow.clockwise") .font(.subheadline.weight(.semibold)) @@ -414,52 +124,151 @@ private struct CommentHeaderView: View { } } } -} -private struct CommentFailureView: View { - let message: String - let onRetry: () -> Void + @ViewBuilder + private var content: some View { + switch viewModel.state { + case .idle, .loading: + HStack { + Spacer() + ProgressView() + Spacer() + } + .padding(.vertical, 60) + case .failed(let message): + VStack(spacing: 12) { + Image(systemName: "text.bubble") + .font(.largeTitle) + .foregroundStyle(.secondary) + Text("评论加载失败") + .font(.headline) + Text(message) + .font(.subheadline) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + TapOnlyControl { + viewModel.load() + } label: { + Text("重试") + .font(.subheadline.weight(.semibold)) + .foregroundStyle(.white) + .padding(.horizontal, 14) + .padding(.vertical, 8) + .background(Color.accentColor, in: RoundedRectangle(cornerRadius: 8, style: .continuous)) + } + CloudflareVerifyButton(errorMessage: message) + } + .frame(maxWidth: .infinity) + .padding(.vertical, 60) + case .loaded: + let comments = viewModel.sortedComments + if comments.isEmpty { + VStack(spacing: 12) { + Image(systemName: "bubble.left.and.bubble.right") + .font(.largeTitle) + .foregroundStyle(.secondary) + Text("暂无评论") + .font(.headline) + Text("成为第一个评论的人。") + .font(.subheadline) + .foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity) + .padding(.vertical, 60) + } else { + VStack(alignment: .leading, spacing: 12) { + ForEach(Array(comments.prefix(renderedCommentCount))) { comment in + CommentRowView( + comment: comment, + isRunningLike: viewModel.runningActionIDs.contains("like-\(comment.id)"), + onReply: { + replyText = "@\(comment.username) " + replyTarget = comment + }, + onShowReplies: { + repliesTarget = comment + }, + onLike: { + viewModel.like(comment: comment, isPositive: true) + }, + onDislike: { + viewModel.like(comment: comment, isPositive: false) + }, + onReport: { + reportTarget = comment + } + ) + } - var body: some View { - VStack(spacing: 12) { - Image(systemName: "text.bubble") - .font(.largeTitle) - .foregroundStyle(.secondary) - Text("评论加载失败") - .font(.headline) - Text(message) - .font(.subheadline) - .foregroundStyle(.secondary) - .multilineTextAlignment(.center) - TapOnlyControl { - onRetry() - } label: { - Text("重试") - .font(.subheadline.weight(.semibold)) - .foregroundStyle(.white) - .padding(.horizontal, 14) - .padding(.vertical, 8) - .background(Color.accentColor, in: RoundedRectangle(cornerRadius: 8, style: .continuous)) + if renderedCommentCount < comments.count { + ProgressView() + .frame(maxWidth: .infinity) + .padding(.vertical, 12) + } else { + Text("comment.no_more") + .font(.footnote) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity) + .padding(.vertical, 12) + } + } + .id(viewModel.sortMode.id) + .onAppear { + scheduleCommentRendering(for: comments) + } + .onValueChange(of: commentRenderSignature(for: comments)) { _ in + scheduleCommentRendering(for: comments) + } } - CloudflareVerifyButton(errorMessage: message) } - .frame(maxWidth: .infinity) } -} -private struct CommentEmptyView: View { - var body: some View { - VStack(spacing: 12) { - Image(systemName: "bubble.left.and.bubble.right") - .font(.largeTitle) - .foregroundStyle(.secondary) - Text("暂无评论") - .font(.headline) - Text("成为第一个评论的人。") - .font(.subheadline) - .foregroundStyle(.secondary) + private var actionMessageBinding: Binding { + Binding( + get: { viewModel.actionMessage != nil }, + set: { if !$0 { viewModel.actionMessage = nil } } + ) + } + + private var reportDialogBinding: Binding { + Binding( + get: { reportTarget != nil }, + set: { if !$0 { reportTarget = nil } } + ) + } + + private func notifyOverlayActivityChanged() { + onOverlayActivityChanged(replyTarget != nil || repliesTarget != nil) + } + + private func commentRenderSignature(for comments: [CommentRow]) -> String { + [ + viewModel.sortMode.id, + "\(comments.count)", + comments.first?.id ?? "", + comments.last?.id ?? "" + ].joined(separator: "|") + } + + private func scheduleCommentRendering(for comments: [CommentRow]) { + let signature = commentRenderSignature(for: comments) + guard commentRenderSignature != signature else { return } + commentRenderSignature = signature + commentRenderTask?.cancel() + renderedCommentCount = min(24, comments.count) + guard renderedCommentCount < comments.count else { + commentRenderTask = nil + return + } + + commentRenderTask = Task { @MainActor in + while !Task.isCancelled, renderedCommentCount < comments.count { + try? await Task.sleep(nanoseconds: 45_000_000) + guard !Task.isCancelled else { return } + renderedCommentCount = min(renderedCommentCount + 16, comments.count) + } + commentRenderTask = nil } - .frame(maxWidth: .infinity) } } diff --git a/iosApp/VideoDetailView.swift b/iosApp/VideoDetailView.swift index b7f46940..324bebc5 100644 --- a/iosApp/VideoDetailView.swift +++ b/iosApp/VideoDetailView.swift @@ -454,34 +454,22 @@ struct VideoDetailView: View { } collapseDistance: { collapseDistance }, - comments: selfScrollingCommentsTabPage( + comments: tabPage( + .comments, contentBottomPadding: composerContentClearance, - contentUpdateRevision: commentsContentRevision( - baseRevision: contentRevision.comments, - contentBottomPadding: composerContentClearance, - collapseDistance: collapseDistance - ), - collapseCompensation: tabCollapseCompensation( - for: .comments, - collapseCompensation: collapseCompensation - ), - collapseDistance: collapseDistance + contentUpdateRevision: contentRevision.comments ) { CommentView( viewModel: commentViewModel, - contentBottomPadding: composerContentClearance, - collapseDistance: collapseDistance, - collapseOffset: tabCollapseCompensation( - for: .comments, - collapseCompensation: collapseCompensation - ), - onScrollOffsetChange: { offset in - updateTabOffset(.comments, offset: offset, collapseDistance: collapseDistance) - }, onOverlayActivityChanged: { isActive in isCommentInternalOverlayActive = isActive } ) + .padding(.top, 16) + } collapseCompensation: { + tabCollapseCompensation(for: .comments, collapseCompensation: collapseCompensation) + } collapseDistance: { + collapseDistance } ) .frame(maxHeight: .infinity) @@ -553,38 +541,6 @@ struct VideoDetailView: View { ) } - private func selfScrollingCommentsTabPage( - contentBottomPadding: CGFloat, - contentUpdateRevision: Int, - collapseCompensation: CGFloat, - collapseDistance: CGFloat, - @ViewBuilder content: @escaping () -> Content - ) -> VideoDetailTabPage { - VideoDetailTabPage( - tab: .comments, - contentBottomPadding: contentBottomPadding, - collapseCompensation: collapseCompensation, - collapseDistance: collapseDistance, - contentUpdateRevision: contentUpdateRevision, - onOffsetChange: { tab, offset in - updateTabOffset(tab, offset: offset, collapseDistance: collapseDistance) - }, - content: content - ) - } - - private func commentsContentRevision( - baseRevision: Int, - contentBottomPadding: CGFloat, - collapseDistance: CGFloat - ) -> Int { - var hasher = Hasher() - hasher.combine(baseRevision) - hasher.combine(Int((contentBottomPadding * 100).rounded())) - hasher.combine(Int((collapseDistance * 100).rounded())) - return hasher.finalize() - } - private func updateTabOffset(_ tab: VideoPageTab, offset: CGFloat, collapseDistance: CGFloat) { let previousActiveOffset = bottomScrollOffsetsByTab[selectedTab] bottomScrollOffsetsByTab[tab] = offset @@ -1012,58 +968,6 @@ private extension UIScrollView { } } -private final class VideoDetailHostedTabPageViewController: UIViewController { - private let host = UIHostingController(rootView: AnyView(EmptyView())) - private var contentUpdateRevision: Int? - private weak var commentTableView: VideoDetailCommentTableView? - - override func viewDidLoad() { - super.viewDidLoad() - view.backgroundColor = .clear - - addChild(host) - host.view.translatesAutoresizingMaskIntoConstraints = false - host.view.backgroundColor = .clear - view.addSubview(host.view) - host.didMove(toParent: self) - - NSLayoutConstraint.activate([ - host.view.leadingAnchor.constraint(equalTo: view.leadingAnchor), - host.view.trailingAnchor.constraint(equalTo: view.trailingAnchor), - host.view.topAnchor.constraint(equalTo: view.topAnchor), - host.view.bottomAnchor.constraint(equalTo: view.bottomAnchor) - ]) - } - - func update(page: VideoDetailTabPage) { - loadViewIfNeeded() - if contentUpdateRevision != page.contentUpdateRevision { - contentUpdateRevision = page.contentUpdateRevision - host.rootView = page.content() - } - updateCommentTableCollapseOffset(page.collapseCompensation) - } - - private func updateCommentTableCollapseOffset(_ collapseOffset: CGFloat) { - let tableView = commentTableView ?? firstCommentTableView(in: host.view) - guard let tableView else { return } - commentTableView = tableView - tableView.playerCollapseOffset = collapseOffset - } - - private func firstCommentTableView(in view: UIView) -> VideoDetailCommentTableView? { - if let tableView = view as? VideoDetailCommentTableView { - return tableView - } - for subview in view.subviews { - if let tableView = firstCommentTableView(in: subview) { - return tableView - } - } - return nil - } -} - private struct VideoDetailTabPager: UIViewControllerRepresentable { @Binding var selectedTab: VideoPageTab let excludedDragStartFrames: [CGRect] @@ -1167,7 +1071,7 @@ private struct VideoDetailTabPager: UIViewControllerRepresentable { private let scrollView = PagingScrollView() private let contentView = UIView() private let introductionPage = VideoDetailVerticalScrollPageViewController(tab: .introduction) - private let commentsPage = VideoDetailHostedTabPageViewController() + private let commentsPage = VideoDetailVerticalScrollPageViewController(tab: .comments) private var selectedIndex = 0 private var pendingSelectedIndex: Int? From 082e4aa480f2860390d662386523e50f983e3ff4 Mon Sep 17 00:00:00 2001 From: pgs666 <74764545+pgs666@users.noreply.github.com> Date: Fri, 5 Jun 2026 22:43:56 +0800 Subject: [PATCH 088/216] Stabilize video detail scrolling --- iosApp/RelatedVideoComponents.swift | 7 +- iosApp/VideoDetailView.swift | 102 +++++++++++----------------- 2 files changed, 42 insertions(+), 67 deletions(-) diff --git a/iosApp/RelatedVideoComponents.swift b/iosApp/RelatedVideoComponents.swift index 3f2a0075..ee8b135e 100644 --- a/iosApp/RelatedVideoComponents.swift +++ b/iosApp/RelatedVideoComponents.swift @@ -315,9 +315,9 @@ struct RelatedVideoCard: View { Text(video.title) .lineLimit(2) - .font(.subheadline.weight(.semibold)) - .foregroundStyle(.primary) - .frame(maxWidth: .infinity, minHeight: 40, alignment: .topLeading) + .font(.subheadline.weight(.semibold)) + .foregroundStyle(.primary) + .frame(maxWidth: .infinity, minHeight: 40, maxHeight: 40, alignment: .topLeading) if showsMetadataFooter { HStack(spacing: 6) { @@ -330,6 +330,7 @@ struct RelatedVideoCard: View { .layoutPriority(1) } } + .frame(height: 16) } } .frame(width: width, alignment: .leading) diff --git a/iosApp/VideoDetailView.swift b/iosApp/VideoDetailView.swift index 324bebc5..02bdd8bb 100644 --- a/iosApp/VideoDetailView.swift +++ b/iosApp/VideoDetailView.swift @@ -14,7 +14,6 @@ struct VideoDetailView: View { @State private var selectedTab = VideoPageTab.introduction @State private var isPlayerFullscreen = false @State private var isPlayerPlaying = false - @State private var horizontalPagerExclusionFrames: [CGRect] = [] @State private var playerPlayRequestToken = 0 /// Global collapse offset for the inline player, decoupled from any /// single tab's ScrollView offset. If one tab has already collapsed the @@ -63,9 +62,6 @@ struct VideoDetailView: View { rootCommentComposer(layoutSize: proxy.size) } .animation(.easeInOut(duration: 0.2), value: shouldShowRootCommentComposer) - .onPreferenceChange(HorizontalPagerExclusionFramePreferenceKey.self) { frames in - horizontalPagerExclusionFrames = frames - } .onPreferenceChange(CommentComposerHeightPreferenceKey.self) { height in guard height > 0, abs(commentComposerHeight - height) > 0.5 else { return } commentComposerHeight = height @@ -208,7 +204,6 @@ struct VideoDetailView: View { ) .frame(width: leftPanelWidth(for: layoutSize)) .reportCommentComposerHeight() - .horizontalPagerExclusionArea() .frame(maxWidth: .infinity, alignment: .leading) .transition(.move(edge: .bottom).combined(with: .opacity)) .zIndex(1) @@ -265,6 +260,9 @@ struct VideoDetailView: View { let currentPlayerScrollAway = isPlayerPlaying ? 0 : playerVisualShrink(panelWidth: leftWidth) + let currentCollapseDistance = isPlayerPlaying + ? 0 + : currentPlayerCollapseDistance let currentBottomScrollOffset = max(currentPlayerHeight - currentPlayerScrollAway, 0) let currentBottomScrollHeight = proxy.size.height - currentBottomScrollOffset let currentContinuationProgress = continuationStripProgress( @@ -284,7 +282,7 @@ struct VideoDetailView: View { belowPlayerScroll( snapshot: snapshot, showsRelated: !isWide, - collapseDistance: currentPlayerCollapseDistance, + collapseDistance: currentCollapseDistance, collapseCompensation: currentPlayerScrollAway, composerContentClearance: commentComposerContentClearance( safeAreaBottom: proxy.safeAreaInsets.bottom @@ -367,7 +365,7 @@ struct VideoDetailView: View { onPlayingChanged: { newValue in if isPlayerPlaying != newValue { isPlayerPlaying = newValue - if newValue { + if newValue, abs(bottomScrollOffset) > 0.5 { bottomScrollOffset = 0 } } @@ -431,7 +429,6 @@ struct VideoDetailView: View { VideoDetailTabPager( selectedTab: $selectedTab, - excludedDragStartFrames: horizontalPagerExclusionFrames, introduction: tabPage( .introduction, contentUpdateRevision: contentRevision.introduction @@ -488,16 +485,24 @@ struct VideoDetailView: View { collapseDistance: CGFloat ) { guard !isPlayerPlaying else { - bottomScrollOffset = 0 + if abs(bottomScrollOffset) > 0.5 { + withTransaction(Transaction(animation: nil)) { + bottomScrollOffset = 0 + } + } return } - bottomScrollOffset = VideoPlayerCollapseModel.nextCollapseOffset( + let nextOffset = VideoPlayerCollapseModel.nextCollapseOffset( currentCollapseOffset: bottomScrollOffset, activeTabOffset: activeTabOffset, previousActiveTabOffset: previousActiveTabOffset, collapseDistance: collapseDistance, switchedTabsRecently: Date().timeIntervalSince(lastSelectedTabChangeAt) < 0.35 ) + guard abs(bottomScrollOffset - nextOffset) > 0.5 else { return } + withTransaction(Transaction(animation: nil)) { + bottomScrollOffset = nextOffset + } } private func submitComment() { @@ -543,13 +548,15 @@ struct VideoDetailView: View { private func updateTabOffset(_ tab: VideoPageTab, offset: CGFloat, collapseDistance: CGFloat) { let previousActiveOffset = bottomScrollOffsetsByTab[selectedTab] - bottomScrollOffsetsByTab[tab] = offset - guard tab == selectedTab else { return } - updatePlayerCollapseOffset( - activeTabOffset: offset, - previousActiveTabOffset: previousActiveOffset, - collapseDistance: collapseDistance - ) + withTransaction(Transaction(animation: nil)) { + bottomScrollOffsetsByTab[tab] = offset + guard tab == selectedTab else { return } + updatePlayerCollapseOffset( + activeTabOffset: offset, + previousActiveTabOffset: previousActiveOffset, + collapseDistance: collapseDistance + ) + } } private func tabCollapseCompensation(for tab: VideoPageTab, collapseCompensation: CGFloat) -> CGFloat { @@ -820,6 +827,7 @@ private struct VideoDetailTabPage { private final class VideoDetailVerticalScrollPageCoordinator: NSObject, UIScrollViewDelegate { var tab: VideoPageTab var onOffsetChange: (VideoPageTab, CGFloat) -> Void + private var lastReportedOffset: CGFloat? init(tab: VideoPageTab, onOffsetChange: @escaping (VideoPageTab, CGFloat) -> Void) { self.tab = tab @@ -827,7 +835,10 @@ private final class VideoDetailVerticalScrollPageCoordinator: NSObject, UIScroll } func scrollViewDidScroll(_ scrollView: UIScrollView) { - onOffsetChange(tab, scrollView.verticalContentOffsetExcludingBounce) + let offset = scrollView.verticalContentOffsetExcludingBounce + guard lastReportedOffset.map({ abs($0 - offset) > 0.5 }) ?? true else { return } + lastReportedOffset = offset + onOffsetChange(tab, offset) } } @@ -860,8 +871,8 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle scrollView.translatesAutoresizingMaskIntoConstraints = false scrollView.backgroundColor = .clear - scrollView.bounces = true - scrollView.alwaysBounceVertical = true + scrollView.bounces = false + scrollView.alwaysBounceVertical = false scrollView.alwaysBounceHorizontal = false scrollView.showsHorizontalScrollIndicator = false scrollView.showsVerticalScrollIndicator = false @@ -970,18 +981,15 @@ private extension UIScrollView { private struct VideoDetailTabPager: UIViewControllerRepresentable { @Binding var selectedTab: VideoPageTab - let excludedDragStartFrames: [CGRect] let introduction: VideoDetailTabPage let comments: VideoDetailTabPage init( selectedTab: Binding, - excludedDragStartFrames: [CGRect], introduction: VideoDetailTabPage, comments: VideoDetailTabPage ) { _selectedTab = selectedTab - self.excludedDragStartFrames = excludedDragStartFrames self.introduction = introduction self.comments = comments } @@ -996,7 +1004,6 @@ private struct VideoDetailTabPager: UIViewControllerRepresentable { func updateUIViewController(_ uiViewController: PagingViewController, context: Context) { context.coordinator.selectedTab = $selectedTab - context.coordinator.excludedDragStartFrames = excludedDragStartFrames uiViewController.updatePages( introduction: introduction, comments: comments, @@ -1007,7 +1014,6 @@ private struct VideoDetailTabPager: UIViewControllerRepresentable { final class Coordinator: NSObject, UIScrollViewDelegate { var selectedTab: Binding - var excludedDragStartFrames: [CGRect] = [] private var lastProgrammaticIndex: Int? init(selectedTab: Binding) { @@ -1043,10 +1049,6 @@ private struct VideoDetailTabPager: UIViewControllerRepresentable { guard !view.hasScrollableHorizontalDescendant(at: startLocation, excluding: view) else { return false } - let globalStartLocation = view.convert(startLocation, to: nil) - guard !excludedDragStartFrames.contains(where: { $0.contains(globalStartLocation) }) else { - return false - } let translation = panGestureRecognizer.translation(in: view) let velocity = panGestureRecognizer.velocity(in: view) @@ -1200,47 +1202,22 @@ private struct VideoDetailTabPager: UIViewControllerRepresentable { private extension UIView { func hasScrollableHorizontalDescendant(at location: CGPoint, excluding excludedView: UIView) -> Bool { - for subview in subviews.reversed() where subview !== excludedView && !subview.isHidden && subview.alpha > 0.01 { - let localLocation = subview.convert(location, from: self) - guard subview.point(inside: localLocation, with: nil) else { continue } - if let scrollView = subview as? UIScrollView, + guard let hitView = hitTest(location, with: nil) else { return false } + var current: UIView? = hitView + while let view = current, view !== excludedView { + if let scrollView = view as? UIScrollView, scrollView.isScrollEnabled, scrollView.panGestureRecognizer.isEnabled, - scrollView.contentSize.width > scrollView.bounds.width + 1 { - return true - } - if subview.hasScrollableHorizontalDescendant(at: localLocation, excluding: excludedView) { + scrollView.contentSize.width > scrollView.bounds.width + 1, + scrollView.contentSize.width > scrollView.contentSize.height { return true } + current = view.superview } return false } } -private struct HorizontalPagerExclusionFramePreferenceKey: PreferenceKey { - static var defaultValue: [CGRect] = [] - static func reduce(value: inout [CGRect], nextValue: () -> [CGRect]) { - value.append(contentsOf: nextValue()) - } -} - -private struct HorizontalPagerExclusionFrameReader: View { - var body: some View { - GeometryReader { proxy in - Color.clear.preference( - key: HorizontalPagerExclusionFramePreferenceKey.self, - value: [proxy.frame(in: .global)] - ) - } - } -} - -extension View { - func horizontalPagerExclusionArea() -> some View { - background(HorizontalPagerExclusionFrameReader()) - } -} - private extension VideoDetailScreenSnapshot { func hash(into hasher: inout Hasher) { hasher.combine(videoCode) @@ -1382,9 +1359,6 @@ private struct AndroidStyleIntroduction: View { showPlaying: true, showsMetadataFooter: false ) - // Temporarily disabled for scroll-jank CI probe: this section - // lives inside the vertical ScrollView, so its global frame - // preference changes on every scroll tick. } if showsRelated && !snapshot.relatedVideos.isEmpty { From 7b8620528dc9548e621ee165f7b0071db0fe3ee2 Mon Sep 17 00:00:00 2001 From: pgs666 <74764545+pgs666@users.noreply.github.com> Date: Fri, 5 Jun 2026 23:22:45 +0800 Subject: [PATCH 089/216] Stabilize video detail tab transitions --- iosApp/VideoDetailView.swift | 1858 +--------------------------------- 1 file changed, 1 insertion(+), 1857 deletions(-) diff --git a/iosApp/VideoDetailView.swift b/iosApp/VideoDetailView.swift index 02bdd8bb..d74fd84b 100644 --- a/iosApp/VideoDetailView.swift +++ b/iosApp/VideoDetailView.swift @@ -1,1857 +1 @@ -import SwiftUI -import UIKit -import Han1meShared - -struct VideoDetailView: View { - let videoCode: String - private let videoFeature: VideoFeature - private let commentFeature: CommentFeature - private let tabletLeftMinimumWidth: CGFloat = 620 - private let tabletSidebarMinimumWidth: CGFloat = 360 - private let playerContinuationStripHeight: CGFloat = 56 - @StateObject private var viewModel: VideoDetailViewModel - @StateObject private var commentViewModel: CommentViewModel - @State private var selectedTab = VideoPageTab.introduction - @State private var isPlayerFullscreen = false - @State private var isPlayerPlaying = false - @State private var playerPlayRequestToken = 0 - /// Global collapse offset for the inline player, decoupled from any - /// single tab's ScrollView offset. If one tab has already collapsed the - /// player, switching to another tab must not snap it back open just - /// because that tab's own content is still at the top. - @State private var bottomScrollOffset: CGFloat = 0 - /// Each tab owns an independent vertical ScrollView below the player, so - /// switching between intro / comments preserves their separate scroll - /// positions instead of sharing one outer ScrollView offset. - @State private var bottomScrollOffsetsByTab: [VideoPageTab: CGFloat] = [:] - @State private var lastSelectedTabChangeAt = Date.distantPast - @State private var commentComposeText = "" - @State private var isCommentInternalOverlayActive = false - @State private var commentComposerHeight: CGFloat = CommentComposerBar.compactHeight - @State private var fullscreenOrientationTask: Task? - /// Natural size of the loaded video (reported by KSPlayer the first time - /// the underlying player gets a non-zero presentation size). Used to - /// decide whether fullscreen should lock the device to portrait or - /// landscape: a video taller than wide on a phone shouldn't force a - /// 90° rotation that produces black side-bars. - @State private var videoNaturalSize: CGSize? - /// Mirrors the KMP-shared `forcePortraitFullscreenForVerticalVideos` - /// preference (default ON). When ON, fullscreen on a portrait-aspect - /// video keeps the device in portrait instead of forcing landscape. - @AppStorage("force_portrait_fullscreen_for_vertical_videos") - private var forcePortraitForVerticalVideos: Bool = true - @Environment(\.horizontalSizeClass) private var horizontalSizeClass - @Environment(\.dismiss) private var dismiss - - init(videoCode: String, videoFeature: VideoFeature, commentFeature: CommentFeature) { - self.videoCode = videoCode - self.videoFeature = videoFeature - self.commentFeature = commentFeature - _viewModel = StateObject(wrappedValue: VideoDetailViewModel(videoFeature: videoFeature)) - _commentViewModel = StateObject( - wrappedValue: CommentViewModel(feature: commentFeature, videoCode: videoCode) - ) - } - - var body: some View { - GeometryReader { proxy in - ZStack(alignment: .bottom) { - content - .ignoresSafeArea(.container, edges: ignoredContainerSafeAreaEdges) - - rootCommentComposer(layoutSize: proxy.size) - } - .animation(.easeInOut(duration: 0.2), value: shouldShowRootCommentComposer) - .onPreferenceChange(CommentComposerHeightPreferenceKey.self) { height in - guard height > 0, abs(commentComposerHeight - height) > 0.5 else { return } - commentComposerHeight = height - } - } - .logScreen("VideoDetail v=\(videoCode)") - // Navigation bar (and its system back button) is hidden the - // whole time. The player draws its own floating back button - // inside the controls overlay — that way show/hide of the - // back button is purely an overlay-layer change and doesn't - // resize / shift the rest of the view tree. - .toolbar(.hidden, for: .navigationBar) - // SwiftUI's `.toolbar(.hidden, for: .navigationBar)` also turns - // off the edge-swipe-to-go-back gesture on the underlying - // UINavigationController. Re-enable it explicitly so the user - // still has the standard iOS gesture to navigate back even - // though we hide the nav bar. - .enableInteractivePopOnHiddenNavBar(disabled: isPlayerFullscreen) - // The video detail page always hides the tab bar — it's a - // pushed sub-page that benefits from extra vertical space, not a - // top-level tab. (Fullscreen state doesn't matter; both inline - // and fullscreen want the tab bar gone.) - // hidesTabBarOnAppear() drives the shared TabBarVisibilityController: - // .onAppear sets hidden; .onDisappear withAnimation sets visible - // again, producing the slide-in/out animation. - .hidesTabBarOnAppear() - .statusBarHidden(isPlayerFullscreen) - .task { - viewModel.loadIfNeeded(videoCode: videoCode) - } - .onDisappear { - fullscreenOrientationTask?.cancel() - fullscreenOrientationTask = nil - // KSPlayer pauses itself in its own .onDisappear; the - // detail VM no longer owns a player. - if isPlayerFullscreen { - AppOrientationController.shared.unlockAfterFullscreen() - } - } - // Apple-Music-style centred HUD for action results - // (favorited / watch-later / playlist / subscribe / errors). - // .overlay so it floats on top of everything, including the - // player. allowsHitTesting(false) so it never blocks taps. - // The HUD self-dismisses 1.2s after appearing (see .task - // modifier on the inner view that's keyed on the message id). - .overlay(alignment: .center) { - if let actionMessage = viewModel.actionMessage { - AppleStyleHUD( - systemImage: actionMessage.systemImage, - message: actionMessage.message - ) - .transition( - .scale(scale: 0.85) - .combined(with: .opacity) - ) - .allowsHitTesting(false) - .task(id: actionMessage.id) { - // Auto-dismiss timer. The .task is keyed on the - // message id so consecutive HUDs (e.g. user - // mashes the favorite button) reset the timer - // rather than dismissing early. - try? await Task.sleep(nanoseconds: 1_200_000_000) - // Make sure we're still showing the same message; - // if the user fired another action mid-sleep, the - // task is cancelled and we don't clear theirs. - if viewModel.actionMessage?.id == actionMessage.id { - withAnimation(.easeOut(duration: 0.25)) { - viewModel.actionMessage = nil - } - } - } - } - } - .animation(.spring(response: 0.32, dampingFraction: 0.78), value: viewModel.actionMessage?.id) - .onValueChange(of: isPlayerFullscreen) { newValue in - // The fullscreen toggle button wraps `isPlayerFullscreen.toggle()` - // in `withAnimation(.easeInOut(duration: 0.25))` so the - // player frame can animate from inline 16:9 to fill-screen. - // If we synchronously trigger AppOrientationController here, - // UIKit fires a size-class / size change in the middle of - // SwiftUI's animation transaction, and SwiftUI cancels the - // running frame animation in favour of laying out for the - // new orientation — the user perceives this as the animation - // "going missing". Defer the orientation change until just - // after the SwiftUI animation has completed (~0.30s, slightly - // longer than the 0.25s curve to be safe). The player has - // already animated to its new size by then; the subsequent - // orientation rotation is its own UIKit-driven animation - // and doesn't fight with SwiftUI. - scheduleFullscreenOrientationUpdate(isFullscreen: newValue) - } - } - - /// Decides whether the player should rotate to landscape or stay in - /// portrait when entering fullscreen. Defaults to landscape (existing - /// behaviour); switches to portrait only when both: - /// 1. The video's reported natural size is taller than wide. - /// 2. The user has the "force portrait fullscreen for vertical - /// videos" preference enabled (default ON). - private var fullscreenOrientation: VideoFullscreenOrientation { - let isPortraitVideo: Bool = { - guard let size = videoNaturalSize else { return false } - return size.height > size.width - }() - if isPortraitVideo && forcePortraitForVerticalVideos { - return .portrait - } - return .landscape - } - - private var ignoredContainerSafeAreaEdges: Edge.Set { - isPlayerFullscreen ? .all : .bottom - } - - private var shouldShowRootCommentComposer: Bool { - guard !isPlayerFullscreen, selectedTab == .comments else { return false } - guard !isCommentInternalOverlayActive else { return false } - guard isCommentComposerReady else { return false } - if case .loaded = viewModel.state { - return true - } - return false - } - - private var isCommentComposerReady: Bool { - if case .loaded = commentViewModel.state { - return true - } - return false - } - - @ViewBuilder - private func rootCommentComposer(layoutSize: CGSize) -> some View { - if shouldShowRootCommentComposer { - CommentComposerBar( - text: $commentComposeText, - isSending: commentViewModel.runningActionIDs.contains("post-comment"), - isReady: isCommentComposerReady, - onSubmit: submitComment - ) - .frame(width: leftPanelWidth(for: layoutSize)) - .reportCommentComposerHeight() - .frame(maxWidth: .infinity, alignment: .leading) - .transition(.move(edge: .bottom).combined(with: .opacity)) - .zIndex(1) - } - } - - @ViewBuilder - private var content: some View { - switch viewModel.state { - case .idle, .loading: - ProgressView() - .frame(maxWidth: .infinity, maxHeight: .infinity) - case .failed(let message): - VStack(spacing: 12) { - Image(systemName: "wifi.exclamationmark") - .font(.largeTitle) - .foregroundStyle(.secondary) - Text("视频加载失败") - .font(.headline) - Text(message) - .font(.subheadline) - .foregroundStyle(.secondary) - .multilineTextAlignment(.center) - Button("重试") { - viewModel.load(videoCode: videoCode) - } - .buttonStyle(.borderedProminent) - CloudflareVerifyButton(errorMessage: message) - } - .padding(24) - .frame(maxWidth: .infinity, maxHeight: .infinity) - case .loaded(let snapshot): - // Bilibili-style iPad layout: an outer HStack with two slots. - // - Slot 0: a VStack (the "left panel") that hosts player + scroll. - // Player is ALWAYS the first child of this VStack at a stable tree - // position, so size-class flips never reparent it (which would - // rebuild @StateObject Coordinator + KSPlayerLayer → reload video). - // - Slot 1: the related-videos sidebar, only mounted on iPad regular - // landscape. Mounting/unmounting it does NOT touch slot 0. - // - // Phone / iPad portrait collapses to a single full-width left panel - // (no sidebar), giving the same visual as before for those modes. - GeometryReader { proxy in - let isWide = usesTabletRelatedSidebar(for: proxy.size) - let leftWidth = leftPanelWidth(for: proxy.size) - - HStack(alignment: .top, spacing: 0) { - ZStack(alignment: .top) { - let currentPlayerCollapseDistance = playerCollapseDistance(panelWidth: leftWidth) - let currentPlayerHeight = playerHeight( - panelWidth: leftWidth, - parentHeight: proxy.size.height - ) - let currentPlayerScrollAway = isPlayerPlaying - ? 0 - : playerVisualShrink(panelWidth: leftWidth) - let currentCollapseDistance = isPlayerPlaying - ? 0 - : currentPlayerCollapseDistance - let currentBottomScrollOffset = max(currentPlayerHeight - currentPlayerScrollAway, 0) - let currentBottomScrollHeight = proxy.size.height - currentBottomScrollOffset - let currentContinuationProgress = continuationStripProgress( - scrollAway: currentPlayerScrollAway, - panelWidth: leftWidth - ) - playerArea(snapshot: snapshot) - .frame( - width: leftWidth, - height: currentPlayerHeight - ) - - if !isPlayerFullscreen { - // showsRelated=false on iPad regular landscape because the - // dedicated right sidebar already shows related videos — - // duplicating them in the bottom scroll would be redundant. - belowPlayerScroll( - snapshot: snapshot, - showsRelated: !isWide, - collapseDistance: currentCollapseDistance, - collapseCompensation: currentPlayerScrollAway, - composerContentClearance: commentComposerContentClearance( - safeAreaBottom: proxy.safeAreaInsets.bottom - ) - ) - .frame(height: max(0, currentBottomScrollHeight)) - .offset(y: currentBottomScrollOffset) - } - - if !isPlayerFullscreen, - currentContinuationProgress > 0 { - continuePlayingStrip(snapshot: snapshot, progress: currentContinuationProgress) - .frame(width: leftWidth, height: playerContinuationStripHeight) - } - } - .frame(width: leftWidth, height: proxy.size.height, alignment: .top) - .clipped() - - if isWide { - Divider() - TabletRelatedSidebar( - videos: snapshot.relatedVideos, - videoFeature: videoFeature, - commentFeature: commentFeature - ) - .frame(maxWidth: .infinity, maxHeight: .infinity) - .background(Color(.systemBackground)) - } - } - } - .background(Color(.systemGroupedBackground)) - } - } - - private func usesTabletRelatedSidebar(for size: CGSize) -> Bool { - let isLandscape = currentInterfaceOrientation()?.isLandscape ?? (size.width > size.height) - return horizontalSizeClass == .regular - && size.width >= tabletLeftMinimumWidth + tabletSidebarMinimumWidth - && isLandscape - && !isPlayerFullscreen - } - - private func leftPanelWidth(for size: CGSize) -> CGFloat { - guard usesTabletRelatedSidebar(for: size) else { return size.width } - return min( - max(size.width * 0.64, tabletLeftMinimumWidth), - size.width - tabletSidebarMinimumWidth - ) - } - - /// Player 高度: - /// - 全屏:撑满整个父容器 - /// - inline:固定为左 panel 宽度的 16:9,不随底部内容滚动缩小 - private func playerHeight(panelWidth: CGFloat, parentHeight: CGFloat) -> CGFloat { - if isPlayerFullscreen { return parentHeight } - return panelWidth * 9 / 16 - } - - private func playerCollapseDistance(panelWidth: CGFloat) -> CGFloat { - max(panelWidth * 9 / 16 - playerContinuationStripHeight, 1) - } - - private func playerVisualShrink(panelWidth: CGFloat) -> CGFloat { - min(max(bottomScrollOffset, 0), playerCollapseDistance(panelWidth: panelWidth)) - } - - private func continuationStripProgress(scrollAway: CGFloat, panelWidth: CGFloat) -> CGFloat { - let collapseDistance = playerCollapseDistance(panelWidth: panelWidth) - let fadeDistance: CGFloat = 72 - guard collapseDistance > 1 else { return 0 } - return min(max((scrollAway - (collapseDistance - fadeDistance)) / fadeDistance, 0), 1) - } - - private func playerArea(snapshot: VideoDetailScreenSnapshot) -> some View { - return KSPlayerView( - snapshot: snapshot, - isFullscreen: $isPlayerFullscreen, - onProgress: { viewModel.recordPlaybackPosition(seconds: $0) }, - onPlaybackEnded: { viewModel.recordPlaybackPosition(seconds: 0) }, - onPlayingChanged: { newValue in - if isPlayerPlaying != newValue { - isPlayerPlaying = newValue - if newValue, abs(bottomScrollOffset) > 0.5 { - bottomScrollOffset = 0 - } - } - }, - onBack: { dismiss() }, - playRequestToken: playerPlayRequestToken, - onNaturalSize: { size in - videoNaturalSize = size - } - ) - } - - private func continuePlayingStrip(snapshot: VideoDetailScreenSnapshot, progress: CGFloat) -> some View { - Button { - withAnimation(.easeInOut(duration: 0.25)) { - bottomScrollOffset = 0 - } - playerPlayRequestToken &+= 1 - } label: { - HStack(spacing: 10) { - Image(systemName: "play.circle.fill") - .font(.title3) - Text("继续播放") - .font(.subheadline.weight(.semibold)) - Text(snapshot.title) - .font(.caption) - .lineLimit(1) - .foregroundStyle(.secondary) - Spacer(minLength: 0) - } - .foregroundStyle(.primary) - .padding(.horizontal, 16) - .frame(maxWidth: .infinity, maxHeight: .infinity) - .background(.ultraThinMaterial) - .contentShape(Rectangle()) - } - .buttonStyle(.plain) - .opacity(progress) - .offset(y: -8 * (1 - progress)) - } - - private func belowPlayerScroll( - snapshot: VideoDetailScreenSnapshot, - showsRelated: Bool, - collapseDistance: CGFloat, - collapseCompensation: CGFloat, - composerContentClearance: CGFloat - ) -> some View { - let contentRevision = tabContentRevision(snapshot: snapshot, showsRelated: showsRelated) - - return VStack(spacing: 0) { - Picker("Content", selection: $selectedTab) { - ForEach(VideoPageTab.allCases) { tab in - Text(tab.title).tag(tab) - } - } - .pickerStyle(.segmented) - .padding(.horizontal, 16) - .padding(.vertical, 8) - .background(.background) - - VideoDetailTabPager( - selectedTab: $selectedTab, - introduction: tabPage( - .introduction, - contentUpdateRevision: contentRevision.introduction - ) { - AndroidStyleIntroduction( - snapshot: snapshot, - videoFeature: videoFeature, - commentFeature: commentFeature, - isArtistActionRunning: viewModel.isActionRunning("artistSubscription"), - onToggleArtistSubscription: { viewModel.toggleArtistSubscription(snapshot: snapshot) }, - onToggleFavorite: { viewModel.toggleFavorite(snapshot: snapshot) }, - onToggleWatchLater: { viewModel.toggleWatchLater(snapshot: snapshot) }, - onSetMyListItem: { item, isSelected in viewModel.setMyListItem(snapshot: snapshot, item: item, isSelected: isSelected) }, - onShowMessage: { viewModel.showActionMessage($0) }, - showsRelated: showsRelated - ) - .padding(.top, 16) - } collapseCompensation: { - tabCollapseCompensation(for: .introduction, collapseCompensation: collapseCompensation) - } collapseDistance: { - collapseDistance - }, - comments: tabPage( - .comments, - contentBottomPadding: composerContentClearance, - contentUpdateRevision: contentRevision.comments - ) { - CommentView( - viewModel: commentViewModel, - onOverlayActivityChanged: { isActive in - isCommentInternalOverlayActive = isActive - } - ) - .padding(.top, 16) - } collapseCompensation: { - tabCollapseCompensation(for: .comments, collapseCompensation: collapseCompensation) - } collapseDistance: { - collapseDistance - } - ) - .frame(maxHeight: .infinity) - } - .frame(maxHeight: .infinity) - .background(Color(.systemGroupedBackground)) - .onValueChange(of: selectedTab) { _ in - lastSelectedTabChangeAt = Date() - bottomScrollOffset = min(max(bottomScrollOffset, 0), collapseDistance) - } - } - - private func updatePlayerCollapseOffset( - activeTabOffset: CGFloat, - previousActiveTabOffset: CGFloat?, - collapseDistance: CGFloat - ) { - guard !isPlayerPlaying else { - if abs(bottomScrollOffset) > 0.5 { - withTransaction(Transaction(animation: nil)) { - bottomScrollOffset = 0 - } - } - return - } - let nextOffset = VideoPlayerCollapseModel.nextCollapseOffset( - currentCollapseOffset: bottomScrollOffset, - activeTabOffset: activeTabOffset, - previousActiveTabOffset: previousActiveTabOffset, - collapseDistance: collapseDistance, - switchedTabsRecently: Date().timeIntervalSince(lastSelectedTabChangeAt) < 0.35 - ) - guard abs(bottomScrollOffset - nextOffset) > 0.5 else { return } - withTransaction(Transaction(animation: nil)) { - bottomScrollOffset = nextOffset - } - } - - private func submitComment() { - guard isCommentComposerReady else { return } - if commentViewModel.postComment(text: commentComposeText) { - commentComposeText = "" - } - } - - private func scheduleFullscreenOrientationUpdate(isFullscreen: Bool) { - fullscreenOrientationTask?.cancel() - fullscreenOrientationTask = Task { @MainActor in - try? await Task.sleep(nanoseconds: 300_000_000) - guard !Task.isCancelled, isPlayerFullscreen == isFullscreen else { return } - if isFullscreen { - AppOrientationController.shared.lockForFullscreen(to: fullscreenOrientation) - } else { - AppOrientationController.shared.unlockAfterFullscreen() - } - } - } - - private func tabPage( - _ tab: VideoPageTab, - contentBottomPadding: CGFloat = 24, - contentUpdateRevision: Int, - @ViewBuilder content: @escaping () -> Content, - collapseCompensation: @escaping () -> CGFloat = { 0 }, - collapseDistance: @escaping () -> CGFloat = { 0 } - ) -> VideoDetailTabPage { - VideoDetailTabPage( - tab: tab, - contentBottomPadding: contentBottomPadding, - collapseCompensation: collapseCompensation(), - collapseDistance: collapseDistance(), - contentUpdateRevision: contentUpdateRevision, - onOffsetChange: { tab, offset in - updateTabOffset(tab, offset: offset, collapseDistance: collapseDistance()) - }, - content: content - ) - } - - private func updateTabOffset(_ tab: VideoPageTab, offset: CGFloat, collapseDistance: CGFloat) { - let previousActiveOffset = bottomScrollOffsetsByTab[selectedTab] - withTransaction(Transaction(animation: nil)) { - bottomScrollOffsetsByTab[tab] = offset - guard tab == selectedTab else { return } - updatePlayerCollapseOffset( - activeTabOffset: offset, - previousActiveTabOffset: previousActiveOffset, - collapseDistance: collapseDistance - ) - } - } - - private func tabCollapseCompensation(for tab: VideoPageTab, collapseCompensation: CGFloat) -> CGFloat { - min(max(bottomScrollOffsetsByTab[tab] ?? 0, 0), collapseCompensation) - } - - private func commentComposerContentClearance(safeAreaBottom: CGFloat) -> CGFloat { - let containerBottomInset = currentWindowBottomSafeAreaInset() - let isKeyboardSafeAreaActive = safeAreaBottom > containerBottomInset + 1 - let composerHeight = max(commentComposerHeight, CommentComposerBar.compactHeight) - return composerHeight + (isKeyboardSafeAreaActive ? 0 : containerBottomInset) - } - - private func currentWindowBottomSafeAreaInset() -> CGFloat { - currentWindowScene()? - .windows - .first { $0.isKeyWindow }? - .safeAreaInsets - .bottom ?? 0 - } - - private func currentInterfaceOrientation() -> UIInterfaceOrientation? { - currentWindowScene()?.interfaceOrientation - } - - private func currentWindowScene() -> UIWindowScene? { - UIApplication.shared.connectedScenes - .compactMap { $0 as? UIWindowScene } - .first { $0.activationState == .foregroundActive } - } - - private func tabContentRevision( - snapshot: VideoDetailScreenSnapshot, - showsRelated: Bool - ) -> VideoDetailTabContentRevision { - var introductionHasher = Hasher() - introductionHasher.combine(showsRelated) - introductionHasher.combine(viewModel.isActionRunning("artistSubscription")) - snapshot.hash(into: &introductionHasher) - - var commentsHasher = Hasher() - commentsHasher.combine(ObjectIdentifier(commentViewModel)) - - return VideoDetailTabContentRevision( - introduction: introductionHasher.finalize(), - comments: commentsHasher.finalize() - ) - } -} - -private struct CommentComposerBar: View { - static let compactHeight: CGFloat = 49 - - @Binding var text: String - let isSending: Bool - let isReady: Bool - let onSubmit: () -> Void - @FocusState private var isFieldFocused: Bool - - private var canSubmit: Bool { - isReady && text.trimmingCharacters(in: .whitespacesAndNewlines).count >= 2 && !isSending - } - - var body: some View { - composerControls - .padding(.horizontal, 16) - .frame(minHeight: Self.compactHeight) - .frame(maxWidth: .infinity) - .commentComposerBarChrome() - .onDisappear { - isFieldFocused = false - } - } - - @ViewBuilder - private var composerControls: some View { - if #available(iOS 26.0, *) { - GlassEffectContainer(spacing: 10) { - composerControlRow - } - } else { - composerControlRow - } - } - - private var composerControlRow: some View { - HStack(spacing: 10) { - TextField("输入评论", text: $text, axis: .vertical) - .textFieldStyle(.plain) - .submitLabel(.send) - .lineLimit(1...4) - .focused($isFieldFocused) - .onSubmit { - guard canSubmit else { return } - onSubmit() - } - .padding(.horizontal, 14) - .padding(.vertical, 10) - .commentComposerFieldChrome() - .layoutPriority(1) - - Button(action: onSubmit) { - Image(systemName: isSending ? "hourglass" : "paperplane.fill") - .font(.headline) - .frame(width: 42, height: 42) - } - .disabled(!canSubmit) - .foregroundStyle(canSubmit ? Color.accentColor : Color.secondary) - .accessibilityLabel("发送评论") - .commentComposerSendButtonChrome(isEnabled: canSubmit) - } - } -} - -private struct CommentComposerHeightPreferenceKey: PreferenceKey { - static var defaultValue: CGFloat = 0 - static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) { - value = max(value, nextValue()) - } -} - -private extension View { - func reportCommentComposerHeight() -> some View { - background( - GeometryReader { proxy in - Color.clear.preference( - key: CommentComposerHeightPreferenceKey.self, - value: proxy.size.height - ) - } - ) - } - - @ViewBuilder - func commentComposerFieldChrome() -> some View { - if #available(iOS 26.0, *) { - contentShape(Capsule()) - .glassEffect(.regular.interactive(), in: Capsule()) - } else { - background(Color(.secondarySystemBackground), in: Capsule()) - .overlay { - Capsule() - .strokeBorder(Color.secondary.opacity(0.16), lineWidth: 0.5) - } - } - } - - @ViewBuilder - func commentComposerSendButtonChrome(isEnabled: Bool) -> some View { - if #available(iOS 26.0, *) { - buttonStyle(.plain) - .contentShape(Circle()) - .glassEffect(.regular.interactive(isEnabled), in: Circle()) - } else { - buttonStyle(.plain) - } - } - - @ViewBuilder - func commentComposerBarChrome() -> some View { - if #available(iOS 26.0, *) { - background(.clear) - } else { - background(.bar) - .overlay(alignment: .top) { - Divider() - } - } - } -} - -private enum VideoPlayerCollapseModel { - static func nextCollapseOffset( - currentCollapseOffset: CGFloat, - activeTabOffset: CGFloat, - previousActiveTabOffset: CGFloat?, - collapseDistance: CGFloat, - switchedTabsRecently: Bool - ) -> CGFloat { - let clampedCurrent = clamp(currentCollapseOffset, upperBound: collapseDistance) - - if switchedTabsRecently { - return clampedCurrent - } - - guard let previousActiveTabOffset else { - let nonnegativeActiveOffset = max(0, activeTabOffset) - return min(collapseDistance, max(clampedCurrent, nonnegativeActiveOffset)) - } - - let delta = activeTabOffset - previousActiveTabOffset - if abs(delta) > 0.5 { - return clamp(clampedCurrent + delta, upperBound: collapseDistance) - } - - return clampedCurrent - } - - private static func clamp(_ value: CGFloat, upperBound: CGFloat) -> CGFloat { - min(max(value, 0), max(upperBound, 0)) - } -} - -private enum VideoPageTab: String, CaseIterable, Identifiable { - case introduction - case comments - - var id: String { rawValue } - - var pageIndex: Int { - switch self { - case .introduction: return 0 - case .comments: return 1 - } - } - - static func page(at index: Int) -> VideoPageTab { - index <= 0 ? .introduction : .comments - } - - var scrollCoordinateSpaceName: String { - "bottomScroll-\(rawValue)" - } - - var title: String { - switch self { - case .introduction: - return String(localized: "简介") - case .comments: - return String(localized: "评论") - } - } -} - -private struct VideoDetailTabContentRevision: Equatable { - let introduction: Int - let comments: Int -} - -private struct VideoDetailTabPage { - let tab: VideoPageTab - let contentBottomPadding: CGFloat - let collapseCompensation: CGFloat - let collapseDistance: CGFloat - let contentUpdateRevision: Int - let onOffsetChange: (VideoPageTab, CGFloat) -> Void - let content: () -> AnyView - - init( - tab: VideoPageTab, - contentBottomPadding: CGFloat, - collapseCompensation: CGFloat, - collapseDistance: CGFloat, - contentUpdateRevision: Int, - onOffsetChange: @escaping (VideoPageTab, CGFloat) -> Void, - @ViewBuilder content: @escaping () -> Content - ) { - self.tab = tab - self.contentBottomPadding = contentBottomPadding - self.collapseCompensation = collapseCompensation - self.collapseDistance = collapseDistance - self.contentUpdateRevision = contentUpdateRevision - self.onOffsetChange = onOffsetChange - self.content = { AnyView(content()) } - } -} - -private final class VideoDetailVerticalScrollPageCoordinator: NSObject, UIScrollViewDelegate { - var tab: VideoPageTab - var onOffsetChange: (VideoPageTab, CGFloat) -> Void - private var lastReportedOffset: CGFloat? - - init(tab: VideoPageTab, onOffsetChange: @escaping (VideoPageTab, CGFloat) -> Void) { - self.tab = tab - self.onOffsetChange = onOffsetChange - } - - func scrollViewDidScroll(_ scrollView: UIScrollView) { - let offset = scrollView.verticalContentOffsetExcludingBounce - guard lastReportedOffset.map({ abs($0 - offset) > 0.5 }) ?? true else { return } - lastReportedOffset = offset - onOffsetChange(tab, offset) - } -} - -private final class VideoDetailVerticalScrollPageViewController: UIViewController { - private let coordinator: VideoDetailVerticalScrollPageCoordinator - private let scrollView = VerticalScrollView() - private let contentView = UIView() - private let bottomPaddingView = UIView() - private let collapseSpacerView = UIView() - private let host = UIHostingController(rootView: AnyView(EmptyView())) - private var hostTopConstraint: NSLayoutConstraint! - private var hostMinimumHeightConstraint: NSLayoutConstraint! - private var bottomPaddingHeightConstraint: NSLayoutConstraint! - private var collapseSpacerHeightConstraint: NSLayoutConstraint! - private var contentUpdateRevision: Int? - - init(tab: VideoPageTab) { - coordinator = VideoDetailVerticalScrollPageCoordinator(tab: tab, onOffsetChange: { _, _ in }) - super.init(nibName: nil, bundle: nil) - } - - @available(*, unavailable) - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - override func viewDidLoad() { - super.viewDidLoad() - view.backgroundColor = .clear - - scrollView.translatesAutoresizingMaskIntoConstraints = false - scrollView.backgroundColor = .clear - scrollView.bounces = false - scrollView.alwaysBounceVertical = false - scrollView.alwaysBounceHorizontal = false - scrollView.showsHorizontalScrollIndicator = false - scrollView.showsVerticalScrollIndicator = false - scrollView.isDirectionalLockEnabled = true - scrollView.contentInsetAdjustmentBehavior = .never - scrollView.keyboardDismissMode = .interactive - scrollView.delegate = coordinator - scrollView.shouldBeginVerticalPan = { panGestureRecognizer, view in - let velocity = panGestureRecognizer.velocity(in: view) - guard abs(velocity.x) > abs(velocity.y) * 1.05 else { return true } - let startLocation = panGestureRecognizer.location(in: view) - return !view.hasScrollableHorizontalDescendant(at: startLocation, excluding: view) - } - view.addSubview(scrollView) - - contentView.translatesAutoresizingMaskIntoConstraints = false - contentView.backgroundColor = .clear - scrollView.addSubview(contentView) - - addChild(host) - host.view.translatesAutoresizingMaskIntoConstraints = false - host.view.backgroundColor = .clear - contentView.addSubview(host.view) - host.didMove(toParent: self) - - bottomPaddingView.translatesAutoresizingMaskIntoConstraints = false - bottomPaddingView.backgroundColor = .clear - contentView.addSubview(bottomPaddingView) - - collapseSpacerView.translatesAutoresizingMaskIntoConstraints = false - collapseSpacerView.backgroundColor = .clear - contentView.addSubview(collapseSpacerView) - - hostTopConstraint = host.view.topAnchor.constraint(equalTo: contentView.topAnchor) - hostMinimumHeightConstraint = host.view.heightAnchor.constraint(greaterThanOrEqualTo: scrollView.frameLayoutGuide.heightAnchor) - bottomPaddingHeightConstraint = bottomPaddingView.heightAnchor.constraint(equalToConstant: 24) - collapseSpacerHeightConstraint = collapseSpacerView.heightAnchor.constraint(equalToConstant: 1) - - NSLayoutConstraint.activate([ - scrollView.leadingAnchor.constraint(equalTo: view.leadingAnchor), - scrollView.trailingAnchor.constraint(equalTo: view.trailingAnchor), - scrollView.topAnchor.constraint(equalTo: view.topAnchor), - scrollView.bottomAnchor.constraint(equalTo: view.bottomAnchor), - - contentView.leadingAnchor.constraint(equalTo: scrollView.contentLayoutGuide.leadingAnchor), - contentView.trailingAnchor.constraint(equalTo: scrollView.contentLayoutGuide.trailingAnchor), - contentView.topAnchor.constraint(equalTo: scrollView.contentLayoutGuide.topAnchor), - contentView.bottomAnchor.constraint(equalTo: scrollView.contentLayoutGuide.bottomAnchor), - contentView.widthAnchor.constraint(equalTo: scrollView.frameLayoutGuide.widthAnchor), - - host.view.leadingAnchor.constraint(equalTo: contentView.leadingAnchor), - host.view.trailingAnchor.constraint(equalTo: contentView.trailingAnchor), - hostTopConstraint, - hostMinimumHeightConstraint, - - bottomPaddingView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor), - bottomPaddingView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor), - bottomPaddingView.topAnchor.constraint(equalTo: host.view.bottomAnchor), - bottomPaddingHeightConstraint, - - collapseSpacerView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor), - collapseSpacerView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor), - collapseSpacerView.topAnchor.constraint(equalTo: bottomPaddingView.bottomAnchor), - collapseSpacerView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor), - collapseSpacerHeightConstraint - ]) - } - - func update(page: VideoDetailTabPage) { - loadViewIfNeeded() - coordinator.tab = page.tab - coordinator.onOffsetChange = page.onOffsetChange - if contentUpdateRevision != page.contentUpdateRevision { - contentUpdateRevision = page.contentUpdateRevision - host.rootView = page.content() - } - - hostTopConstraint.constant = 0 - host.view.transform = CGAffineTransform(translationX: 0, y: page.collapseCompensation) - bottomPaddingView.transform = CGAffineTransform(translationX: 0, y: page.collapseCompensation) - bottomPaddingHeightConstraint.constant = page.contentBottomPadding - collapseSpacerHeightConstraint.constant = page.collapseDistance + 1 - } -} - -private final class VerticalScrollView: UIScrollView { - var shouldBeginVerticalPan: ((UIPanGestureRecognizer, UIView) -> Bool)? - - override func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool { - if let panGestureRecognizer = gestureRecognizer as? UIPanGestureRecognizer, - panGestureRecognizer === self.panGestureRecognizer { - return shouldBeginVerticalPan?(panGestureRecognizer, self) ?? true - } - return super.gestureRecognizerShouldBegin(gestureRecognizer) - } -} - -private extension UIScrollView { - var verticalContentOffsetExcludingBounce: CGFloat { - let inset = adjustedContentInset - let minOffsetY = -inset.top - let maxOffsetY = max(minOffsetY, contentSize.height - bounds.height + inset.bottom) - return min(max(contentOffset.y, minOffsetY), maxOffsetY) + inset.top - } -} - -private struct VideoDetailTabPager: UIViewControllerRepresentable { - @Binding var selectedTab: VideoPageTab - let introduction: VideoDetailTabPage - let comments: VideoDetailTabPage - - init( - selectedTab: Binding, - introduction: VideoDetailTabPage, - comments: VideoDetailTabPage - ) { - _selectedTab = selectedTab - self.introduction = introduction - self.comments = comments - } - - func makeCoordinator() -> Coordinator { - Coordinator(selectedTab: $selectedTab) - } - - func makeUIViewController(context: Context) -> PagingViewController { - PagingViewController(coordinator: context.coordinator) - } - - func updateUIViewController(_ uiViewController: PagingViewController, context: Context) { - context.coordinator.selectedTab = $selectedTab - uiViewController.updatePages( - introduction: introduction, - comments: comments, - selectedIndex: selectedTab.pageIndex, - animated: context.coordinator.shouldAnimateProgrammaticSelection(to: selectedTab.pageIndex) - ) - } - - final class Coordinator: NSObject, UIScrollViewDelegate { - var selectedTab: Binding - private var lastProgrammaticIndex: Int? - - init(selectedTab: Binding) { - self.selectedTab = selectedTab - } - - func shouldAnimateProgrammaticSelection(to index: Int) -> Bool { - defer { lastProgrammaticIndex = index } - guard let lastProgrammaticIndex else { return false } - return lastProgrammaticIndex != index - } - - func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) { - updateSelectedTab(from: scrollView) - } - - func scrollViewDidEndScrollingAnimation(_ scrollView: UIScrollView) { - updateSelectedTab(from: scrollView) - } - - func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) { - if !decelerate { - updateSelectedTab(from: scrollView) - } - } - - func shouldBeginHorizontalPagingPan( - panGestureRecognizer: UIPanGestureRecognizer, - in view: UIView - ) -> Bool { - let startLocation = panGestureRecognizer.location(in: view) - guard startLocation.x > 24 else { return false } - guard !view.hasScrollableHorizontalDescendant(at: startLocation, excluding: view) else { - return false - } - - let translation = panGestureRecognizer.translation(in: view) - let velocity = panGestureRecognizer.velocity(in: view) - let horizontal = max(abs(translation.x), abs(velocity.x) * 0.05) - let vertical = max(abs(translation.y), abs(velocity.y) * 0.05) - return horizontal > 8 && horizontal > vertical * 1.18 - } - - private func updateSelectedTab(from scrollView: UIScrollView) { - let width = scrollView.bounds.width - guard width > 0 else { return } - let index = Int(round(scrollView.contentOffset.x / width)) - let tab = VideoPageTab.page(at: index) - if selectedTab.wrappedValue != tab { - selectedTab.wrappedValue = tab - } - } - } - - final class PagingViewController: UIViewController { - private let coordinator: Coordinator - private let scrollView = PagingScrollView() - private let contentView = UIView() - private let introductionPage = VideoDetailVerticalScrollPageViewController(tab: .introduction) - private let commentsPage = VideoDetailVerticalScrollPageViewController(tab: .comments) - private var selectedIndex = 0 - private var pendingSelectedIndex: Int? - - init(coordinator: Coordinator) { - self.coordinator = coordinator - super.init(nibName: nil, bundle: nil) - } - - @available(*, unavailable) - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - override func viewDidLoad() { - super.viewDidLoad() - view.backgroundColor = .clear - - scrollView.translatesAutoresizingMaskIntoConstraints = false - scrollView.backgroundColor = .clear - scrollView.isPagingEnabled = true - scrollView.bounces = false - scrollView.alwaysBounceHorizontal = false - scrollView.alwaysBounceVertical = false - scrollView.showsHorizontalScrollIndicator = false - scrollView.showsVerticalScrollIndicator = false - scrollView.isDirectionalLockEnabled = true - scrollView.contentInsetAdjustmentBehavior = .never - scrollView.delegate = coordinator - scrollView.shouldBeginPagingPan = { [weak self] panGestureRecognizer, view in - self?.coordinator.shouldBeginHorizontalPagingPan( - panGestureRecognizer: panGestureRecognizer, - in: view - ) ?? true - } - view.addSubview(scrollView) - - contentView.translatesAutoresizingMaskIntoConstraints = false - contentView.backgroundColor = .clear - scrollView.addSubview(contentView) - - addPage(introductionPage) - addPage(commentsPage) - - NSLayoutConstraint.activate([ - scrollView.leadingAnchor.constraint(equalTo: view.leadingAnchor), - scrollView.trailingAnchor.constraint(equalTo: view.trailingAnchor), - scrollView.topAnchor.constraint(equalTo: view.topAnchor), - scrollView.bottomAnchor.constraint(equalTo: view.bottomAnchor), - - contentView.leadingAnchor.constraint(equalTo: scrollView.contentLayoutGuide.leadingAnchor), - contentView.trailingAnchor.constraint(equalTo: scrollView.contentLayoutGuide.trailingAnchor), - contentView.topAnchor.constraint(equalTo: scrollView.contentLayoutGuide.topAnchor), - contentView.bottomAnchor.constraint(equalTo: scrollView.contentLayoutGuide.bottomAnchor), - contentView.heightAnchor.constraint(equalTo: scrollView.frameLayoutGuide.heightAnchor), - - introductionPage.view.leadingAnchor.constraint(equalTo: contentView.leadingAnchor), - introductionPage.view.topAnchor.constraint(equalTo: contentView.topAnchor), - introductionPage.view.bottomAnchor.constraint(equalTo: contentView.bottomAnchor), - introductionPage.view.widthAnchor.constraint(equalTo: scrollView.frameLayoutGuide.widthAnchor), - introductionPage.view.heightAnchor.constraint(equalTo: scrollView.frameLayoutGuide.heightAnchor), - - commentsPage.view.leadingAnchor.constraint(equalTo: introductionPage.view.trailingAnchor), - commentsPage.view.trailingAnchor.constraint(equalTo: contentView.trailingAnchor), - commentsPage.view.topAnchor.constraint(equalTo: contentView.topAnchor), - commentsPage.view.bottomAnchor.constraint(equalTo: contentView.bottomAnchor), - commentsPage.view.widthAnchor.constraint(equalTo: scrollView.frameLayoutGuide.widthAnchor), - commentsPage.view.heightAnchor.constraint(equalTo: scrollView.frameLayoutGuide.heightAnchor) - ]) - } - - override func viewDidLayoutSubviews() { - super.viewDidLayoutSubviews() - if let pendingSelectedIndex { - self.pendingSelectedIndex = nil - setSelectedIndex(pendingSelectedIndex, animated: false) - } else { - setSelectedIndex(selectedIndex, animated: false) - } - } - - func updatePages( - introduction: VideoDetailTabPage, - comments: VideoDetailTabPage, - selectedIndex: Int, - animated: Bool - ) { - loadViewIfNeeded() - introductionPage.update(page: introduction) - commentsPage.update(page: comments) - setSelectedIndex(selectedIndex, animated: animated) - } - - private func addPage(_ page: UIViewController) { - addChild(page) - page.view.translatesAutoresizingMaskIntoConstraints = false - page.view.backgroundColor = .clear - contentView.addSubview(page.view) - page.didMove(toParent: self) - } - - private func setSelectedIndex(_ index: Int, animated: Bool) { - selectedIndex = min(max(index, 0), VideoPageTab.allCases.count - 1) - let width = scrollView.bounds.width - guard width > 0 else { - pendingSelectedIndex = selectedIndex - return - } - let targetOffset = CGPoint(x: CGFloat(selectedIndex) * width, y: 0) - guard abs(scrollView.contentOffset.x - targetOffset.x) > 0.5 else { return } - scrollView.setContentOffset(targetOffset, animated: animated) - } - } - - final class PagingScrollView: UIScrollView { - var shouldBeginPagingPan: ((UIPanGestureRecognizer, UIView) -> Bool)? - - override func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool { - if let panGestureRecognizer = gestureRecognizer as? UIPanGestureRecognizer, - panGestureRecognizer === self.panGestureRecognizer { - return shouldBeginPagingPan?(panGestureRecognizer, self) ?? true - } - return super.gestureRecognizerShouldBegin(gestureRecognizer) - } - } -} - -private extension UIView { - func hasScrollableHorizontalDescendant(at location: CGPoint, excluding excludedView: UIView) -> Bool { - guard let hitView = hitTest(location, with: nil) else { return false } - var current: UIView? = hitView - while let view = current, view !== excludedView { - if let scrollView = view as? UIScrollView, - scrollView.isScrollEnabled, - scrollView.panGestureRecognizer.isEnabled, - scrollView.contentSize.width > scrollView.bounds.width + 1, - scrollView.contentSize.width > scrollView.contentSize.height { - return true - } - current = view.superview - } - return false - } -} - -private extension VideoDetailScreenSnapshot { - func hash(into hasher: inout Hasher) { - hasher.combine(videoCode) - hasher.combine(title) - hasher.combine(chineseTitle) - hasher.combine(videoDescription) - hasher.combine(views) - hasher.combine(tagSummary) - hasher.combine(sourceCount) - hasher.combine(defaultSourceLabel) - hasher.combine(defaultSourceUrl) - hasher.combine(uploadDate) - hasher.combine(coverUrl) - hasher.combine(artist) - hasher.combine(favTimes) - hasher.combine(isFav) - hasher.combine(csrfToken) - hasher.combine(currentUserId) - hasher.combine(isWatchLater) - hasher.combine(originalComic) - hasher.combine(playbackPositionMillis) - hasher.combine(tags) - hasher.combine(playbackSources) - hasher.combine(playlistName) - playlistVideos.forEach { $0.hash(into: &hasher) } - hasher.combine(myListItems) - relatedVideos.forEach { $0.hash(into: &hasher) } - } -} - -private extension VideoRelatedRow { - func hash(into hasher: inout Hasher) { - hasher.combine(videoCode) - hasher.combine(title) - hasher.combine(coverUrl) - hasher.combine(duration) - hasher.combine(views) - hasher.combine(artist) - hasher.combine(uploadTime) - hasher.combine(isPlaying) - } -} - -struct TapOnlyControl: View { - let isDisabled: Bool - let action: () -> Void - let label: () -> Label - - init( - isDisabled: Bool = false, - action: @escaping () -> Void, - @ViewBuilder label: @escaping () -> Label - ) { - self.isDisabled = isDisabled - self.action = action - self.label = label - } - - var body: some View { - label() - .opacity(isDisabled ? 0.45 : 1) - .contentShape(Rectangle()) - .onTapGesture { - guard !isDisabled else { return } - action() - } - .accessibilityAddTraits(.isButton) - .accessibilityAction { - guard !isDisabled else { return } - action() - } - } -} - -private struct AndroidStyleIntroduction: View { - let snapshot: VideoDetailScreenSnapshot - let videoFeature: VideoFeature - let commentFeature: CommentFeature - let isArtistActionRunning: Bool - let onToggleArtistSubscription: () -> Void - let onToggleFavorite: () -> Void - let onToggleWatchLater: () -> Void - let onSetMyListItem: (VideoMyListRow, Bool) -> Void - let onShowMessage: (String) -> Void - let showsRelated: Bool - - var body: some View { - VStack(alignment: .leading, spacing: 16) { - if let artist = snapshot.artist { - ArtistCard( - artist: artist, - isRunning: isArtistActionRunning, - toggleAction: onToggleArtistSubscription, - videoFeature: videoFeature, - commentFeature: commentFeature - ) - } - - TitleBlock(snapshot: snapshot) - MetadataRow(snapshot: snapshot) - - if let description = snapshot.videoDescription, !description.isEmpty { - ExpandableDescription(text: description) - } - - ActionButtonRow( - snapshot: snapshot, - onToggleFavorite: onToggleFavorite, - onToggleWatchLater: onToggleWatchLater, - onSetMyListItem: onSetMyListItem, - onShowMessage: onShowMessage, - onDownload: { source in - DownloadManager.shared.enqueue( - videoCode: snapshot.videoCode, - quality: source.label, - title: snapshot.title, - coverUrl: snapshot.coverUrl, - remoteUrl: source.url - ) - onShowMessage(String(localized: "已加入下载")) - } - ) - - if !snapshot.tags.isEmpty { - TagFlow( - tags: snapshot.tags, - videoFeature: videoFeature, - commentFeature: commentFeature - ) - } - - if !snapshot.playlistVideos.isEmpty { - HorizontalVideoSection( - title: "系列影片", - subtitle: snapshot.playlistName, - videos: snapshot.playlistVideos, - videoFeature: videoFeature, - commentFeature: commentFeature, - showPlaying: true, - showsMetadataFooter: false - ) - } - - if showsRelated && !snapshot.relatedVideos.isEmpty { - RelatedVideoGrid( - videos: snapshot.relatedVideos, - videoFeature: videoFeature, - commentFeature: commentFeature - ) - } - } - .padding(.horizontal, 16) - } -} - -private struct ArtistCard: View { - let artist: VideoArtistRow - let isRunning: Bool - let toggleAction: () -> Void - let videoFeature: VideoFeature - let commentFeature: CommentFeature - @Environment(\.searchFeature) private var searchFeature - @State private var isConfirmingUnsubscribe = false - @State private var isShowingArtistVideos = false - - var body: some View { - HStack(spacing: 12) { - // Artist avatar / name / genre — tap to push the artist's videos - // page (NavigationLink). Subscription button to the right is - // independent and remains tap-able while the rest of the card - // navigates. - artistInfoTappable - - Spacer() - - TapOnlyControl(isDisabled: isRunning) { - if artist.isSubscribed { - isConfirmingUnsubscribe = true - } else { - toggleAction() - } - } label: { - - Text(artist.isSubscribed ? "已订阅" : "订阅") - .font(.subheadline.weight(.semibold)) - .padding(.horizontal, 14) - .padding(.vertical, 7) - .background(Color.accentColor.opacity(0.14), in: Capsule()) - } - } - .padding(12) - .background(Color(.secondarySystemGroupedBackground), in: RoundedRectangle(cornerRadius: 12, style: .continuous)) - .confirmationDialog("取消订阅该作者", isPresented: $isConfirmingUnsubscribe) { - Button("取消订阅", role: .destructive) { - toggleAction() - } - Button("不取消", role: .cancel) {} - } message: { - Text("确定要取消订阅吗?") - } - .navigationDestination(isPresented: $isShowingArtistVideos) { - if let searchFeature { - ArtistVideosView( - artistName: artist.name, - searchFeature: searchFeature, - videoFeature: videoFeature, - commentFeature: commentFeature - ) - } - } - } - - /// Wraps the avatar + name + genre block in a NavigationLink that pushes - /// the artist's video list. Falls back to a non-tappable label if the - /// SearchFeature isn't available in the environment (shouldn't happen in - /// production but keeps the view robust during previews / testing). - @ViewBuilder - private var artistInfoTappable: some View { - if searchFeature != nil { - TapOnlyControl { - isShowingArtistVideos = true - } label: { - artistInfoLabel - } - } else { - artistInfoLabel - } - } - - private var artistInfoLabel: some View { - HStack(spacing: 12) { - AsyncImage(url: artist.avatarURL) { image in - image.resizable().scaledToFill() - } placeholder: { - Circle().fill(Color.secondary.opacity(0.15)) - } - .frame(width: 52, height: 52) - .clipShape(Circle()) - - VStack(alignment: .leading, spacing: 4) { - Text(artist.name) - .font(.headline) - .foregroundStyle(.primary) - .lineLimit(1) - if let genre = artist.genre, !genre.isEmpty { - Text(genre) - .font(.subheadline) - .foregroundStyle(.secondary) - } - } - } - .contentShape(Rectangle()) - } -} - -private struct TitleBlock: View { - let snapshot: VideoDetailScreenSnapshot - - var body: some View { - VStack(alignment: .leading, spacing: 6) { - Text(snapshot.chineseTitle?.isEmpty == false ? snapshot.chineseTitle! : snapshot.title) - .font(.title2.weight(.bold)) - .foregroundStyle(.primary) - .textSelection(.enabled) - - if let chineseTitle = snapshot.chineseTitle, !chineseTitle.isEmpty, chineseTitle != snapshot.title { - Text(snapshot.title) - .font(.headline) - .foregroundStyle(.secondary) - .textSelection(.enabled) - } - } - } -} - -private struct MetadataRow: View { - let snapshot: VideoDetailScreenSnapshot - - var body: some View { - HStack(spacing: 8) { - if let views = snapshot.views, !views.isEmpty { - Text(String(format: String(localized: "video.views.count"), views)) - } - if snapshot.views?.isEmpty == false && snapshot.uploadDate?.isEmpty == false { - Divider() - .frame(height: 16) - } - if let uploadDate = snapshot.uploadDate, !uploadDate.isEmpty { - Text(uploadDate) - } - } - .font(.subheadline) - .foregroundStyle(.secondary) - } -} - -private struct ExpandableDescription: View { - let text: String - @State private var expanded = false - - var body: some View { - VStack(alignment: .leading, spacing: 8) { - Text(text) - .font(.body) - .foregroundStyle(.primary) - .lineLimit(expanded ? nil : 4) - .textSelection(.enabled) - - TapOnlyControl { - withAnimation(.easeInOut(duration: 0.18)) { - expanded.toggle() - } - } label: { - Text(expanded ? String(localized: "收起") : String(localized: "展开")) - } - .font(.caption.weight(.semibold)) - } - } -} - -private struct ActionButtonRow: View { - let snapshot: VideoDetailScreenSnapshot - let onToggleFavorite: () -> Void - let onToggleWatchLater: () -> Void - let onSetMyListItem: (VideoMyListRow, Bool) -> Void - let onShowMessage: (String) -> Void - /// Invoked when the user picks a concrete quality to download. - let onDownload: (VideoPlaybackSourceRow) -> Void - @Environment(\.openURL) private var openURL - @State private var isShowingMyList = false - @State private var isShowingMoreActions = false - @State private var isShowingShareSheet = false - @State private var isShowingDownloadQuality = false - - private var videoURL: URL? { - siteURL(path: "/watch") - } - - private var downloadURL: URL? { - siteURL(path: "/download") - } - - /// Real downloadable sources (a concrete resolution + a usable URL). - /// A lone "auto" source means the page only exposed a JS-extracted - /// single URL with no resolution choices — in that case we fall back - /// to opening the official download page instead of in-app download. - private var downloadableSources: [VideoPlaybackSourceRow] { - snapshot.playbackSources.filter { $0.label.uppercased() != "AUTO" && !$0.url.isEmpty } - } - - private func siteURL(path: String) -> URL? { - guard var components = URLComponents(string: AppDomain.currentBaseURL) else { - return nil - } - components.path = path - components.queryItems = [ - URLQueryItem(name: "v", value: snapshot.videoCode) - ] - return components.url - } - - var body: some View { - HStack(spacing: 6) { - LabelButton( - title: snapshot.isFav ? "已收藏" : "收藏", - systemImage: snapshot.isFav ? "heart.fill" : "heart", - action: onToggleFavorite - ) - - LabelButton( - title: snapshot.isWatchLater ? "已稍后" : "稍后观看", - systemImage: "text.badge.plus", - action: onToggleWatchLater - ) - - LabelButton( - title: "更多", - systemImage: "ellipsis.circle", - action: { - isShowingMoreActions = true - } - ) - - LabelButton( - title: "分享", - systemImage: "square.and.arrow.up", - action: { - if videoURL != nil { - isShowingShareSheet = true - } - } - ) - } - .confirmationDialog("更多操作", isPresented: $isShowingMoreActions, titleVisibility: .visible) { - Button("加入列表") { - if snapshot.myListItems.isEmpty { - onShowMessage(String(localized: "video.action.playlist.empty")) - } else { - isShowingMyList = true - } - } - - Button("下载") { - if downloadableSources.isEmpty { - // No selectable resolutions parsed — defer to the - // site's official download page in the browser. - if let downloadURL { openURL(downloadURL) } - } else { - isShowingDownloadQuality = true - } - } - - if snapshot.originalComic?.isEmpty == false { - Button("原作漫画") { - if let originalComic = snapshot.originalComic, - let url = URL(string: originalComic) { - openURL(url) - } - } - } - - Button("网页") { - if let videoURL { - openURL(videoURL) - } - } - - Button("取消", role: .cancel) {} - } - .confirmationDialog("播放列表", isPresented: $isShowingMyList) { - ForEach(snapshot.myListItems) { item in - Button(String(format: NSLocalizedString(item.isSelected ? "video.playlist.remove_item" : "video.playlist.add_item", comment: ""), item.title)) { - onSetMyListItem(item, !item.isSelected) - } - } - } - .sheet(isPresented: $isShowingShareSheet) { - if let videoURL { - ActivityView(activityItems: [videoURL]) - } - } - .confirmationDialog("选择下载画质", isPresented: $isShowingDownloadQuality, titleVisibility: .visible) { - ForEach(downloadableSources) { source in - Button(source.label) { - onDownload(source) - } - } - } - } -} - -private struct ActivityView: UIViewControllerRepresentable { - let activityItems: [Any] - - func makeUIViewController(context: Context) -> UIActivityViewController { - let controller = UIActivityViewController(activityItems: activityItems, applicationActivities: nil) - controller.popoverPresentationController?.sourceView = controller.view - controller.popoverPresentationController?.sourceRect = CGRect( - x: controller.view.bounds.midX, - y: controller.view.bounds.midY, - width: 0, - height: 0 - ) - return controller - } - - func updateUIViewController(_ uiViewController: UIActivityViewController, context: Context) {} -} - -private struct LabelButton: View { - let title: String - let systemImage: String - var action: () -> Void = {} - - var body: some View { - TapOnlyControl(action: action) { - LabelButtonContent(title: title, systemImage: systemImage) - } - .frame(maxWidth: .infinity) - } -} - -private struct LabelButtonContent: View { - let title: String - let systemImage: String - - var body: some View { - VStack(spacing: 6) { - Image(systemName: systemImage) - .font(.title3) - Text(title) - .font(.caption) - .lineLimit(1) - } - .frame(maxWidth: .infinity) - .padding(.vertical, 8) - } -} - -private struct TagFlow: View { - let tags: [String] - let videoFeature: VideoFeature - let commentFeature: CommentFeature - @Environment(\.searchFeature) private var searchFeature - @State private var selectedTag: String? - - var body: some View { - VStack(alignment: .leading, spacing: 8) { - Text("标签") - .font(.headline) - // Custom Layout that flows tags onto each row according to their - // measured width, wrapping when the next tag wouldn't fit. Avoids - // the rigid grid look of LazyVGrid where every tag occupies the - // same column width. - FlowLayout(spacing: 8, lineSpacing: 8) { - ForEach(Array(tags.enumerated()), id: \.offset) { _, tag in - if searchFeature != nil { - TapOnlyControl { - selectedTag = tag - } label: { - TagChipText(tag: tag) - .padding(.horizontal, 12) - .padding(.vertical, 7) - .background( - Capsule() - .strokeBorder(Color.accentColor.opacity(0.45), lineWidth: 1) - ) - } - } else { - // Defensive: if the search feature isn't injected ( - // which shouldn't happen in production) the tag still - // renders as a disabled bordered chip rather than - // disappearing entirely. - TagChipText(tag: tag) - .padding(.horizontal, 12) - .padding(.vertical, 7) - .foregroundStyle(.secondary) - .background( - Capsule() - .strokeBorder(Color.secondary.opacity(0.25), lineWidth: 1) - ) - } - } - } - } - .navigationDestination( - isPresented: Binding( - get: { selectedTag != nil }, - set: { if !$0 { selectedTag = nil } } - ) - ) { - if let selectedTag, let searchFeature { - ArtistVideosView( - title: "#\(selectedTag)", - mode: .keyword(selectedTag), - searchFeature: searchFeature, - videoFeature: videoFeature, - commentFeature: commentFeature - ) - } - } - } -} - -private struct TagChipText: View { - let tag: String - - var body: some View { - Text(tag) - .font(.caption) - .lineLimit(1) - .truncationMode(.tail) - .frame(maxWidth: 240, alignment: .leading) - } -} - -/// Lightweight flow layout: lays out subviews left-to-right, wrapping to a -/// new line when a child wouldn't fit on the current one. Each child takes -/// its natural intrinsic width, so different-length labels pack tightly. -private struct FlowLayout: Layout { - var spacing: CGFloat = 8 - var lineSpacing: CGFloat = 8 - - func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize { - let maxWidth = proposal.width ?? .infinity - let result = arrange(in: maxWidth, subviews: subviews) - // Report content size based on actually used width when proposal is - // unbounded, otherwise fill the proposal so the parent can size us - // consistently. - let width: CGFloat - if maxWidth.isFinite { - width = maxWidth - } else { - width = result.usedWidth - } - return CGSize(width: width, height: result.totalHeight) - } - - func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) { - let result = arrange(in: bounds.width, subviews: subviews) - for (index, frame) in result.frames.enumerated() { - let origin = CGPoint(x: bounds.minX + frame.origin.x, y: bounds.minY + frame.origin.y) - subviews[index].place(at: origin, anchor: .topLeading, proposal: ProposedViewSize(width: frame.width, height: frame.height)) - } - } - - private struct Arranged { - let frames: [CGRect] - let totalHeight: CGFloat - let usedWidth: CGFloat - } - - private func arrange(in maxWidth: CGFloat, subviews: Subviews) -> Arranged { - var frames: [CGRect] = [] - var x: CGFloat = 0 - var y: CGFloat = 0 - var currentRowHeight: CGFloat = 0 - var maxRowEnd: CGFloat = 0 - - for subview in subviews { - let size = subview.sizeThatFits(.unspecified) - // If this subview wouldn't fit on the current row, wrap. - if x > 0 && x + size.width > maxWidth { - y += currentRowHeight + lineSpacing - x = 0 - currentRowHeight = 0 - } - frames.append(CGRect(x: x, y: y, width: size.width, height: size.height)) - x += size.width + spacing - currentRowHeight = max(currentRowHeight, size.height) - maxRowEnd = max(maxRowEnd, x - spacing) - } - - let totalHeight = y + currentRowHeight - return Arranged(frames: frames, totalHeight: totalHeight, usedWidth: maxRowEnd) - } -} +@iosApp/VideoDetailView.swift \ No newline at end of file From acd71b96d1018017698d8884ee199722e13f5738 Mon Sep 17 00:00:00 2001 From: pgs666 <74764545+pgs666@users.noreply.github.com> Date: Fri, 5 Jun 2026 23:28:37 +0800 Subject: [PATCH 090/216] Fix video detail tab transition build --- iosApp/VideoDetailView.swift | 1927 +++++++++++++++++++++++++++++++++- 1 file changed, 1926 insertions(+), 1 deletion(-) diff --git a/iosApp/VideoDetailView.swift b/iosApp/VideoDetailView.swift index d74fd84b..94be20a5 100644 --- a/iosApp/VideoDetailView.swift +++ b/iosApp/VideoDetailView.swift @@ -1 +1,1926 @@ -@iosApp/VideoDetailView.swift \ No newline at end of file +import SwiftUI +import UIKit +import Han1meShared + +struct VideoDetailView: View { + let videoCode: String + private let videoFeature: VideoFeature + private let commentFeature: CommentFeature + private let tabletLeftMinimumWidth: CGFloat = 620 + private let tabletSidebarMinimumWidth: CGFloat = 360 + private let playerContinuationStripHeight: CGFloat = 56 + @StateObject private var viewModel: VideoDetailViewModel + @StateObject private var commentViewModel: CommentViewModel + @State private var selectedTab = VideoPageTab.introduction + @State private var isPlayerFullscreen = false + @State private var isPlayerPlaying = false + @State private var playerPlayRequestToken = 0 + /// Global collapse offset for the inline player, decoupled from any + /// single tab's ScrollView offset. If one tab has already collapsed the + /// player, switching to another tab must not snap it back open just + /// because that tab's own content is still at the top. + @State private var bottomScrollOffset: CGFloat = 0 + /// Each tab owns an independent vertical ScrollView below the player, so + /// switching between intro / comments preserves their separate scroll + /// positions instead of sharing one outer ScrollView offset. + @State private var bottomScrollOffsetsByTab: [VideoPageTab: CGFloat] = [:] + @State private var commentComposeText = "" + @State private var isCommentInternalOverlayActive = false + @State private var commentComposerHeight: CGFloat = CommentComposerBar.compactHeight + @State private var fullscreenOrientationTask: Task? + /// Natural size of the loaded video (reported by KSPlayer the first time + /// the underlying player gets a non-zero presentation size). Used to + /// decide whether fullscreen should lock the device to portrait or + /// landscape: a video taller than wide on a phone shouldn't force a + /// 90° rotation that produces black side-bars. + @State private var videoNaturalSize: CGSize? + /// Mirrors the KMP-shared `forcePortraitFullscreenForVerticalVideos` + /// preference (default ON). When ON, fullscreen on a portrait-aspect + /// video keeps the device in portrait instead of forcing landscape. + @AppStorage("force_portrait_fullscreen_for_vertical_videos") + private var forcePortraitForVerticalVideos: Bool = true + @Environment(\.horizontalSizeClass) private var horizontalSizeClass + @Environment(\.dismiss) private var dismiss + + init(videoCode: String, videoFeature: VideoFeature, commentFeature: CommentFeature) { + self.videoCode = videoCode + self.videoFeature = videoFeature + self.commentFeature = commentFeature + _viewModel = StateObject(wrappedValue: VideoDetailViewModel(videoFeature: videoFeature)) + _commentViewModel = StateObject( + wrappedValue: CommentViewModel(feature: commentFeature, videoCode: videoCode) + ) + } + + var body: some View { + GeometryReader { proxy in + ZStack(alignment: .bottom) { + content + .ignoresSafeArea(.container, edges: ignoredContainerSafeAreaEdges) + + rootCommentComposer(layoutSize: proxy.size) + } + .animation(.easeInOut(duration: 0.2), value: shouldShowRootCommentComposer) + .onPreferenceChange(CommentComposerHeightPreferenceKey.self) { height in + guard height > 0, abs(commentComposerHeight - height) > 0.5 else { return } + commentComposerHeight = height + } + } + .logScreen("VideoDetail v=\(videoCode)") + // Navigation bar (and its system back button) is hidden the + // whole time. The player draws its own floating back button + // inside the controls overlay — that way show/hide of the + // back button is purely an overlay-layer change and doesn't + // resize / shift the rest of the view tree. + .toolbar(.hidden, for: .navigationBar) + // SwiftUI's `.toolbar(.hidden, for: .navigationBar)` also turns + // off the edge-swipe-to-go-back gesture on the underlying + // UINavigationController. Re-enable it explicitly so the user + // still has the standard iOS gesture to navigate back even + // though we hide the nav bar. + .enableInteractivePopOnHiddenNavBar(disabled: isPlayerFullscreen) + // The video detail page always hides the tab bar — it's a + // pushed sub-page that benefits from extra vertical space, not a + // top-level tab. (Fullscreen state doesn't matter; both inline + // and fullscreen want the tab bar gone.) + // hidesTabBarOnAppear() drives the shared TabBarVisibilityController: + // .onAppear sets hidden; .onDisappear withAnimation sets visible + // again, producing the slide-in/out animation. + .hidesTabBarOnAppear() + .statusBarHidden(isPlayerFullscreen) + .task { + viewModel.loadIfNeeded(videoCode: videoCode) + } + .onDisappear { + fullscreenOrientationTask?.cancel() + fullscreenOrientationTask = nil + // KSPlayer pauses itself in its own .onDisappear; the + // detail VM no longer owns a player. + if isPlayerFullscreen { + AppOrientationController.shared.unlockAfterFullscreen() + } + } + // Apple-Music-style centred HUD for action results + // (favorited / watch-later / playlist / subscribe / errors). + // .overlay so it floats on top of everything, including the + // player. allowsHitTesting(false) so it never blocks taps. + // The HUD self-dismisses 1.2s after appearing (see .task + // modifier on the inner view that's keyed on the message id). + .overlay(alignment: .center) { + if let actionMessage = viewModel.actionMessage { + AppleStyleHUD( + systemImage: actionMessage.systemImage, + message: actionMessage.message + ) + .transition( + .scale(scale: 0.85) + .combined(with: .opacity) + ) + .allowsHitTesting(false) + .task(id: actionMessage.id) { + // Auto-dismiss timer. The .task is keyed on the + // message id so consecutive HUDs (e.g. user + // mashes the favorite button) reset the timer + // rather than dismissing early. + try? await Task.sleep(nanoseconds: 1_200_000_000) + // Make sure we're still showing the same message; + // if the user fired another action mid-sleep, the + // task is cancelled and we don't clear theirs. + if viewModel.actionMessage?.id == actionMessage.id { + withAnimation(.easeOut(duration: 0.25)) { + viewModel.actionMessage = nil + } + } + } + } + } + .animation(.spring(response: 0.32, dampingFraction: 0.78), value: viewModel.actionMessage?.id) + .onValueChange(of: isPlayerFullscreen) { newValue in + // The fullscreen toggle button wraps `isPlayerFullscreen.toggle()` + // in `withAnimation(.easeInOut(duration: 0.25))` so the + // player frame can animate from inline 16:9 to fill-screen. + // If we synchronously trigger AppOrientationController here, + // UIKit fires a size-class / size change in the middle of + // SwiftUI's animation transaction, and SwiftUI cancels the + // running frame animation in favour of laying out for the + // new orientation — the user perceives this as the animation + // "going missing". Defer the orientation change until just + // after the SwiftUI animation has completed (~0.30s, slightly + // longer than the 0.25s curve to be safe). The player has + // already animated to its new size by then; the subsequent + // orientation rotation is its own UIKit-driven animation + // and doesn't fight with SwiftUI. + scheduleFullscreenOrientationUpdate(isFullscreen: newValue) + } + } + + /// Decides whether the player should rotate to landscape or stay in + /// portrait when entering fullscreen. Defaults to landscape (existing + /// behaviour); switches to portrait only when both: + /// 1. The video's reported natural size is taller than wide. + /// 2. The user has the "force portrait fullscreen for vertical + /// videos" preference enabled (default ON). + private var fullscreenOrientation: VideoFullscreenOrientation { + let isPortraitVideo: Bool = { + guard let size = videoNaturalSize else { return false } + return size.height > size.width + }() + if isPortraitVideo && forcePortraitForVerticalVideos { + return .portrait + } + return .landscape + } + + private var ignoredContainerSafeAreaEdges: Edge.Set { + isPlayerFullscreen ? .all : .bottom + } + + private var shouldShowRootCommentComposer: Bool { + guard !isPlayerFullscreen, selectedTab == .comments else { return false } + guard !isCommentInternalOverlayActive else { return false } + guard isCommentComposerReady else { return false } + if case .loaded = viewModel.state { + return true + } + return false + } + + private var isCommentComposerReady: Bool { + if case .loaded = commentViewModel.state { + return true + } + return false + } + + @ViewBuilder + private func rootCommentComposer(layoutSize: CGSize) -> some View { + if shouldShowRootCommentComposer { + CommentComposerBar( + text: $commentComposeText, + isSending: commentViewModel.runningActionIDs.contains("post-comment"), + isReady: isCommentComposerReady, + onSubmit: submitComment + ) + .frame(width: leftPanelWidth(for: layoutSize)) + .reportCommentComposerHeight() + .frame(maxWidth: .infinity, alignment: .leading) + .transition(.move(edge: .bottom).combined(with: .opacity)) + .zIndex(1) + } + } + + @ViewBuilder + private var content: some View { + switch viewModel.state { + case .idle, .loading: + ProgressView() + .frame(maxWidth: .infinity, maxHeight: .infinity) + case .failed(let message): + VStack(spacing: 12) { + Image(systemName: "wifi.exclamationmark") + .font(.largeTitle) + .foregroundStyle(.secondary) + Text("视频加载失败") + .font(.headline) + Text(message) + .font(.subheadline) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + Button("重试") { + viewModel.load(videoCode: videoCode) + } + .buttonStyle(.borderedProminent) + CloudflareVerifyButton(errorMessage: message) + } + .padding(24) + .frame(maxWidth: .infinity, maxHeight: .infinity) + case .loaded(let snapshot): + // Bilibili-style iPad layout: an outer HStack with two slots. + // - Slot 0: a VStack (the "left panel") that hosts player + scroll. + // Player is ALWAYS the first child of this VStack at a stable tree + // position, so size-class flips never reparent it (which would + // rebuild @StateObject Coordinator + KSPlayerLayer → reload video). + // - Slot 1: the related-videos sidebar, only mounted on iPad regular + // landscape. Mounting/unmounting it does NOT touch slot 0. + // + // Phone / iPad portrait collapses to a single full-width left panel + // (no sidebar), giving the same visual as before for those modes. + GeometryReader { proxy in + let isWide = usesTabletRelatedSidebar(for: proxy.size) + let leftWidth = leftPanelWidth(for: proxy.size) + + HStack(alignment: .top, spacing: 0) { + ZStack(alignment: .top) { + let currentPlayerCollapseDistance = playerCollapseDistance(panelWidth: leftWidth) + let currentInlinePlayerHeight = inlinePlayerHeight(panelWidth: leftWidth) + let currentPlayerHeight = playerHeight( + panelWidth: leftWidth, + parentHeight: proxy.size.height + ) + let currentPlayerScrollAway = isPlayerPlaying + ? 0 + : playerVisualShrink(panelWidth: leftWidth) + let currentCollapseDistance = isPlayerPlaying + ? 0 + : currentPlayerCollapseDistance + let currentBottomScrollOffset = max(currentInlinePlayerHeight - currentPlayerScrollAway, 0) + let currentBottomScrollHeight = proxy.size.height - currentBottomScrollOffset + let currentContinuationProgress = continuationStripProgress( + scrollAway: currentPlayerScrollAway, + panelWidth: leftWidth + ) + playerArea(snapshot: snapshot) + .frame( + width: leftWidth, + height: currentPlayerHeight + ) + + // Keep the tab pager mounted during fullscreen. Rebuilding + // the SwiftUI-hosted intro/comment pages while the player + // animates back to inline is a visible hitch on iPadOS 16. + // It keeps the inline layout metrics while hidden, so exit + // fullscreen does not have to re-expand the whole pager. + belowPlayerScroll( + snapshot: snapshot, + showsRelated: !isWide, + collapseDistance: currentCollapseDistance, + collapseCompensation: currentPlayerScrollAway, + composerContentClearance: commentComposerContentClearance( + safeAreaBottom: proxy.safeAreaInsets.bottom + ) + ) + .frame(height: max(0, currentBottomScrollHeight)) + .offset(y: currentBottomScrollOffset) + .opacity(isPlayerFullscreen ? 0 : 1) + .allowsHitTesting(!isPlayerFullscreen) + + if !isPlayerFullscreen, + currentContinuationProgress > 0 { + continuePlayingStrip(snapshot: snapshot, progress: currentContinuationProgress) + .frame(width: leftWidth, height: playerContinuationStripHeight) + } + } + .frame(width: leftWidth, height: proxy.size.height, alignment: .top) + .clipped() + + if isWide { + Divider() + TabletRelatedSidebar( + videos: snapshot.relatedVideos, + videoFeature: videoFeature, + commentFeature: commentFeature + ) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(Color(.systemBackground)) + } + } + } + .background(Color(.systemGroupedBackground)) + } + } + + private func usesTabletRelatedSidebar(for size: CGSize) -> Bool { + let isLandscape = currentInterfaceOrientation()?.isLandscape ?? (size.width > size.height) + return horizontalSizeClass == .regular + && size.width >= tabletLeftMinimumWidth + tabletSidebarMinimumWidth + && isLandscape + && !isPlayerFullscreen + } + + private func leftPanelWidth(for size: CGSize) -> CGFloat { + guard usesTabletRelatedSidebar(for: size) else { return size.width } + return min( + max(size.width * 0.64, tabletLeftMinimumWidth), + size.width - tabletSidebarMinimumWidth + ) + } + + /// Player 高度: + /// - 全屏:撑满整个父容器 + /// - inline:固定为左 panel 宽度的 16:9,不随底部内容滚动缩小 + private func playerHeight(panelWidth: CGFloat, parentHeight: CGFloat) -> CGFloat { + if isPlayerFullscreen { return parentHeight } + return inlinePlayerHeight(panelWidth: panelWidth) + } + + private func inlinePlayerHeight(panelWidth: CGFloat) -> CGFloat { + panelWidth * 9 / 16 + } + + private func playerCollapseDistance(panelWidth: CGFloat) -> CGFloat { + max(panelWidth * 9 / 16 - playerContinuationStripHeight, 1) + } + + private func playerVisualShrink(panelWidth: CGFloat) -> CGFloat { + min(max(bottomScrollOffset, 0), playerCollapseDistance(panelWidth: panelWidth)) + } + + private func continuationStripProgress(scrollAway: CGFloat, panelWidth: CGFloat) -> CGFloat { + let collapseDistance = playerCollapseDistance(panelWidth: panelWidth) + let fadeDistance: CGFloat = 72 + guard collapseDistance > 1 else { return 0 } + return min(max((scrollAway - (collapseDistance - fadeDistance)) / fadeDistance, 0), 1) + } + + private func playerArea(snapshot: VideoDetailScreenSnapshot) -> some View { + return KSPlayerView( + snapshot: snapshot, + isFullscreen: $isPlayerFullscreen, + onProgress: { viewModel.recordPlaybackPosition(seconds: $0) }, + onPlaybackEnded: { viewModel.recordPlaybackPosition(seconds: 0) }, + onPlayingChanged: { newValue in + if isPlayerPlaying != newValue { + isPlayerPlaying = newValue + if newValue, abs(bottomScrollOffset) > 0.5 { + bottomScrollOffset = 0 + } + } + }, + onBack: { dismiss() }, + playRequestToken: playerPlayRequestToken, + onNaturalSize: { size in + videoNaturalSize = size + } + ) + } + + private func continuePlayingStrip(snapshot: VideoDetailScreenSnapshot, progress: CGFloat) -> some View { + Button { + withAnimation(.easeInOut(duration: 0.25)) { + bottomScrollOffset = 0 + } + playerPlayRequestToken &+= 1 + } label: { + HStack(spacing: 10) { + Image(systemName: "play.circle.fill") + .font(.title3) + Text("继续播放") + .font(.subheadline.weight(.semibold)) + Text(snapshot.title) + .font(.caption) + .lineLimit(1) + .foregroundStyle(.secondary) + Spacer(minLength: 0) + } + .foregroundStyle(.primary) + .padding(.horizontal, 16) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(.ultraThinMaterial) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .opacity(progress) + .offset(y: -8 * (1 - progress)) + } + + private func belowPlayerScroll( + snapshot: VideoDetailScreenSnapshot, + showsRelated: Bool, + collapseDistance: CGFloat, + collapseCompensation: CGFloat, + composerContentClearance: CGFloat + ) -> some View { + let contentRevision = tabContentRevision(snapshot: snapshot, showsRelated: showsRelated) + + return VStack(spacing: 0) { + Picker("Content", selection: $selectedTab) { + ForEach(VideoPageTab.allCases) { tab in + Text(tab.title).tag(tab) + } + } + .pickerStyle(.segmented) + .padding(.horizontal, 16) + .padding(.vertical, 8) + .background(.background) + + VideoDetailTabPager( + selectedTab: $selectedTab, + introduction: tabPage( + .introduction, + contentUpdateRevision: contentRevision.introduction + ) { + AndroidStyleIntroduction( + snapshot: snapshot, + videoFeature: videoFeature, + commentFeature: commentFeature, + isArtistActionRunning: viewModel.isActionRunning("artistSubscription"), + onToggleArtistSubscription: { viewModel.toggleArtistSubscription(snapshot: snapshot) }, + onToggleFavorite: { viewModel.toggleFavorite(snapshot: snapshot) }, + onToggleWatchLater: { viewModel.toggleWatchLater(snapshot: snapshot) }, + onSetMyListItem: { item, isSelected in viewModel.setMyListItem(snapshot: snapshot, item: item, isSelected: isSelected) }, + onShowMessage: { viewModel.showActionMessage($0) }, + showsRelated: showsRelated + ) + .padding(.top, 16) + } collapseCompensation: { + tabCollapseCompensation(for: .introduction, collapseCompensation: collapseCompensation) + } collapseDistance: { + collapseDistance + }, + comments: tabPage( + .comments, + contentBottomPadding: composerContentClearance, + contentUpdateRevision: contentRevision.comments + ) { + CommentView( + viewModel: commentViewModel, + onOverlayActivityChanged: { isActive in + isCommentInternalOverlayActive = isActive + } + ) + .padding(.top, 16) + } collapseCompensation: { + tabCollapseCompensation(for: .comments, collapseCompensation: collapseCompensation) + } collapseDistance: { + collapseDistance + } + ) + .frame(maxHeight: .infinity) + } + .frame(maxHeight: .infinity) + .background(Color(.systemGroupedBackground)) + .onValueChange(of: selectedTab) { _ in + withTransaction(Transaction(animation: nil)) { + bottomScrollOffset = min(max(bottomScrollOffset, 0), collapseDistance) + } + } + } + + private func updatePlayerCollapseOffset( + activeTabOffset: CGFloat, + previousActiveTabOffset: CGFloat?, + collapseDistance: CGFloat + ) { + guard !isPlayerPlaying else { + if abs(bottomScrollOffset) > 0.5 { + withTransaction(Transaction(animation: nil)) { + bottomScrollOffset = 0 + } + } + return + } + let nextOffset = VideoPlayerCollapseModel.nextCollapseOffset( + currentCollapseOffset: bottomScrollOffset, + activeTabOffset: activeTabOffset, + previousActiveTabOffset: previousActiveTabOffset, + collapseDistance: collapseDistance + ) + guard abs(bottomScrollOffset - nextOffset) > 0.5 else { return } + withTransaction(Transaction(animation: nil)) { + bottomScrollOffset = nextOffset + } + } + + private func submitComment() { + guard isCommentComposerReady else { return } + if commentViewModel.postComment(text: commentComposeText) { + commentComposeText = "" + } + } + + private func scheduleFullscreenOrientationUpdate(isFullscreen: Bool) { + fullscreenOrientationTask?.cancel() + fullscreenOrientationTask = Task { @MainActor in + try? await Task.sleep(nanoseconds: 300_000_000) + guard !Task.isCancelled, isPlayerFullscreen == isFullscreen else { return } + if isFullscreen { + AppOrientationController.shared.lockForFullscreen(to: fullscreenOrientation) + } else { + AppOrientationController.shared.unlockAfterFullscreen() + } + } + } + + private func tabPage( + _ tab: VideoPageTab, + contentBottomPadding: CGFloat = 24, + contentUpdateRevision: Int, + @ViewBuilder content: @escaping () -> Content, + collapseCompensation: @escaping () -> CGFloat = { 0 }, + collapseDistance: @escaping () -> CGFloat = { 0 } + ) -> VideoDetailTabPage { + VideoDetailTabPage( + tab: tab, + contentBottomPadding: contentBottomPadding, + collapseCompensation: collapseCompensation(), + collapseDistance: collapseDistance(), + contentUpdateRevision: contentUpdateRevision, + onOffsetChange: { tab, offset in + updateTabOffset(tab, offset: offset, collapseDistance: collapseDistance()) + }, + onInteractionBegan: { tab in + updateInteractingTab(tab) + }, + onTopPullDelta: { tab, delta in + updateTabTopPull(tab, delta: delta, collapseDistance: collapseDistance()) + }, + content: content + ) + } + + private func updateTabOffset(_ tab: VideoPageTab, offset: CGFloat, collapseDistance: CGFloat) { + let previousActiveOffset = bottomScrollOffsetsByTab[selectedTab] + withTransaction(Transaction(animation: nil)) { + bottomScrollOffsetsByTab[tab] = offset + guard tab == selectedTab else { return } + updatePlayerCollapseOffset( + activeTabOffset: offset, + previousActiveTabOffset: previousActiveOffset, + collapseDistance: collapseDistance + ) + } + } + + private func tabCollapseCompensation(for tab: VideoPageTab, collapseCompensation: CGFloat) -> CGFloat { + min(max(bottomScrollOffsetsByTab[tab] ?? 0, 0), collapseCompensation) + } + + private func updateInteractingTab(_ tab: VideoPageTab) { + guard selectedTab != tab else { return } + withTransaction(Transaction(animation: nil)) { + selectedTab = tab + } + } + + private func updateTabTopPull(_ tab: VideoPageTab, delta: CGFloat, collapseDistance: CGFloat) { + guard !isPlayerPlaying, delta > 0, bottomScrollOffset > 0 else { return } + let nextOffset = min(max(bottomScrollOffset - delta, 0), collapseDistance) + guard abs(bottomScrollOffset - nextOffset) > 0.5 else { return } + withTransaction(Transaction(animation: nil)) { + bottomScrollOffset = nextOffset + } + } + + private func commentComposerContentClearance(safeAreaBottom: CGFloat) -> CGFloat { + let containerBottomInset = currentWindowBottomSafeAreaInset() + let isKeyboardSafeAreaActive = safeAreaBottom > containerBottomInset + 1 + let composerHeight = max(commentComposerHeight, CommentComposerBar.compactHeight) + return composerHeight + (isKeyboardSafeAreaActive ? 0 : containerBottomInset) + } + + private func currentWindowBottomSafeAreaInset() -> CGFloat { + currentWindowScene()? + .windows + .first { $0.isKeyWindow }? + .safeAreaInsets + .bottom ?? 0 + } + + private func currentInterfaceOrientation() -> UIInterfaceOrientation? { + currentWindowScene()?.interfaceOrientation + } + + private func currentWindowScene() -> UIWindowScene? { + UIApplication.shared.connectedScenes + .compactMap { $0 as? UIWindowScene } + .first { $0.activationState == .foregroundActive } + } + + private func tabContentRevision( + snapshot: VideoDetailScreenSnapshot, + showsRelated: Bool + ) -> VideoDetailTabContentRevision { + var introductionHasher = Hasher() + introductionHasher.combine(showsRelated) + introductionHasher.combine(viewModel.isActionRunning("artistSubscription")) + snapshot.hash(into: &introductionHasher) + + var commentsHasher = Hasher() + commentsHasher.combine(ObjectIdentifier(commentViewModel)) + + return VideoDetailTabContentRevision( + introduction: introductionHasher.finalize(), + comments: commentsHasher.finalize() + ) + } +} + +private struct CommentComposerBar: View { + static let compactHeight: CGFloat = 49 + + @Binding var text: String + let isSending: Bool + let isReady: Bool + let onSubmit: () -> Void + @FocusState private var isFieldFocused: Bool + + private var canSubmit: Bool { + isReady && text.trimmingCharacters(in: .whitespacesAndNewlines).count >= 2 && !isSending + } + + var body: some View { + composerControls + .padding(.horizontal, 16) + .frame(minHeight: Self.compactHeight) + .frame(maxWidth: .infinity) + .commentComposerBarChrome() + .onDisappear { + isFieldFocused = false + } + } + + @ViewBuilder + private var composerControls: some View { + if #available(iOS 26.0, *) { + GlassEffectContainer(spacing: 10) { + composerControlRow + } + } else { + composerControlRow + } + } + + private var composerControlRow: some View { + HStack(spacing: 10) { + TextField("输入评论", text: $text, axis: .vertical) + .textFieldStyle(.plain) + .submitLabel(.send) + .lineLimit(1...4) + .focused($isFieldFocused) + .onSubmit { + guard canSubmit else { return } + onSubmit() + } + .padding(.horizontal, 14) + .padding(.vertical, 10) + .commentComposerFieldChrome() + .layoutPriority(1) + + Button(action: onSubmit) { + Image(systemName: isSending ? "hourglass" : "paperplane.fill") + .font(.headline) + .frame(width: 42, height: 42) + } + .disabled(!canSubmit) + .foregroundStyle(canSubmit ? Color.accentColor : Color.secondary) + .accessibilityLabel("发送评论") + .commentComposerSendButtonChrome(isEnabled: canSubmit) + } + } +} + +private struct CommentComposerHeightPreferenceKey: PreferenceKey { + static var defaultValue: CGFloat = 0 + static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) { + value = max(value, nextValue()) + } +} + +private extension View { + func reportCommentComposerHeight() -> some View { + background( + GeometryReader { proxy in + Color.clear.preference( + key: CommentComposerHeightPreferenceKey.self, + value: proxy.size.height + ) + } + ) + } + + @ViewBuilder + func commentComposerFieldChrome() -> some View { + if #available(iOS 26.0, *) { + contentShape(Capsule()) + .glassEffect(.regular.interactive(), in: Capsule()) + } else { + background(Color(.secondarySystemBackground), in: Capsule()) + .overlay { + Capsule() + .strokeBorder(Color.secondary.opacity(0.16), lineWidth: 0.5) + } + } + } + + @ViewBuilder + func commentComposerSendButtonChrome(isEnabled: Bool) -> some View { + if #available(iOS 26.0, *) { + buttonStyle(.plain) + .contentShape(Circle()) + .glassEffect(.regular.interactive(isEnabled), in: Circle()) + } else { + buttonStyle(.plain) + } + } + + @ViewBuilder + func commentComposerBarChrome() -> some View { + if #available(iOS 26.0, *) { + background(.clear) + } else { + background(.bar) + .overlay(alignment: .top) { + Divider() + } + } + } +} + +private enum VideoPlayerCollapseModel { + static func nextCollapseOffset( + currentCollapseOffset: CGFloat, + activeTabOffset: CGFloat, + previousActiveTabOffset: CGFloat?, + collapseDistance: CGFloat + ) -> CGFloat { + let clampedCurrent = clamp(currentCollapseOffset, upperBound: collapseDistance) + + guard let previousActiveTabOffset else { + let nonnegativeActiveOffset = max(0, activeTabOffset) + return min(collapseDistance, max(clampedCurrent, nonnegativeActiveOffset)) + } + + let delta = activeTabOffset - previousActiveTabOffset + if abs(delta) > 0.5 { + return clamp(clampedCurrent + delta, upperBound: collapseDistance) + } + + return clampedCurrent + } + + private static func clamp(_ value: CGFloat, upperBound: CGFloat) -> CGFloat { + min(max(value, 0), max(upperBound, 0)) + } +} + +private enum VideoPageTab: String, CaseIterable, Identifiable { + case introduction + case comments + + var id: String { rawValue } + + var pageIndex: Int { + switch self { + case .introduction: return 0 + case .comments: return 1 + } + } + + static func page(at index: Int) -> VideoPageTab { + index <= 0 ? .introduction : .comments + } + + var scrollCoordinateSpaceName: String { + "bottomScroll-\(rawValue)" + } + + var title: String { + switch self { + case .introduction: + return String(localized: "简介") + case .comments: + return String(localized: "评论") + } + } +} + +private struct VideoDetailTabContentRevision: Equatable { + let introduction: Int + let comments: Int +} + +private struct VideoDetailTabPage { + let tab: VideoPageTab + let contentBottomPadding: CGFloat + let collapseCompensation: CGFloat + let collapseDistance: CGFloat + let contentUpdateRevision: Int + let onOffsetChange: (VideoPageTab, CGFloat) -> Void + let onInteractionBegan: (VideoPageTab) -> Void + let onTopPullDelta: (VideoPageTab, CGFloat) -> Void + let content: () -> AnyView + + init( + tab: VideoPageTab, + contentBottomPadding: CGFloat, + collapseCompensation: CGFloat, + collapseDistance: CGFloat, + contentUpdateRevision: Int, + onOffsetChange: @escaping (VideoPageTab, CGFloat) -> Void, + onInteractionBegan: @escaping (VideoPageTab) -> Void, + onTopPullDelta: @escaping (VideoPageTab, CGFloat) -> Void, + @ViewBuilder content: @escaping () -> Content + ) { + self.tab = tab + self.contentBottomPadding = contentBottomPadding + self.collapseCompensation = collapseCompensation + self.collapseDistance = collapseDistance + self.contentUpdateRevision = contentUpdateRevision + self.onOffsetChange = onOffsetChange + self.onInteractionBegan = onInteractionBegan + self.onTopPullDelta = onTopPullDelta + self.content = { AnyView(content()) } + } +} + +private final class VideoDetailVerticalScrollPageCoordinator: NSObject, UIScrollViewDelegate { + var tab: VideoPageTab + var onOffsetChange: (VideoPageTab, CGFloat) -> Void + var onInteractionBegan: (VideoPageTab) -> Void + var onTopPullDelta: (VideoPageTab, CGFloat) -> Void + private var lastReportedOffset: CGFloat? + private var lastTopPullTranslationY: CGFloat = 0 + + init( + tab: VideoPageTab, + onOffsetChange: @escaping (VideoPageTab, CGFloat) -> Void, + onInteractionBegan: @escaping (VideoPageTab) -> Void, + onTopPullDelta: @escaping (VideoPageTab, CGFloat) -> Void + ) { + self.tab = tab + self.onOffsetChange = onOffsetChange + self.onInteractionBegan = onInteractionBegan + self.onTopPullDelta = onTopPullDelta + } + + func scrollViewDidScroll(_ scrollView: UIScrollView) { + let offset = scrollView.verticalContentOffsetExcludingBounce + guard lastReportedOffset.map({ abs($0 - offset) > 0.5 }) ?? true else { return } + lastReportedOffset = offset + onOffsetChange(tab, offset) + } + + @objc func handlePan(_ panGestureRecognizer: UIPanGestureRecognizer) { + guard let scrollView = panGestureRecognizer.view as? UIScrollView else { return } + switch panGestureRecognizer.state { + case .began: + onInteractionBegan(tab) + lastTopPullTranslationY = 0 + case .changed: + guard scrollView.verticalContentOffsetExcludingBounce <= 0.5 else { + lastTopPullTranslationY = panGestureRecognizer.translation(in: scrollView).y + return + } + let translationY = panGestureRecognizer.translation(in: scrollView).y + let delta = translationY - lastTopPullTranslationY + lastTopPullTranslationY = translationY + if delta > 0 { + onTopPullDelta(tab, delta) + } + default: + lastTopPullTranslationY = 0 + } + } +} + +private final class VideoDetailVerticalScrollPageViewController: UIViewController { + private let coordinator: VideoDetailVerticalScrollPageCoordinator + private let scrollView = VerticalScrollView() + private let contentView = UIView() + private let bottomPaddingView = UIView() + private let collapseSpacerView = UIView() + private let host = UIHostingController(rootView: AnyView(EmptyView())) + private var hostTopConstraint: NSLayoutConstraint! + private var hostMinimumHeightConstraint: NSLayoutConstraint! + private var bottomPaddingHeightConstraint: NSLayoutConstraint! + private var collapseSpacerHeightConstraint: NSLayoutConstraint! + private var contentUpdateRevision: Int? + + init(tab: VideoPageTab) { + coordinator = VideoDetailVerticalScrollPageCoordinator( + tab: tab, + onOffsetChange: { _, _ in }, + onInteractionBegan: { _ in }, + onTopPullDelta: { _, _ in } + ) + super.init(nibName: nil, bundle: nil) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + view.backgroundColor = .clear + + scrollView.translatesAutoresizingMaskIntoConstraints = false + scrollView.backgroundColor = .clear + scrollView.bounces = false + scrollView.alwaysBounceVertical = false + scrollView.alwaysBounceHorizontal = false + scrollView.showsHorizontalScrollIndicator = false + scrollView.showsVerticalScrollIndicator = false + scrollView.isDirectionalLockEnabled = true + scrollView.contentInsetAdjustmentBehavior = .never + scrollView.keyboardDismissMode = .interactive + scrollView.delegate = coordinator + scrollView.panGestureRecognizer.addTarget(coordinator, action: #selector(VideoDetailVerticalScrollPageCoordinator.handlePan(_:))) + scrollView.shouldBeginVerticalPan = { panGestureRecognizer, view in + let velocity = panGestureRecognizer.velocity(in: view) + guard abs(velocity.x) > abs(velocity.y) * 1.05 else { return true } + let startLocation = panGestureRecognizer.location(in: view) + return !view.hasScrollableHorizontalDescendant(at: startLocation, excluding: view) + } + view.addSubview(scrollView) + + contentView.translatesAutoresizingMaskIntoConstraints = false + contentView.backgroundColor = .clear + scrollView.addSubview(contentView) + + addChild(host) + host.view.translatesAutoresizingMaskIntoConstraints = false + host.view.backgroundColor = .clear + contentView.addSubview(host.view) + host.didMove(toParent: self) + + bottomPaddingView.translatesAutoresizingMaskIntoConstraints = false + bottomPaddingView.backgroundColor = .clear + contentView.addSubview(bottomPaddingView) + + collapseSpacerView.translatesAutoresizingMaskIntoConstraints = false + collapseSpacerView.backgroundColor = .clear + contentView.addSubview(collapseSpacerView) + + hostTopConstraint = host.view.topAnchor.constraint(equalTo: contentView.topAnchor) + hostMinimumHeightConstraint = host.view.heightAnchor.constraint(greaterThanOrEqualTo: scrollView.frameLayoutGuide.heightAnchor) + bottomPaddingHeightConstraint = bottomPaddingView.heightAnchor.constraint(equalToConstant: 24) + collapseSpacerHeightConstraint = collapseSpacerView.heightAnchor.constraint(equalToConstant: 1) + + NSLayoutConstraint.activate([ + scrollView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + scrollView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + scrollView.topAnchor.constraint(equalTo: view.topAnchor), + scrollView.bottomAnchor.constraint(equalTo: view.bottomAnchor), + + contentView.leadingAnchor.constraint(equalTo: scrollView.contentLayoutGuide.leadingAnchor), + contentView.trailingAnchor.constraint(equalTo: scrollView.contentLayoutGuide.trailingAnchor), + contentView.topAnchor.constraint(equalTo: scrollView.contentLayoutGuide.topAnchor), + contentView.bottomAnchor.constraint(equalTo: scrollView.contentLayoutGuide.bottomAnchor), + contentView.widthAnchor.constraint(equalTo: scrollView.frameLayoutGuide.widthAnchor), + + host.view.leadingAnchor.constraint(equalTo: contentView.leadingAnchor), + host.view.trailingAnchor.constraint(equalTo: contentView.trailingAnchor), + hostTopConstraint, + hostMinimumHeightConstraint, + + bottomPaddingView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor), + bottomPaddingView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor), + bottomPaddingView.topAnchor.constraint(equalTo: host.view.bottomAnchor), + bottomPaddingHeightConstraint, + + collapseSpacerView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor), + collapseSpacerView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor), + collapseSpacerView.topAnchor.constraint(equalTo: bottomPaddingView.bottomAnchor), + collapseSpacerView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor), + collapseSpacerHeightConstraint + ]) + } + + func update(page: VideoDetailTabPage) { + loadViewIfNeeded() + coordinator.tab = page.tab + coordinator.onOffsetChange = page.onOffsetChange + coordinator.onInteractionBegan = page.onInteractionBegan + coordinator.onTopPullDelta = page.onTopPullDelta + if contentUpdateRevision != page.contentUpdateRevision { + contentUpdateRevision = page.contentUpdateRevision + host.rootView = page.content() + } + + hostTopConstraint.constant = 0 + host.view.transform = CGAffineTransform(translationX: 0, y: page.collapseCompensation) + bottomPaddingView.transform = CGAffineTransform(translationX: 0, y: page.collapseCompensation) + bottomPaddingHeightConstraint.constant = page.contentBottomPadding + collapseSpacerHeightConstraint.constant = page.collapseDistance + 1 + } +} + +private final class VerticalScrollView: UIScrollView { + var shouldBeginVerticalPan: ((UIPanGestureRecognizer, UIView) -> Bool)? + + override func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool { + if let panGestureRecognizer = gestureRecognizer as? UIPanGestureRecognizer, + panGestureRecognizer === self.panGestureRecognizer { + return shouldBeginVerticalPan?(panGestureRecognizer, self) ?? true + } + return super.gestureRecognizerShouldBegin(gestureRecognizer) + } +} + +private extension UIScrollView { + var verticalContentOffsetExcludingBounce: CGFloat { + let inset = adjustedContentInset + let minOffsetY = -inset.top + let maxOffsetY = max(minOffsetY, contentSize.height - bounds.height + inset.bottom) + return min(max(contentOffset.y, minOffsetY), maxOffsetY) + inset.top + } +} + +private struct VideoDetailTabPager: UIViewControllerRepresentable { + @Binding var selectedTab: VideoPageTab + let introduction: VideoDetailTabPage + let comments: VideoDetailTabPage + + init( + selectedTab: Binding, + introduction: VideoDetailTabPage, + comments: VideoDetailTabPage + ) { + _selectedTab = selectedTab + self.introduction = introduction + self.comments = comments + } + + func makeCoordinator() -> Coordinator { + Coordinator(selectedTab: $selectedTab) + } + + func makeUIViewController(context: Context) -> PagingViewController { + PagingViewController(coordinator: context.coordinator) + } + + func updateUIViewController(_ uiViewController: PagingViewController, context: Context) { + context.coordinator.selectedTab = $selectedTab + uiViewController.updatePages( + introduction: introduction, + comments: comments, + selectedIndex: selectedTab.pageIndex, + animated: context.coordinator.shouldAnimateProgrammaticSelection(to: selectedTab.pageIndex) + ) + } + + final class Coordinator: NSObject, UIScrollViewDelegate { + var selectedTab: Binding + private var lastProgrammaticIndex: Int? + + init(selectedTab: Binding) { + self.selectedTab = selectedTab + } + + func shouldAnimateProgrammaticSelection(to index: Int) -> Bool { + defer { lastProgrammaticIndex = index } + guard let lastProgrammaticIndex else { return false } + return lastProgrammaticIndex != index + } + + func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) { + updateSelectedTab(from: scrollView) + } + + func scrollViewDidEndScrollingAnimation(_ scrollView: UIScrollView) { + updateSelectedTab(from: scrollView) + } + + func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) { + if !decelerate { + updateSelectedTab(from: scrollView) + } + } + + func shouldBeginHorizontalPagingPan( + panGestureRecognizer: UIPanGestureRecognizer, + in view: UIView + ) -> Bool { + let startLocation = panGestureRecognizer.location(in: view) + guard startLocation.x > 24 else { return false } + guard !view.hasScrollableHorizontalDescendant(at: startLocation, excluding: view) else { + return false + } + + let translation = panGestureRecognizer.translation(in: view) + let velocity = panGestureRecognizer.velocity(in: view) + let horizontal = max(abs(translation.x), abs(velocity.x) * 0.05) + let vertical = max(abs(translation.y), abs(velocity.y) * 0.05) + return horizontal > 8 && horizontal > vertical * 1.18 + } + + private func updateSelectedTab(from scrollView: UIScrollView) { + let width = scrollView.bounds.width + guard width > 0 else { return } + let index = Int(round(scrollView.contentOffset.x / width)) + let tab = VideoPageTab.page(at: index) + if selectedTab.wrappedValue != tab { + selectedTab.wrappedValue = tab + } + } + } + + final class PagingViewController: UIViewController { + private let coordinator: Coordinator + private let scrollView = PagingScrollView() + private let contentView = UIView() + private let introductionPage = VideoDetailVerticalScrollPageViewController(tab: .introduction) + private let commentsPage = VideoDetailVerticalScrollPageViewController(tab: .comments) + private var selectedIndex = 0 + private var pendingSelectedIndex: Int? + + init(coordinator: Coordinator) { + self.coordinator = coordinator + super.init(nibName: nil, bundle: nil) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + view.backgroundColor = .clear + + scrollView.translatesAutoresizingMaskIntoConstraints = false + scrollView.backgroundColor = .clear + scrollView.isPagingEnabled = true + scrollView.bounces = false + scrollView.alwaysBounceHorizontal = false + scrollView.alwaysBounceVertical = false + scrollView.showsHorizontalScrollIndicator = false + scrollView.showsVerticalScrollIndicator = false + scrollView.isDirectionalLockEnabled = true + scrollView.contentInsetAdjustmentBehavior = .never + scrollView.delegate = coordinator + scrollView.shouldBeginPagingPan = { [weak self] panGestureRecognizer, view in + self?.coordinator.shouldBeginHorizontalPagingPan( + panGestureRecognizer: panGestureRecognizer, + in: view + ) ?? true + } + view.addSubview(scrollView) + + contentView.translatesAutoresizingMaskIntoConstraints = false + contentView.backgroundColor = .clear + scrollView.addSubview(contentView) + + addPage(introductionPage) + addPage(commentsPage) + + NSLayoutConstraint.activate([ + scrollView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + scrollView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + scrollView.topAnchor.constraint(equalTo: view.topAnchor), + scrollView.bottomAnchor.constraint(equalTo: view.bottomAnchor), + + contentView.leadingAnchor.constraint(equalTo: scrollView.contentLayoutGuide.leadingAnchor), + contentView.trailingAnchor.constraint(equalTo: scrollView.contentLayoutGuide.trailingAnchor), + contentView.topAnchor.constraint(equalTo: scrollView.contentLayoutGuide.topAnchor), + contentView.bottomAnchor.constraint(equalTo: scrollView.contentLayoutGuide.bottomAnchor), + contentView.heightAnchor.constraint(equalTo: scrollView.frameLayoutGuide.heightAnchor), + + introductionPage.view.leadingAnchor.constraint(equalTo: contentView.leadingAnchor), + introductionPage.view.topAnchor.constraint(equalTo: contentView.topAnchor), + introductionPage.view.bottomAnchor.constraint(equalTo: contentView.bottomAnchor), + introductionPage.view.widthAnchor.constraint(equalTo: scrollView.frameLayoutGuide.widthAnchor), + introductionPage.view.heightAnchor.constraint(equalTo: scrollView.frameLayoutGuide.heightAnchor), + + commentsPage.view.leadingAnchor.constraint(equalTo: introductionPage.view.trailingAnchor), + commentsPage.view.trailingAnchor.constraint(equalTo: contentView.trailingAnchor), + commentsPage.view.topAnchor.constraint(equalTo: contentView.topAnchor), + commentsPage.view.bottomAnchor.constraint(equalTo: contentView.bottomAnchor), + commentsPage.view.widthAnchor.constraint(equalTo: scrollView.frameLayoutGuide.widthAnchor), + commentsPage.view.heightAnchor.constraint(equalTo: scrollView.frameLayoutGuide.heightAnchor) + ]) + } + + override func viewDidLayoutSubviews() { + super.viewDidLayoutSubviews() + if let pendingSelectedIndex { + self.pendingSelectedIndex = nil + setSelectedIndex(pendingSelectedIndex, animated: false) + } else { + setSelectedIndex(selectedIndex, animated: false) + } + } + + func updatePages( + introduction: VideoDetailTabPage, + comments: VideoDetailTabPage, + selectedIndex: Int, + animated: Bool + ) { + loadViewIfNeeded() + introductionPage.update(page: introduction) + commentsPage.update(page: comments) + setSelectedIndex(selectedIndex, animated: animated) + } + + private func addPage(_ page: UIViewController) { + addChild(page) + page.view.translatesAutoresizingMaskIntoConstraints = false + page.view.backgroundColor = .clear + contentView.addSubview(page.view) + page.didMove(toParent: self) + } + + private func setSelectedIndex(_ index: Int, animated: Bool) { + selectedIndex = min(max(index, 0), VideoPageTab.allCases.count - 1) + let width = scrollView.bounds.width + guard width > 0 else { + pendingSelectedIndex = selectedIndex + return + } + let targetOffset = CGPoint(x: CGFloat(selectedIndex) * width, y: 0) + guard abs(scrollView.contentOffset.x - targetOffset.x) > 0.5 else { return } + scrollView.setContentOffset(targetOffset, animated: animated) + } + } + + final class PagingScrollView: UIScrollView { + var shouldBeginPagingPan: ((UIPanGestureRecognizer, UIView) -> Bool)? + + override func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool { + if let panGestureRecognizer = gestureRecognizer as? UIPanGestureRecognizer, + panGestureRecognizer === self.panGestureRecognizer { + return shouldBeginPagingPan?(panGestureRecognizer, self) ?? true + } + return super.gestureRecognizerShouldBegin(gestureRecognizer) + } + } +} + +private extension UIView { + func hasScrollableHorizontalDescendant(at location: CGPoint, excluding excludedView: UIView) -> Bool { + guard let hitView = hitTest(location, with: nil) else { return false } + var current: UIView? = hitView + while let view = current, view !== excludedView { + if let scrollView = view as? UIScrollView, + scrollView.isScrollEnabled, + scrollView.panGestureRecognizer.isEnabled, + scrollView.contentSize.width > scrollView.bounds.width + 1, + scrollView.contentSize.width > scrollView.contentSize.height { + return true + } + current = view.superview + } + return false + } +} + +private extension VideoDetailScreenSnapshot { + func hash(into hasher: inout Hasher) { + hasher.combine(videoCode) + hasher.combine(title) + hasher.combine(chineseTitle) + hasher.combine(videoDescription) + hasher.combine(views) + hasher.combine(tagSummary) + hasher.combine(sourceCount) + hasher.combine(defaultSourceLabel) + hasher.combine(defaultSourceUrl) + hasher.combine(uploadDate) + hasher.combine(coverUrl) + hasher.combine(artist) + hasher.combine(favTimes) + hasher.combine(isFav) + hasher.combine(csrfToken) + hasher.combine(currentUserId) + hasher.combine(isWatchLater) + hasher.combine(originalComic) + hasher.combine(playbackPositionMillis) + hasher.combine(tags) + hasher.combine(playbackSources) + hasher.combine(playlistName) + playlistVideos.forEach { $0.hash(into: &hasher) } + hasher.combine(myListItems) + relatedVideos.forEach { $0.hash(into: &hasher) } + } +} + +private extension VideoRelatedRow { + func hash(into hasher: inout Hasher) { + hasher.combine(videoCode) + hasher.combine(title) + hasher.combine(coverUrl) + hasher.combine(duration) + hasher.combine(views) + hasher.combine(artist) + hasher.combine(uploadTime) + hasher.combine(isPlaying) + } +} + +struct TapOnlyControl: View { + let isDisabled: Bool + let action: () -> Void + let label: () -> Label + + init( + isDisabled: Bool = false, + action: @escaping () -> Void, + @ViewBuilder label: @escaping () -> Label + ) { + self.isDisabled = isDisabled + self.action = action + self.label = label + } + + var body: some View { + label() + .opacity(isDisabled ? 0.45 : 1) + .contentShape(Rectangle()) + .onTapGesture { + guard !isDisabled else { return } + action() + } + .accessibilityAddTraits(.isButton) + .accessibilityAction { + guard !isDisabled else { return } + action() + } + } +} + +private struct AndroidStyleIntroduction: View { + let snapshot: VideoDetailScreenSnapshot + let videoFeature: VideoFeature + let commentFeature: CommentFeature + let isArtistActionRunning: Bool + let onToggleArtistSubscription: () -> Void + let onToggleFavorite: () -> Void + let onToggleWatchLater: () -> Void + let onSetMyListItem: (VideoMyListRow, Bool) -> Void + let onShowMessage: (String) -> Void + let showsRelated: Bool + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + if let artist = snapshot.artist { + ArtistCard( + artist: artist, + isRunning: isArtistActionRunning, + toggleAction: onToggleArtistSubscription, + videoFeature: videoFeature, + commentFeature: commentFeature + ) + } + + TitleBlock(snapshot: snapshot) + MetadataRow(snapshot: snapshot) + + if let description = snapshot.videoDescription, !description.isEmpty { + ExpandableDescription(text: description) + } + + ActionButtonRow( + snapshot: snapshot, + onToggleFavorite: onToggleFavorite, + onToggleWatchLater: onToggleWatchLater, + onSetMyListItem: onSetMyListItem, + onShowMessage: onShowMessage, + onDownload: { source in + DownloadManager.shared.enqueue( + videoCode: snapshot.videoCode, + quality: source.label, + title: snapshot.title, + coverUrl: snapshot.coverUrl, + remoteUrl: source.url + ) + onShowMessage(String(localized: "已加入下载")) + } + ) + + if !snapshot.tags.isEmpty { + TagFlow( + tags: snapshot.tags, + videoFeature: videoFeature, + commentFeature: commentFeature + ) + } + + if !snapshot.playlistVideos.isEmpty { + HorizontalVideoSection( + title: "系列影片", + subtitle: snapshot.playlistName, + videos: snapshot.playlistVideos, + videoFeature: videoFeature, + commentFeature: commentFeature, + showPlaying: true, + showsMetadataFooter: false + ) + } + + if showsRelated && !snapshot.relatedVideos.isEmpty { + RelatedVideoGrid( + videos: snapshot.relatedVideos, + videoFeature: videoFeature, + commentFeature: commentFeature + ) + } + } + .padding(.horizontal, 16) + } +} + +private struct ArtistCard: View { + let artist: VideoArtistRow + let isRunning: Bool + let toggleAction: () -> Void + let videoFeature: VideoFeature + let commentFeature: CommentFeature + @Environment(\.searchFeature) private var searchFeature + @State private var isConfirmingUnsubscribe = false + @State private var isShowingArtistVideos = false + + var body: some View { + HStack(spacing: 12) { + // Artist avatar / name / genre — tap to push the artist's videos + // page (NavigationLink). Subscription button to the right is + // independent and remains tap-able while the rest of the card + // navigates. + artistInfoTappable + + Spacer() + + TapOnlyControl(isDisabled: isRunning) { + if artist.isSubscribed { + isConfirmingUnsubscribe = true + } else { + toggleAction() + } + } label: { + + Text(artist.isSubscribed ? "已订阅" : "订阅") + .font(.subheadline.weight(.semibold)) + .padding(.horizontal, 14) + .padding(.vertical, 7) + .background(Color.accentColor.opacity(0.14), in: Capsule()) + } + } + .padding(12) + .background(Color(.secondarySystemGroupedBackground), in: RoundedRectangle(cornerRadius: 12, style: .continuous)) + .confirmationDialog("取消订阅该作者", isPresented: $isConfirmingUnsubscribe) { + Button("取消订阅", role: .destructive) { + toggleAction() + } + Button("不取消", role: .cancel) {} + } message: { + Text("确定要取消订阅吗?") + } + .navigationDestination(isPresented: $isShowingArtistVideos) { + if let searchFeature { + ArtistVideosView( + artistName: artist.name, + searchFeature: searchFeature, + videoFeature: videoFeature, + commentFeature: commentFeature + ) + } + } + } + + /// Wraps the avatar + name + genre block in a NavigationLink that pushes + /// the artist's video list. Falls back to a non-tappable label if the + /// SearchFeature isn't available in the environment (shouldn't happen in + /// production but keeps the view robust during previews / testing). + @ViewBuilder + private var artistInfoTappable: some View { + if searchFeature != nil { + TapOnlyControl { + isShowingArtistVideos = true + } label: { + artistInfoLabel + } + } else { + artistInfoLabel + } + } + + private var artistInfoLabel: some View { + HStack(spacing: 12) { + AsyncImage(url: artist.avatarURL) { image in + image.resizable().scaledToFill() + } placeholder: { + Circle().fill(Color.secondary.opacity(0.15)) + } + .frame(width: 52, height: 52) + .clipShape(Circle()) + + VStack(alignment: .leading, spacing: 4) { + Text(artist.name) + .font(.headline) + .foregroundStyle(.primary) + .lineLimit(1) + if let genre = artist.genre, !genre.isEmpty { + Text(genre) + .font(.subheadline) + .foregroundStyle(.secondary) + } + } + } + .contentShape(Rectangle()) + } +} + +private struct TitleBlock: View { + let snapshot: VideoDetailScreenSnapshot + + var body: some View { + VStack(alignment: .leading, spacing: 6) { + Text(snapshot.chineseTitle?.isEmpty == false ? snapshot.chineseTitle! : snapshot.title) + .font(.title2.weight(.bold)) + .foregroundStyle(.primary) + .textSelection(.enabled) + + if let chineseTitle = snapshot.chineseTitle, !chineseTitle.isEmpty, chineseTitle != snapshot.title { + Text(snapshot.title) + .font(.headline) + .foregroundStyle(.secondary) + .textSelection(.enabled) + } + } + } +} + +private struct MetadataRow: View { + let snapshot: VideoDetailScreenSnapshot + + var body: some View { + HStack(spacing: 8) { + if let views = snapshot.views, !views.isEmpty { + Text(String(format: String(localized: "video.views.count"), views)) + } + if snapshot.views?.isEmpty == false && snapshot.uploadDate?.isEmpty == false { + Divider() + .frame(height: 16) + } + if let uploadDate = snapshot.uploadDate, !uploadDate.isEmpty { + Text(uploadDate) + } + } + .font(.subheadline) + .foregroundStyle(.secondary) + } +} + +private struct ExpandableDescription: View { + let text: String + @State private var expanded = false + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + Text(text) + .font(.body) + .foregroundStyle(.primary) + .lineLimit(expanded ? nil : 4) + .textSelection(.enabled) + + TapOnlyControl { + withAnimation(.easeInOut(duration: 0.18)) { + expanded.toggle() + } + } label: { + Text(expanded ? String(localized: "收起") : String(localized: "展开")) + } + .font(.caption.weight(.semibold)) + } + } +} + +private struct ActionButtonRow: View { + let snapshot: VideoDetailScreenSnapshot + let onToggleFavorite: () -> Void + let onToggleWatchLater: () -> Void + let onSetMyListItem: (VideoMyListRow, Bool) -> Void + let onShowMessage: (String) -> Void + /// Invoked when the user picks a concrete quality to download. + let onDownload: (VideoPlaybackSourceRow) -> Void + @Environment(\.openURL) private var openURL + @State private var isShowingMyList = false + @State private var isShowingMoreActions = false + @State private var isShowingShareSheet = false + @State private var isShowingDownloadQuality = false + + private var videoURL: URL? { + siteURL(path: "/watch") + } + + private var downloadURL: URL? { + siteURL(path: "/download") + } + + /// Real downloadable sources (a concrete resolution + a usable URL). + /// A lone "auto" source means the page only exposed a JS-extracted + /// single URL with no resolution choices — in that case we fall back + /// to opening the official download page instead of in-app download. + private var downloadableSources: [VideoPlaybackSourceRow] { + snapshot.playbackSources.filter { $0.label.uppercased() != "AUTO" && !$0.url.isEmpty } + } + + private func siteURL(path: String) -> URL? { + guard var components = URLComponents(string: AppDomain.currentBaseURL) else { + return nil + } + components.path = path + components.queryItems = [ + URLQueryItem(name: "v", value: snapshot.videoCode) + ] + return components.url + } + + var body: some View { + HStack(spacing: 6) { + LabelButton( + title: snapshot.isFav ? "已收藏" : "收藏", + systemImage: snapshot.isFav ? "heart.fill" : "heart", + action: onToggleFavorite + ) + + LabelButton( + title: snapshot.isWatchLater ? "已稍后" : "稍后观看", + systemImage: "text.badge.plus", + action: onToggleWatchLater + ) + + LabelButton( + title: "更多", + systemImage: "ellipsis.circle", + action: { + isShowingMoreActions = true + } + ) + + LabelButton( + title: "分享", + systemImage: "square.and.arrow.up", + action: { + if videoURL != nil { + isShowingShareSheet = true + } + } + ) + } + .confirmationDialog("更多操作", isPresented: $isShowingMoreActions, titleVisibility: .visible) { + Button("加入列表") { + if snapshot.myListItems.isEmpty { + onShowMessage(String(localized: "video.action.playlist.empty")) + } else { + isShowingMyList = true + } + } + + Button("下载") { + if downloadableSources.isEmpty { + // No selectable resolutions parsed — defer to the + // site's official download page in the browser. + if let downloadURL { openURL(downloadURL) } + } else { + isShowingDownloadQuality = true + } + } + + if snapshot.originalComic?.isEmpty == false { + Button("原作漫画") { + if let originalComic = snapshot.originalComic, + let url = URL(string: originalComic) { + openURL(url) + } + } + } + + Button("网页") { + if let videoURL { + openURL(videoURL) + } + } + + Button("取消", role: .cancel) {} + } + .confirmationDialog("播放列表", isPresented: $isShowingMyList) { + ForEach(snapshot.myListItems) { item in + Button(String(format: NSLocalizedString(item.isSelected ? "video.playlist.remove_item" : "video.playlist.add_item", comment: ""), item.title)) { + onSetMyListItem(item, !item.isSelected) + } + } + } + .sheet(isPresented: $isShowingShareSheet) { + if let videoURL { + ActivityView(activityItems: [videoURL]) + } + } + .confirmationDialog("选择下载画质", isPresented: $isShowingDownloadQuality, titleVisibility: .visible) { + ForEach(downloadableSources) { source in + Button(source.label) { + onDownload(source) + } + } + } + } +} + +private struct ActivityView: UIViewControllerRepresentable { + let activityItems: [Any] + + func makeUIViewController(context: Context) -> UIActivityViewController { + let controller = UIActivityViewController(activityItems: activityItems, applicationActivities: nil) + controller.popoverPresentationController?.sourceView = controller.view + controller.popoverPresentationController?.sourceRect = CGRect( + x: controller.view.bounds.midX, + y: controller.view.bounds.midY, + width: 0, + height: 0 + ) + return controller + } + + func updateUIViewController(_ uiViewController: UIActivityViewController, context: Context) {} +} + +private struct LabelButton: View { + let title: String + let systemImage: String + var action: () -> Void = {} + + var body: some View { + TapOnlyControl(action: action) { + LabelButtonContent(title: title, systemImage: systemImage) + } + .frame(maxWidth: .infinity) + } +} + +private struct LabelButtonContent: View { + let title: String + let systemImage: String + + var body: some View { + VStack(spacing: 6) { + Image(systemName: systemImage) + .font(.title3) + Text(title) + .font(.caption) + .lineLimit(1) + } + .frame(maxWidth: .infinity) + .padding(.vertical, 8) + } +} + +private struct TagFlow: View { + let tags: [String] + let videoFeature: VideoFeature + let commentFeature: CommentFeature + @Environment(\.searchFeature) private var searchFeature + @State private var selectedTag: String? + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + Text("标签") + .font(.headline) + // Custom Layout that flows tags onto each row according to their + // measured width, wrapping when the next tag wouldn't fit. Avoids + // the rigid grid look of LazyVGrid where every tag occupies the + // same column width. + FlowLayout(spacing: 8, lineSpacing: 8) { + ForEach(Array(tags.enumerated()), id: \.offset) { _, tag in + if searchFeature != nil { + TapOnlyControl { + selectedTag = tag + } label: { + TagChipText(tag: tag) + .padding(.horizontal, 12) + .padding(.vertical, 7) + .background( + Capsule() + .strokeBorder(Color.accentColor.opacity(0.45), lineWidth: 1) + ) + } + } else { + // Defensive: if the search feature isn't injected ( + // which shouldn't happen in production) the tag still + // renders as a disabled bordered chip rather than + // disappearing entirely. + TagChipText(tag: tag) + .padding(.horizontal, 12) + .padding(.vertical, 7) + .foregroundStyle(.secondary) + .background( + Capsule() + .strokeBorder(Color.secondary.opacity(0.25), lineWidth: 1) + ) + } + } + } + } + .navigationDestination( + isPresented: Binding( + get: { selectedTag != nil }, + set: { if !$0 { selectedTag = nil } } + ) + ) { + if let selectedTag, let searchFeature { + ArtistVideosView( + title: "#\(selectedTag)", + mode: .keyword(selectedTag), + searchFeature: searchFeature, + videoFeature: videoFeature, + commentFeature: commentFeature + ) + } + } + } +} + +private struct TagChipText: View { + let tag: String + + var body: some View { + Text(tag) + .font(.caption) + .lineLimit(1) + .truncationMode(.tail) + .frame(maxWidth: 240, alignment: .leading) + } +} + +/// Lightweight flow layout: lays out subviews left-to-right, wrapping to a +/// new line when a child wouldn't fit on the current one. Each child takes +/// its natural intrinsic width, so different-length labels pack tightly. +private struct FlowLayout: Layout { + var spacing: CGFloat = 8 + var lineSpacing: CGFloat = 8 + + func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize { + let maxWidth = proposal.width ?? .infinity + let result = arrange(in: maxWidth, subviews: subviews) + // Report content size based on actually used width when proposal is + // unbounded, otherwise fill the proposal so the parent can size us + // consistently. + let width: CGFloat + if maxWidth.isFinite { + width = maxWidth + } else { + width = result.usedWidth + } + return CGSize(width: width, height: result.totalHeight) + } + + func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) { + let result = arrange(in: bounds.width, subviews: subviews) + for (index, frame) in result.frames.enumerated() { + let origin = CGPoint(x: bounds.minX + frame.origin.x, y: bounds.minY + frame.origin.y) + subviews[index].place(at: origin, anchor: .topLeading, proposal: ProposedViewSize(width: frame.width, height: frame.height)) + } + } + + private struct Arranged { + let frames: [CGRect] + let totalHeight: CGFloat + let usedWidth: CGFloat + } + + private func arrange(in maxWidth: CGFloat, subviews: Subviews) -> Arranged { + var frames: [CGRect] = [] + var x: CGFloat = 0 + var y: CGFloat = 0 + var currentRowHeight: CGFloat = 0 + var maxRowEnd: CGFloat = 0 + + for subview in subviews { + let size = subview.sizeThatFits(.unspecified) + // If this subview wouldn't fit on the current row, wrap. + if x > 0 && x + size.width > maxWidth { + y += currentRowHeight + lineSpacing + x = 0 + currentRowHeight = 0 + } + frames.append(CGRect(x: x, y: y, width: size.width, height: size.height)) + x += size.width + spacing + currentRowHeight = max(currentRowHeight, size.height) + maxRowEnd = max(maxRowEnd, x - spacing) + } + + let totalHeight = y + currentRowHeight + return Arranged(frames: frames, totalHeight: totalHeight, usedWidth: maxRowEnd) + } +} From 62e637c5461658dfa7e322880eb2ca5e49d13a75 Mon Sep 17 00:00:00 2001 From: pgs666 <74764545+pgs666@users.noreply.github.com> Date: Fri, 5 Jun 2026 23:57:25 +0800 Subject: [PATCH 091/216] Stabilize related video card thumbnails --- iosApp/RelatedVideoComponents.swift | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/iosApp/RelatedVideoComponents.swift b/iosApp/RelatedVideoComponents.swift index ee8b135e..6b09b98d 100644 --- a/iosApp/RelatedVideoComponents.swift +++ b/iosApp/RelatedVideoComponents.swift @@ -268,8 +268,14 @@ struct RelatedVideoCard: View { var body: some View { VStack(alignment: .leading, spacing: 6) { ZStack(alignment: .bottom) { - CachedRemoteImage(urlString: video.coverUrl, resizeWidth: 172) + Color(.secondarySystemBackground) + .opacity(0.5) .aspectRatio(16.0 / 9.0, contentMode: .fit) + .overlay { + CachedRemoteImage(urlString: video.coverUrl, resizeWidth: 172) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .clipped() + } LinearGradient( colors: [ @@ -333,7 +339,7 @@ struct RelatedVideoCard: View { .frame(height: 16) } } - .frame(width: width, alignment: .leading) + .frame(width: width, maxWidth: width == nil ? .infinity : nil, alignment: .leading) .padding(8) .background(Color(.systemBackground)) .clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous)) From 56fa8d2a95f5d97604974c63ad907f0eb197a0cc Mon Sep 17 00:00:00 2001 From: pgs666 <74764545+pgs666@users.noreply.github.com> Date: Sat, 6 Jun 2026 00:02:40 +0800 Subject: [PATCH 092/216] Handle portrait related video thumbnails --- iosApp/RelatedVideoComponents.swift | 32 +++++++++++++++++++++++++++-- 1 file changed, 30 insertions(+), 2 deletions(-) diff --git a/iosApp/RelatedVideoComponents.swift b/iosApp/RelatedVideoComponents.swift index 6b09b98d..ce596054 100644 --- a/iosApp/RelatedVideoComponents.swift +++ b/iosApp/RelatedVideoComponents.swift @@ -252,6 +252,7 @@ struct RelatedVideoCard: View { let showPlaying: Bool let showsMetadataFooter: Bool let width: CGFloat? + @State private var coverNaturalSize: CGSize? init( video: VideoRelatedRow, @@ -272,7 +273,14 @@ struct RelatedVideoCard: View { .opacity(0.5) .aspectRatio(16.0 / 9.0, contentMode: .fit) .overlay { - CachedRemoteImage(urlString: video.coverUrl, resizeWidth: 172) + CachedRemoteImage( + urlString: video.coverUrl, + contentMode: coverContentMode, + resizeWidth: 240, + onImageLoaded: { size in + coverNaturalSize = size + } + ) .frame(maxWidth: .infinity, maxHeight: .infinity) .clipped() } @@ -339,11 +347,20 @@ struct RelatedVideoCard: View { .frame(height: 16) } } - .frame(width: width, maxWidth: width == nil ? .infinity : nil, alignment: .leading) + .relatedVideoCardWidth(width) .padding(8) .background(Color(.systemBackground)) .clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous)) } + + private var coverContentMode: ContentMode { + guard let coverNaturalSize, + coverNaturalSize.width > 0, + coverNaturalSize.height > coverNaturalSize.width * 1.1 else { + return .fill + } + return .fit + } } private extension VideoRelatedRow { @@ -355,6 +372,17 @@ private extension VideoRelatedRow { } } +private extension View { + @ViewBuilder + func relatedVideoCardWidth(_ width: CGFloat?) -> some View { + if let width { + frame(width: width, alignment: .leading) + } else { + frame(maxWidth: .infinity, alignment: .leading) + } + } +} + extension VideoDetailScreenSnapshot { var coverURL: URL? { coverUrl.flatMap(URL.init(string:)) From 27c87ca60018158951228f59e3f5fb8c394cea2c Mon Sep 17 00:00:00 2001 From: pgs666 <74764545+pgs666@users.noreply.github.com> Date: Sat, 6 Jun 2026 00:56:11 +0800 Subject: [PATCH 093/216] Refactor video detail pager --- iosApp/CommentView.swift | 12 +- iosApp/KSPlayerView.swift | 50 +++- iosApp/VideoDetailView.swift | 500 ++++++++++++++++++++--------------- 3 files changed, 349 insertions(+), 213 deletions(-) diff --git a/iosApp/CommentView.swift b/iosApp/CommentView.swift index 6471f4ca..13b8584f 100644 --- a/iosApp/CommentView.swift +++ b/iosApp/CommentView.swift @@ -69,8 +69,7 @@ struct CommentView: View { comment: comment, viewModel: viewModel ) { reply in - replyText = "@\(reply.username) " - replyTarget = reply + beginReplyFromRepliesSheet(reply) } } .confirmationDialog("举报原因", isPresented: reportDialogBinding, titleVisibility: .visible) { @@ -241,6 +240,15 @@ struct CommentView: View { onOverlayActivityChanged(replyTarget != nil || repliesTarget != nil) } + private func beginReplyFromRepliesSheet(_ reply: CommentRow) { + replyText = "@\(reply.username) " + repliesTarget = nil + Task { @MainActor in + await Task.yield() + replyTarget = reply + } + } + private func commentRenderSignature(for comments: [CommentRow]) -> String { [ viewModel.sortMode.id, diff --git a/iosApp/KSPlayerView.swift b/iosApp/KSPlayerView.swift index 215896f6..cf2aefd2 100644 --- a/iosApp/KSPlayerView.swift +++ b/iosApp/KSPlayerView.swift @@ -71,6 +71,8 @@ struct KSPlayerView: View { /// Whether `onNaturalSize` has been fired already. We only want to /// notify the parent once — the natural size won't change mid-playback. @State private var naturalSizeReported = false + @State private var lastProgressReportSeconds: TimeInterval? + @State private var lastProgressReportAt = Date.distantPast /// User-picked playback source (quality). Nil = use the snapshot's /// default-marked source via primarySource(). Wired up from the /// quality menu in bottomBar; switching value re-evaluates `body` and @@ -260,7 +262,7 @@ struct KSPlayerView: View { // user rewind to 0 (or below ~2s) silently failed to // persist, so the saved resume position kept its // previous value across re-entry. - onProgress(current) + reportPlaybackProgress(current) } .onFinish { _, _ in setPlayingState(false) @@ -449,6 +451,8 @@ struct KSPlayerView: View { lastLoadedEnd = 0 lastSampleAt = nil currentSpeedText = nil + lastProgressReportSeconds = nil + lastProgressReportAt = Date.distantPast // Bind the status observer eagerly if a layer already exists // (re-appear after navigation pop); fresh mounts will pick // it up via the onStateChanged callback once the layer is @@ -869,7 +873,7 @@ struct KSPlayerView: View { Menu { ForEach(snapshot.playbackSources) { source in Button { - selectedSourceID = source.id + selectPlaybackSource(id: source.id) } label: { HStack { Text(source.label) @@ -1024,6 +1028,48 @@ struct KSPlayerView: View { return primarySource() } + private func reportPlaybackProgress(_ current: TimeInterval) { + let now = Date() + let shouldReportImmediately = current <= 2 + && (lastProgressReportSeconds.map { abs(current - $0) >= 0.5 } ?? true) + let movedEnough = lastProgressReportSeconds.map { abs(current - $0) >= 5 } ?? true + let waitedEnough = now.timeIntervalSince(lastProgressReportAt) >= 5 + guard shouldReportImmediately || movedEnough || waitedEnough else { return } + + lastProgressReportSeconds = current + lastProgressReportAt = now + onProgress(current) + } + + private func selectPlaybackSource(id: String) { + guard activeSource?.id != id else { return } + selectedSourceID = id + resetPlaybackSessionForSourceChange() + } + + private func resetPlaybackSessionForSourceChange() { + hideControlsTask?.cancel() + speedSampleTask?.cancel() + speedSampleTask = nil + currentSpeedText = nil + statusObserver.observe(nil) + hasReachedStartPlayTime = false + hasAppliedResumeSeek = false + naturalSizeReported = false + autoPlayApplied = false + stateLogBudget = 8 + lastLoadedEnd = 0 + lastSampleAt = nil + lastProgressReportSeconds = nil + lastProgressReportAt = Date.distantPast + sliderValue = 0 + isSliderEditing = false + resetSwipeHUDState() + coordinator.playerLayer?.pause() + setPlayingState(false) + scheduleAutoHide() + } + /// Unified down/move handler. Called from `DragGesture(minimumDistance: 0)` /// so we get an onChanged on every touch-down. First call starts a 0.4s /// long-press timer (=> startBoost); subsequent calls cancel that timer diff --git a/iosApp/VideoDetailView.swift b/iosApp/VideoDetailView.swift index 94be20a5..38342f6d 100644 --- a/iosApp/VideoDetailView.swift +++ b/iosApp/VideoDetailView.swift @@ -11,23 +11,14 @@ struct VideoDetailView: View { private let playerContinuationStripHeight: CGFloat = 56 @StateObject private var viewModel: VideoDetailViewModel @StateObject private var commentViewModel: CommentViewModel - @State private var selectedTab = VideoPageTab.introduction + @State private var pagerState = VideoDetailPagerState() @State private var isPlayerFullscreen = false - @State private var isPlayerPlaying = false @State private var playerPlayRequestToken = 0 - /// Global collapse offset for the inline player, decoupled from any - /// single tab's ScrollView offset. If one tab has already collapsed the - /// player, switching to another tab must not snap it back open just - /// because that tab's own content is still at the top. - @State private var bottomScrollOffset: CGFloat = 0 - /// Each tab owns an independent vertical ScrollView below the player, so - /// switching between intro / comments preserves their separate scroll - /// positions instead of sharing one outer ScrollView offset. - @State private var bottomScrollOffsetsByTab: [VideoPageTab: CGFloat] = [:] @State private var commentComposeText = "" @State private var isCommentInternalOverlayActive = false @State private var commentComposerHeight: CGFloat = CommentComposerBar.compactHeight @State private var fullscreenOrientationTask: Task? + @State private var isFullscreenOrientationLocked = false /// Natural size of the loaded video (reported by KSPlayer the first time /// the underlying player gets a non-zero presentation size). Used to /// decide whether fullscreen should lock the device to portrait or @@ -96,8 +87,9 @@ struct VideoDetailView: View { fullscreenOrientationTask = nil // KSPlayer pauses itself in its own .onDisappear; the // detail VM no longer owns a player. - if isPlayerFullscreen { + if isPlayerFullscreen || isFullscreenOrientationLocked { AppOrientationController.shared.unlockAfterFullscreen() + isFullscreenOrientationLocked = false } } // Apple-Music-style centred HUD for action results @@ -161,8 +153,12 @@ struct VideoDetailView: View { /// 2. The user has the "force portrait fullscreen for vertical /// videos" preference enabled (default ON). private var fullscreenOrientation: VideoFullscreenOrientation { + fullscreenOrientation(forNaturalSize: videoNaturalSize) + } + + private func fullscreenOrientation(forNaturalSize naturalSize: CGSize?) -> VideoFullscreenOrientation { let isPortraitVideo: Bool = { - guard let size = videoNaturalSize else { return false } + guard let size = naturalSize else { return false } return size.height > size.width }() if isPortraitVideo && forcePortraitForVerticalVideos { @@ -176,7 +172,7 @@ struct VideoDetailView: View { } private var shouldShowRootCommentComposer: Bool { - guard !isPlayerFullscreen, selectedTab == .comments else { return false } + guard !isPlayerFullscreen, pagerState.selectedTab == .comments else { return false } guard !isCommentInternalOverlayActive else { return false } guard isCommentComposerReady else { return false } if case .loaded = viewModel.state { @@ -248,27 +244,19 @@ struct VideoDetailView: View { GeometryReader { proxy in let isWide = usesTabletRelatedSidebar(for: proxy.size) let leftWidth = leftPanelWidth(for: proxy.size) + let inlineHeight = inlinePlayerHeight(panelWidth: leftWidth) + let pagerMetrics = pagerState.layoutMetrics( + inlinePlayerHeight: inlineHeight, + containerHeight: proxy.size.height, + rawCollapseDistance: playerCollapseDistance(panelWidth: leftWidth) + ) HStack(alignment: .top, spacing: 0) { ZStack(alignment: .top) { - let currentPlayerCollapseDistance = playerCollapseDistance(panelWidth: leftWidth) - let currentInlinePlayerHeight = inlinePlayerHeight(panelWidth: leftWidth) let currentPlayerHeight = playerHeight( panelWidth: leftWidth, parentHeight: proxy.size.height ) - let currentPlayerScrollAway = isPlayerPlaying - ? 0 - : playerVisualShrink(panelWidth: leftWidth) - let currentCollapseDistance = isPlayerPlaying - ? 0 - : currentPlayerCollapseDistance - let currentBottomScrollOffset = max(currentInlinePlayerHeight - currentPlayerScrollAway, 0) - let currentBottomScrollHeight = proxy.size.height - currentBottomScrollOffset - let currentContinuationProgress = continuationStripProgress( - scrollAway: currentPlayerScrollAway, - panelWidth: leftWidth - ) playerArea(snapshot: snapshot) .frame( width: leftWidth, @@ -283,20 +271,21 @@ struct VideoDetailView: View { belowPlayerScroll( snapshot: snapshot, showsRelated: !isWide, - collapseDistance: currentCollapseDistance, - collapseCompensation: currentPlayerScrollAway, + collapseDistance: pagerMetrics.collapseDistance, + playerScrollAway: pagerMetrics.playerScrollAway, + introductionContentClearance: introductionContentClearance(), composerContentClearance: commentComposerContentClearance( safeAreaBottom: proxy.safeAreaInsets.bottom ) ) - .frame(height: max(0, currentBottomScrollHeight)) - .offset(y: currentBottomScrollOffset) + .frame(height: pagerMetrics.belowPlayerHeight) + .offset(y: pagerMetrics.belowPlayerTopOffset) .opacity(isPlayerFullscreen ? 0 : 1) .allowsHitTesting(!isPlayerFullscreen) if !isPlayerFullscreen, - currentContinuationProgress > 0 { - continuePlayingStrip(snapshot: snapshot, progress: currentContinuationProgress) + pagerMetrics.continuationProgress > 0 { + continuePlayingStrip(snapshot: snapshot, progress: pagerMetrics.continuationProgress) .frame(width: leftWidth, height: playerContinuationStripHeight) } } @@ -351,17 +340,6 @@ struct VideoDetailView: View { max(panelWidth * 9 / 16 - playerContinuationStripHeight, 1) } - private func playerVisualShrink(panelWidth: CGFloat) -> CGFloat { - min(max(bottomScrollOffset, 0), playerCollapseDistance(panelWidth: panelWidth)) - } - - private func continuationStripProgress(scrollAway: CGFloat, panelWidth: CGFloat) -> CGFloat { - let collapseDistance = playerCollapseDistance(panelWidth: panelWidth) - let fadeDistance: CGFloat = 72 - guard collapseDistance > 1 else { return 0 } - return min(max((scrollAway - (collapseDistance - fadeDistance)) / fadeDistance, 0), 1) - } - private func playerArea(snapshot: VideoDetailScreenSnapshot) -> some View { return KSPlayerView( snapshot: snapshot, @@ -369,17 +347,20 @@ struct VideoDetailView: View { onProgress: { viewModel.recordPlaybackPosition(seconds: $0) }, onPlaybackEnded: { viewModel.recordPlaybackPosition(seconds: 0) }, onPlayingChanged: { newValue in - if isPlayerPlaying != newValue { - isPlayerPlaying = newValue - if newValue, abs(bottomScrollOffset) > 0.5 { - bottomScrollOffset = 0 - } + guard pagerState.isPlayerPlaying != newValue else { return } + withTransaction(Transaction(animation: nil)) { + pagerState.setPlayerPlaying(newValue) } }, onBack: { dismiss() }, playRequestToken: playerPlayRequestToken, onNaturalSize: { size in + let orientation = fullscreenOrientation(forNaturalSize: size) videoNaturalSize = size + if isPlayerFullscreen { + AppOrientationController.shared.lockForFullscreen(to: orientation) + isFullscreenOrientationLocked = true + } } ) } @@ -387,7 +368,7 @@ struct VideoDetailView: View { private func continuePlayingStrip(snapshot: VideoDetailScreenSnapshot, progress: CGFloat) -> some View { Button { withAnimation(.easeInOut(duration: 0.25)) { - bottomScrollOffset = 0 + pagerState.expandPlayer() } playerPlayRequestToken &+= 1 } label: { @@ -417,98 +398,45 @@ struct VideoDetailView: View { snapshot: VideoDetailScreenSnapshot, showsRelated: Bool, collapseDistance: CGFloat, - collapseCompensation: CGFloat, + playerScrollAway: CGFloat, + introductionContentClearance: CGFloat, composerContentClearance: CGFloat ) -> some View { let contentRevision = tabContentRevision(snapshot: snapshot, showsRelated: showsRelated) - return VStack(spacing: 0) { - Picker("Content", selection: $selectedTab) { - ForEach(VideoPageTab.allCases) { tab in - Text(tab.title).tag(tab) - } - } - .pickerStyle(.segmented) - .padding(.horizontal, 16) - .padding(.vertical, 8) - .background(.background) - - VideoDetailTabPager( - selectedTab: $selectedTab, - introduction: tabPage( - .introduction, - contentUpdateRevision: contentRevision.introduction - ) { - AndroidStyleIntroduction( - snapshot: snapshot, - videoFeature: videoFeature, - commentFeature: commentFeature, - isArtistActionRunning: viewModel.isActionRunning("artistSubscription"), - onToggleArtistSubscription: { viewModel.toggleArtistSubscription(snapshot: snapshot) }, - onToggleFavorite: { viewModel.toggleFavorite(snapshot: snapshot) }, - onToggleWatchLater: { viewModel.toggleWatchLater(snapshot: snapshot) }, - onSetMyListItem: { item, isSelected in viewModel.setMyListItem(snapshot: snapshot, item: item, isSelected: isSelected) }, - onShowMessage: { viewModel.showActionMessage($0) }, - showsRelated: showsRelated - ) - .padding(.top, 16) - } collapseCompensation: { - tabCollapseCompensation(for: .introduction, collapseCompensation: collapseCompensation) - } collapseDistance: { - collapseDistance - }, - comments: tabPage( - .comments, - contentBottomPadding: composerContentClearance, - contentUpdateRevision: contentRevision.comments - ) { - CommentView( - viewModel: commentViewModel, - onOverlayActivityChanged: { isActive in - isCommentInternalOverlayActive = isActive - } - ) - .padding(.top, 16) - } collapseCompensation: { - tabCollapseCompensation(for: .comments, collapseCompensation: collapseCompensation) - } collapseDistance: { - collapseDistance - } - ) - .frame(maxHeight: .infinity) - } - .frame(maxHeight: .infinity) - .background(Color(.systemGroupedBackground)) - .onValueChange(of: selectedTab) { _ in - withTransaction(Transaction(animation: nil)) { - bottomScrollOffset = min(max(bottomScrollOffset, 0), collapseDistance) - } - } - } - - private func updatePlayerCollapseOffset( - activeTabOffset: CGFloat, - previousActiveTabOffset: CGFloat?, - collapseDistance: CGFloat - ) { - guard !isPlayerPlaying else { - if abs(bottomScrollOffset) > 0.5 { - withTransaction(Transaction(animation: nil)) { - bottomScrollOffset = 0 - } + return VideoDetailPagerContainer( + state: $pagerState, + collapseDistance: collapseDistance, + playerScrollAway: playerScrollAway, + introductionContentBottomPadding: introductionContentClearance, + commentsContentBottomPadding: composerContentClearance, + introductionContentRevision: contentRevision.introduction, + commentsContentRevision: contentRevision.comments, + introduction: { + AndroidStyleIntroduction( + snapshot: snapshot, + videoFeature: videoFeature, + commentFeature: commentFeature, + isArtistActionRunning: viewModel.isActionRunning("artistSubscription"), + onToggleArtistSubscription: { viewModel.toggleArtistSubscription(snapshot: snapshot) }, + onToggleFavorite: { viewModel.toggleFavorite(snapshot: snapshot) }, + onToggleWatchLater: { viewModel.toggleWatchLater(snapshot: snapshot) }, + onSetMyListItem: { item, isSelected in viewModel.setMyListItem(snapshot: snapshot, item: item, isSelected: isSelected) }, + onShowMessage: { viewModel.showActionMessage($0) }, + showsRelated: showsRelated + ) + .padding(.top, 16) + }, + comments: { + CommentView( + viewModel: commentViewModel, + onOverlayActivityChanged: { isActive in + isCommentInternalOverlayActive = isActive + } + ) + .padding(.top, 16) } - return - } - let nextOffset = VideoPlayerCollapseModel.nextCollapseOffset( - currentCollapseOffset: bottomScrollOffset, - activeTabOffset: activeTabOffset, - previousActiveTabOffset: previousActiveTabOffset, - collapseDistance: collapseDistance ) - guard abs(bottomScrollOffset - nextOffset) > 0.5 else { return } - withTransaction(Transaction(animation: nil)) { - bottomScrollOffset = nextOffset - } } private func submitComment() { @@ -525,72 +453,14 @@ struct VideoDetailView: View { guard !Task.isCancelled, isPlayerFullscreen == isFullscreen else { return } if isFullscreen { AppOrientationController.shared.lockForFullscreen(to: fullscreenOrientation) - } else { + isFullscreenOrientationLocked = true + } else if isFullscreenOrientationLocked { AppOrientationController.shared.unlockAfterFullscreen() + isFullscreenOrientationLocked = false } } } - private func tabPage( - _ tab: VideoPageTab, - contentBottomPadding: CGFloat = 24, - contentUpdateRevision: Int, - @ViewBuilder content: @escaping () -> Content, - collapseCompensation: @escaping () -> CGFloat = { 0 }, - collapseDistance: @escaping () -> CGFloat = { 0 } - ) -> VideoDetailTabPage { - VideoDetailTabPage( - tab: tab, - contentBottomPadding: contentBottomPadding, - collapseCompensation: collapseCompensation(), - collapseDistance: collapseDistance(), - contentUpdateRevision: contentUpdateRevision, - onOffsetChange: { tab, offset in - updateTabOffset(tab, offset: offset, collapseDistance: collapseDistance()) - }, - onInteractionBegan: { tab in - updateInteractingTab(tab) - }, - onTopPullDelta: { tab, delta in - updateTabTopPull(tab, delta: delta, collapseDistance: collapseDistance()) - }, - content: content - ) - } - - private func updateTabOffset(_ tab: VideoPageTab, offset: CGFloat, collapseDistance: CGFloat) { - let previousActiveOffset = bottomScrollOffsetsByTab[selectedTab] - withTransaction(Transaction(animation: nil)) { - bottomScrollOffsetsByTab[tab] = offset - guard tab == selectedTab else { return } - updatePlayerCollapseOffset( - activeTabOffset: offset, - previousActiveTabOffset: previousActiveOffset, - collapseDistance: collapseDistance - ) - } - } - - private func tabCollapseCompensation(for tab: VideoPageTab, collapseCompensation: CGFloat) -> CGFloat { - min(max(bottomScrollOffsetsByTab[tab] ?? 0, 0), collapseCompensation) - } - - private func updateInteractingTab(_ tab: VideoPageTab) { - guard selectedTab != tab else { return } - withTransaction(Transaction(animation: nil)) { - selectedTab = tab - } - } - - private func updateTabTopPull(_ tab: VideoPageTab, delta: CGFloat, collapseDistance: CGFloat) { - guard !isPlayerPlaying, delta > 0, bottomScrollOffset > 0 else { return } - let nextOffset = min(max(bottomScrollOffset - delta, 0), collapseDistance) - guard abs(bottomScrollOffset - nextOffset) > 0.5 else { return } - withTransaction(Transaction(animation: nil)) { - bottomScrollOffset = nextOffset - } - } - private func commentComposerContentClearance(safeAreaBottom: CGFloat) -> CGFloat { let containerBottomInset = currentWindowBottomSafeAreaInset() let isKeyboardSafeAreaActive = safeAreaBottom > containerBottomInset + 1 @@ -598,6 +468,10 @@ struct VideoDetailView: View { return composerHeight + (isKeyboardSafeAreaActive ? 0 : containerBottomInset) } + private func introductionContentClearance() -> CGFloat { + currentWindowBottomSafeAreaInset() + 24 + } + private func currentWindowBottomSafeAreaInset() -> CGFloat { currentWindowScene()? .windows @@ -783,6 +657,116 @@ private enum VideoPlayerCollapseModel { } } +private struct VideoDetailPagerLayoutMetrics { + let collapseDistance: CGFloat + let playerScrollAway: CGFloat + let belowPlayerTopOffset: CGFloat + let belowPlayerHeight: CGFloat + let continuationProgress: CGFloat +} + +private struct VideoDetailPagerState: Equatable { + var selectedTab: VideoPageTab = .introduction + var collapseOffset: CGFloat = 0 + var tabOffsets: [VideoPageTab: CGFloat] = [:] + var isPlayerPlaying: Bool = false + + func layoutMetrics( + inlinePlayerHeight: CGFloat, + containerHeight: CGFloat, + rawCollapseDistance: CGFloat + ) -> VideoDetailPagerLayoutMetrics { + let collapseDistance = isPlayerPlaying ? 0 : max(rawCollapseDistance, 0) + let playerScrollAway = isPlayerPlaying ? 0 : Self.clamp(collapseOffset, upperBound: collapseDistance) + let belowPlayerTopOffset = max(inlinePlayerHeight - playerScrollAway, 0) + let fadeDistance: CGFloat = 72 + let continuationProgress: CGFloat + if rawCollapseDistance > 1 { + continuationProgress = min( + max((playerScrollAway - (rawCollapseDistance - fadeDistance)) / fadeDistance, 0), + 1 + ) + } else { + continuationProgress = 0 + } + + return VideoDetailPagerLayoutMetrics( + collapseDistance: collapseDistance, + playerScrollAway: playerScrollAway, + belowPlayerTopOffset: belowPlayerTopOffset, + belowPlayerHeight: max(0, containerHeight - belowPlayerTopOffset), + continuationProgress: continuationProgress + ) + } + + func contentTopInset(for tab: VideoPageTab, playerScrollAway: CGFloat) -> CGFloat { + Self.clamp(tabOffsets[tab] ?? 0, upperBound: playerScrollAway) + } + + mutating func setPlayerPlaying(_ isPlaying: Bool) { + isPlayerPlaying = isPlaying + if isPlaying { + collapseOffset = 0 + } + } + + mutating func expandPlayer() { + collapseOffset = 0 + } + + mutating func selectTab(_ tab: VideoPageTab, collapseDistance: CGFloat) { + selectedTab = tab + clampCollapse(to: collapseDistance) + } + + mutating func updateTabOffset(_ tab: VideoPageTab, offset: CGFloat, collapseDistance: CGFloat) { + let previousActiveOffset = tabOffsets[selectedTab] + tabOffsets[tab] = offset + guard tab == selectedTab else { return } + updateCollapseOffset( + activeTabOffset: offset, + previousActiveTabOffset: previousActiveOffset, + collapseDistance: collapseDistance + ) + } + + mutating func beginInteracting(with tab: VideoPageTab, collapseDistance: CGFloat) { + guard selectedTab != tab else { return } + selectTab(tab, collapseDistance: collapseDistance) + } + + mutating func handleTopPull(tab: VideoPageTab, delta: CGFloat, collapseDistance: CGFloat) { + guard tab == selectedTab, !isPlayerPlaying, delta > 0, collapseOffset > 0 else { return } + collapseOffset = Self.clamp(collapseOffset - delta, upperBound: collapseDistance) + } + + mutating func clampCollapse(to collapseDistance: CGFloat) { + collapseOffset = Self.clamp(collapseOffset, upperBound: collapseDistance) + } + + private mutating func updateCollapseOffset( + activeTabOffset: CGFloat, + previousActiveTabOffset: CGFloat?, + collapseDistance: CGFloat + ) { + guard !isPlayerPlaying else { + collapseOffset = 0 + return + } + + collapseOffset = VideoPlayerCollapseModel.nextCollapseOffset( + currentCollapseOffset: collapseOffset, + activeTabOffset: activeTabOffset, + previousActiveTabOffset: previousActiveTabOffset, + collapseDistance: collapseDistance + ) + } + + private static func clamp(_ value: CGFloat, upperBound: CGFloat) -> CGFloat { + min(max(value, 0), max(upperBound, 0)) + } +} + private enum VideoPageTab: String, CaseIterable, Identifiable { case introduction case comments @@ -822,7 +806,7 @@ private struct VideoDetailTabContentRevision: Equatable { private struct VideoDetailTabPage { let tab: VideoPageTab let contentBottomPadding: CGFloat - let collapseCompensation: CGFloat + let contentTopInset: CGFloat let collapseDistance: CGFloat let contentUpdateRevision: Int let onOffsetChange: (VideoPageTab, CGFloat) -> Void @@ -833,7 +817,7 @@ private struct VideoDetailTabPage { init( tab: VideoPageTab, contentBottomPadding: CGFloat, - collapseCompensation: CGFloat, + contentTopInset: CGFloat, collapseDistance: CGFloat, contentUpdateRevision: Int, onOffsetChange: @escaping (VideoPageTab, CGFloat) -> Void, @@ -843,7 +827,7 @@ private struct VideoDetailTabPage { ) { self.tab = tab self.contentBottomPadding = contentBottomPadding - self.collapseCompensation = collapseCompensation + self.contentTopInset = contentTopInset self.collapseDistance = collapseDistance self.contentUpdateRevision = contentUpdateRevision self.onOffsetChange = onOffsetChange @@ -853,6 +837,102 @@ private struct VideoDetailTabPage { } } +private struct VideoDetailPagerContainer: View { + @Binding var state: VideoDetailPagerState + let collapseDistance: CGFloat + let playerScrollAway: CGFloat + let introductionContentBottomPadding: CGFloat + let commentsContentBottomPadding: CGFloat + let introductionContentRevision: Int + let commentsContentRevision: Int + let introduction: () -> Introduction + let comments: () -> Comments + + private var selectedTabBinding: Binding { + Binding( + get: { state.selectedTab }, + set: { newTab in + mutateState { $0.selectTab(newTab, collapseDistance: collapseDistance) } + } + ) + } + + var body: some View { + VStack(spacing: 0) { + Picker("Content", selection: selectedTabBinding) { + ForEach(VideoPageTab.allCases) { tab in + Text(tab.title).tag(tab) + } + } + .pickerStyle(.segmented) + .padding(.horizontal, 16) + .padding(.vertical, 8) + .background(.background) + + VideoDetailTabPager( + selectedTab: selectedTabBinding, + introduction: tabPage( + .introduction, + contentBottomPadding: introductionContentBottomPadding, + contentUpdateRevision: introductionContentRevision, + content: introduction + ), + comments: tabPage( + .comments, + contentBottomPadding: commentsContentBottomPadding, + contentUpdateRevision: commentsContentRevision, + content: comments + ) + ) + .frame(maxHeight: .infinity) + } + .frame(maxHeight: .infinity) + .background(Color(.systemGroupedBackground)) + .onValueChange(of: collapseDistance) { newValue in + mutateState { $0.clampCollapse(to: newValue) } + } + } + + private func tabPage( + _ tab: VideoPageTab, + contentBottomPadding: CGFloat, + contentUpdateRevision: Int, + @ViewBuilder content: @escaping () -> Content + ) -> VideoDetailTabPage { + VideoDetailTabPage( + tab: tab, + contentBottomPadding: contentBottomPadding, + contentTopInset: state.contentTopInset(for: tab, playerScrollAway: playerScrollAway), + collapseDistance: collapseDistance, + contentUpdateRevision: contentUpdateRevision, + onOffsetChange: { tab, offset in + mutateState { + $0.updateTabOffset(tab, offset: offset, collapseDistance: collapseDistance) + } + }, + onInteractionBegan: { tab in + mutateState { + $0.beginInteracting(with: tab, collapseDistance: collapseDistance) + } + }, + onTopPullDelta: { tab, delta in + mutateState { + $0.handleTopPull(tab: tab, delta: delta, collapseDistance: collapseDistance) + } + }, + content: content + ) + } + + private func mutateState(_ mutation: (inout VideoDetailPagerState) -> Void) { + withTransaction(Transaction(animation: nil)) { + var nextState = state + mutation(&nextState) + state = nextState + } + } +} + private final class VideoDetailVerticalScrollPageCoordinator: NSObject, UIScrollViewDelegate { var tab: VideoPageTab var onOffsetChange: (VideoPageTab, CGFloat) -> Void @@ -949,9 +1029,7 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle scrollView.panGestureRecognizer.addTarget(coordinator, action: #selector(VideoDetailVerticalScrollPageCoordinator.handlePan(_:))) scrollView.shouldBeginVerticalPan = { panGestureRecognizer, view in let velocity = panGestureRecognizer.velocity(in: view) - guard abs(velocity.x) > abs(velocity.y) * 1.05 else { return true } - let startLocation = panGestureRecognizer.location(in: view) - return !view.hasScrollableHorizontalDescendant(at: startLocation, excluding: view) + return abs(velocity.x) <= abs(velocity.y) * 1.05 } view.addSubview(scrollView) @@ -1019,11 +1097,10 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle host.rootView = page.content() } - hostTopConstraint.constant = 0 - host.view.transform = CGAffineTransform(translationX: 0, y: page.collapseCompensation) - bottomPaddingView.transform = CGAffineTransform(translationX: 0, y: page.collapseCompensation) + let contentTopInset = min(max(page.contentTopInset, 0), max(page.collapseDistance, 0)) + hostTopConstraint.constant = contentTopInset bottomPaddingHeightConstraint.constant = page.contentBottomPadding - collapseSpacerHeightConstraint.constant = page.collapseDistance + 1 + collapseSpacerHeightConstraint.constant = max(page.collapseDistance - contentTopInset + 1, 1) } } @@ -1145,6 +1222,7 @@ private struct VideoDetailTabPager: UIViewControllerRepresentable { private let commentsPage = VideoDetailVerticalScrollPageViewController(tab: .comments) private var selectedIndex = 0 private var pendingSelectedIndex: Int? + private var lastLaidOutWidth: CGFloat = 0 init(coordinator: Coordinator) { self.coordinator = coordinator @@ -1215,10 +1293,13 @@ private struct VideoDetailTabPager: UIViewControllerRepresentable { override func viewDidLayoutSubviews() { super.viewDidLayoutSubviews() + let width = scrollView.bounds.width + let widthChanged = abs(width - lastLaidOutWidth) > 0.5 + lastLaidOutWidth = width if let pendingSelectedIndex { self.pendingSelectedIndex = nil setSelectedIndex(pendingSelectedIndex, animated: false) - } else { + } else if widthChanged { setSelectedIndex(selectedIndex, animated: false) } } @@ -1232,6 +1313,7 @@ private struct VideoDetailTabPager: UIViewControllerRepresentable { loadViewIfNeeded() introductionPage.update(page: introduction) commentsPage.update(page: comments) + guard !scrollView.isTracking, !scrollView.isDragging, !scrollView.isDecelerating else { return } setSelectedIndex(selectedIndex, animated: animated) } From a250d46a8aa81a9d89083c636ff9aec02fbf84ff Mon Sep 17 00:00:00 2001 From: pgs666 <74764545+pgs666@users.noreply.github.com> Date: Sat, 6 Jun 2026 01:33:09 +0800 Subject: [PATCH 094/216] Fix comment pager top and keyboard glass --- iosApp/VideoDetailView.swift | 59 +++++++++++++++++++++++++++++++++++- 1 file changed, 58 insertions(+), 1 deletion(-) diff --git a/iosApp/VideoDetailView.swift b/iosApp/VideoDetailView.swift index 38342f6d..858dca8e 100644 --- a/iosApp/VideoDetailView.swift +++ b/iosApp/VideoDetailView.swift @@ -528,6 +528,16 @@ private struct CommentComposerBar: View { .frame(minHeight: Self.compactHeight) .frame(maxWidth: .infinity) .commentComposerBarChrome() + .onValueChange(of: isFieldFocused) { isFocused in + guard isFocused else { return } + CommentKeyboardTransparency.applySoon() + } + .onReceive(NotificationCenter.default.publisher(for: UIResponder.keyboardWillShowNotification)) { _ in + CommentKeyboardTransparency.applySoon() + } + .onReceive(NotificationCenter.default.publisher(for: UIResponder.keyboardDidShowNotification)) { _ in + CommentKeyboardTransparency.applySoon() + } .onDisappear { isFieldFocused = false } @@ -630,6 +640,47 @@ private extension View { } } +private enum CommentKeyboardTransparency { + @MainActor + static func applySoon() { + guard #available(iOS 26.0, *) else { return } + apply() + Task { @MainActor in + try? await Task.sleep(nanoseconds: 80_000_000) + apply() + } + } + + @MainActor + private static func apply() { + UIApplication.shared.connectedScenes + .compactMap { $0 as? UIWindowScene } + .flatMap { $0.windows } + .filter { isKeyboardWindow($0) } + .forEach { window in + clearKeyboardChrome(in: window, depth: 0) + } + } + + private static func isKeyboardWindow(_ window: UIWindow) -> Bool { + let className = NSStringFromClass(type(of: window)) + return className.contains("UIRemoteKeyboardWindow") + || className.contains("UITextEffectsWindow") + } + + private static func clearKeyboardChrome(in view: UIView, depth: Int) { + let className = NSStringFromClass(type(of: view)) + if depth <= 2 + || className.contains("InputSet") + || className.contains("Keyboard") + || className.contains("TextEffects") { + view.backgroundColor = .clear + view.isOpaque = false + } + view.subviews.forEach { clearKeyboardChrome(in: $0, depth: depth + 1) } + } +} + private enum VideoPlayerCollapseModel { static func nextCollapseOffset( currentCollapseOffset: CGFloat, @@ -1097,11 +1148,17 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle host.rootView = page.content() } - let contentTopInset = min(max(page.contentTopInset, 0), max(page.collapseDistance, 0)) + let contentTopInset = effectiveContentTopInset(for: page) hostTopConstraint.constant = contentTopInset bottomPaddingHeightConstraint.constant = page.contentBottomPadding collapseSpacerHeightConstraint.constant = max(page.collapseDistance - contentTopInset + 1, 1) } + + private func effectiveContentTopInset(for page: VideoDetailTabPage) -> CGFloat { + let desiredInset = min(max(page.contentTopInset, 0), max(page.collapseDistance, 0)) + let currentOffset = max(scrollView.verticalContentOffsetExcludingBounce, 0) + return min(desiredInset, currentOffset) + } } private final class VerticalScrollView: UIScrollView { From 7bb598363b0c30cc9ec80dd1d2e4e64e36f8ede7 Mon Sep 17 00:00:00 2001 From: pgs666 <74764545+pgs666@users.noreply.github.com> Date: Sat, 6 Jun 2026 01:58:46 +0800 Subject: [PATCH 095/216] Fix comment tab activation offset --- iosApp/VideoDetailView.swift | 44 ++++++++++++++++++++++++++++++++++-- 1 file changed, 42 insertions(+), 2 deletions(-) diff --git a/iosApp/VideoDetailView.swift b/iosApp/VideoDetailView.swift index 858dca8e..abe91e15 100644 --- a/iosApp/VideoDetailView.swift +++ b/iosApp/VideoDetailView.swift @@ -754,6 +754,10 @@ private struct VideoDetailPagerState: Equatable { Self.clamp(tabOffsets[tab] ?? 0, upperBound: playerScrollAway) } + func storedOffset(for tab: VideoPageTab) -> CGFloat? { + tabOffsets[tab] + } + mutating func setPlayerPlaying(_ isPlaying: Bool) { isPlayerPlaying = isPlaying if isPlaying { @@ -771,9 +775,9 @@ private struct VideoDetailPagerState: Equatable { } mutating func updateTabOffset(_ tab: VideoPageTab, offset: CGFloat, collapseDistance: CGFloat) { - let previousActiveOffset = tabOffsets[selectedTab] - tabOffsets[tab] = offset guard tab == selectedTab else { return } + let previousActiveOffset = tabOffsets[tab] + tabOffsets[tab] = offset updateCollapseOffset( activeTabOffset: offset, previousActiveTabOffset: previousActiveOffset, @@ -858,6 +862,8 @@ private struct VideoDetailTabPage { let tab: VideoPageTab let contentBottomPadding: CGFloat let contentTopInset: CGFloat + let storedContentOffset: CGFloat? + let isSelected: Bool let collapseDistance: CGFloat let contentUpdateRevision: Int let onOffsetChange: (VideoPageTab, CGFloat) -> Void @@ -869,6 +875,8 @@ private struct VideoDetailTabPage { tab: VideoPageTab, contentBottomPadding: CGFloat, contentTopInset: CGFloat, + storedContentOffset: CGFloat?, + isSelected: Bool, collapseDistance: CGFloat, contentUpdateRevision: Int, onOffsetChange: @escaping (VideoPageTab, CGFloat) -> Void, @@ -879,6 +887,8 @@ private struct VideoDetailTabPage { self.tab = tab self.contentBottomPadding = contentBottomPadding self.contentTopInset = contentTopInset + self.storedContentOffset = storedContentOffset + self.isSelected = isSelected self.collapseDistance = collapseDistance self.contentUpdateRevision = contentUpdateRevision self.onOffsetChange = onOffsetChange @@ -954,6 +964,8 @@ private struct VideoDetailPagerContainer: Vi tab: tab, contentBottomPadding: contentBottomPadding, contentTopInset: state.contentTopInset(for: tab, playerScrollAway: playerScrollAway), + storedContentOffset: state.storedOffset(for: tab), + isSelected: state.selectedTab == tab, collapseDistance: collapseDistance, contentUpdateRevision: contentUpdateRevision, onOffsetChange: { tab, offset in @@ -1046,6 +1058,8 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle private var bottomPaddingHeightConstraint: NSLayoutConstraint! private var collapseSpacerHeightConstraint: NSLayoutConstraint! private var contentUpdateRevision: Int? + private var wasSelected = false + private var topAlignmentGeneration = 0 init(tab: VideoPageTab) { coordinator = VideoDetailVerticalScrollPageCoordinator( @@ -1143,6 +1157,10 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle coordinator.onOffsetChange = page.onOffsetChange coordinator.onInteractionBegan = page.onInteractionBegan coordinator.onTopPullDelta = page.onTopPullDelta + let shouldAlignTopOnActivation = page.isSelected + && !wasSelected + && (page.storedContentOffset ?? 0) <= 0.5 + wasSelected = page.isSelected if contentUpdateRevision != page.contentUpdateRevision { contentUpdateRevision = page.contentUpdateRevision host.rootView = page.content() @@ -1152,6 +1170,9 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle hostTopConstraint.constant = contentTopInset bottomPaddingHeightConstraint.constant = page.contentBottomPadding collapseSpacerHeightConstraint.constant = max(page.collapseDistance - contentTopInset + 1, 1) + if shouldAlignTopOnActivation { + alignToTopAfterActivation() + } } private func effectiveContentTopInset(for page: VideoDetailTabPage) -> CGFloat { @@ -1159,6 +1180,25 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle let currentOffset = max(scrollView.verticalContentOffsetExcludingBounce, 0) return min(desiredInset, currentOffset) } + + private func alignToTopAfterActivation() { + topAlignmentGeneration &+= 1 + let generation = topAlignmentGeneration + alignToTopIfIdle() + DispatchQueue.main.async { [weak self] in + guard let self, self.topAlignmentGeneration == generation else { return } + self.alignToTopIfIdle() + } + } + + private func alignToTopIfIdle() { + guard !scrollView.isTracking, !scrollView.isDragging, !scrollView.isDecelerating else { return } + view.setNeedsLayout() + view.layoutIfNeeded() + let topOffsetY = -scrollView.adjustedContentInset.top + guard abs(scrollView.contentOffset.y - topOffsetY) > 0.5 else { return } + scrollView.setContentOffset(CGPoint(x: scrollView.contentOffset.x, y: topOffsetY), animated: false) + } } private final class VerticalScrollView: UIScrollView { From 323d80727c42df9438722eba85b13cb89ad98c73 Mon Sep 17 00:00:00 2001 From: pgs666 <74764545+pgs666@users.noreply.github.com> Date: Sat, 6 Jun 2026 02:22:14 +0800 Subject: [PATCH 096/216] Stabilize comment pager scrolling --- iosApp/CommentView.swift | 58 ++++----------------------------- iosApp/VideoDetailView.swift | 63 +++++++++++++++++++++++++----------- 2 files changed, 51 insertions(+), 70 deletions(-) diff --git a/iosApp/CommentView.swift b/iosApp/CommentView.swift index 13b8584f..eb86101a 100644 --- a/iosApp/CommentView.swift +++ b/iosApp/CommentView.swift @@ -8,9 +8,6 @@ struct CommentView: View { @State private var replyText = "" @State private var reportTarget: CommentRow? @State private var repliesTarget: CommentRow? - @State private var renderedCommentCount = 24 - @State private var commentRenderSignature = "" - @State private var commentRenderTask: Task? init( viewModel: CommentViewModel, @@ -37,8 +34,6 @@ struct CommentView: View { } .onDisappear { onOverlayActivityChanged(false) - commentRenderTask?.cancel() - commentRenderTask = nil } .alert("提示", isPresented: actionMessageBinding) { Button("好", role: .cancel) { @@ -176,7 +171,7 @@ struct CommentView: View { .padding(.vertical, 60) } else { VStack(alignment: .leading, spacing: 12) { - ForEach(Array(comments.prefix(renderedCommentCount))) { comment in + ForEach(comments) { comment in CommentRowView( comment: comment, isRunningLike: viewModel.runningActionIDs.contains("like-\(comment.id)"), @@ -199,25 +194,13 @@ struct CommentView: View { ) } - if renderedCommentCount < comments.count { - ProgressView() - .frame(maxWidth: .infinity) - .padding(.vertical, 12) - } else { - Text("comment.no_more") - .font(.footnote) - .foregroundStyle(.secondary) - .frame(maxWidth: .infinity) - .padding(.vertical, 12) - } + Text("comment.no_more") + .font(.footnote) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity) + .padding(.vertical, 12) } .id(viewModel.sortMode.id) - .onAppear { - scheduleCommentRendering(for: comments) - } - .onValueChange(of: commentRenderSignature(for: comments)) { _ in - scheduleCommentRendering(for: comments) - } } } } @@ -249,35 +232,6 @@ struct CommentView: View { } } - private func commentRenderSignature(for comments: [CommentRow]) -> String { - [ - viewModel.sortMode.id, - "\(comments.count)", - comments.first?.id ?? "", - comments.last?.id ?? "" - ].joined(separator: "|") - } - - private func scheduleCommentRendering(for comments: [CommentRow]) { - let signature = commentRenderSignature(for: comments) - guard commentRenderSignature != signature else { return } - commentRenderSignature = signature - commentRenderTask?.cancel() - renderedCommentCount = min(24, comments.count) - guard renderedCommentCount < comments.count else { - commentRenderTask = nil - return - } - - commentRenderTask = Task { @MainActor in - while !Task.isCancelled, renderedCommentCount < comments.count { - try? await Task.sleep(nanoseconds: 45_000_000) - guard !Task.isCancelled else { return } - renderedCommentCount = min(renderedCommentCount + 16, comments.count) - } - commentRenderTask = nil - } - } } private struct CommentRowView: View { diff --git a/iosApp/VideoDetailView.swift b/iosApp/VideoDetailView.swift index abe91e15..5b94f0ae 100644 --- a/iosApp/VideoDetailView.swift +++ b/iosApp/VideoDetailView.swift @@ -9,6 +9,7 @@ struct VideoDetailView: View { private let tabletLeftMinimumWidth: CGFloat = 620 private let tabletSidebarMinimumWidth: CGFloat = 360 private let playerContinuationStripHeight: CGFloat = 56 + private let commentComposerBottomSlack: CGFloat = 24 @StateObject private var viewModel: VideoDetailViewModel @StateObject private var commentViewModel: CommentViewModel @State private var pagerState = VideoDetailPagerState() @@ -465,7 +466,9 @@ struct VideoDetailView: View { let containerBottomInset = currentWindowBottomSafeAreaInset() let isKeyboardSafeAreaActive = safeAreaBottom > containerBottomInset + 1 let composerHeight = max(commentComposerHeight, CommentComposerBar.compactHeight) - return composerHeight + (isKeyboardSafeAreaActive ? 0 : containerBottomInset) + return composerHeight + + (isKeyboardSafeAreaActive ? 0 : containerBottomInset) + + commentComposerBottomSlack } private func introductionContentClearance() -> CGFloat { @@ -750,8 +753,8 @@ private struct VideoDetailPagerState: Equatable { ) } - func contentTopInset(for tab: VideoPageTab, playerScrollAway: CGFloat) -> CGFloat { - Self.clamp(tabOffsets[tab] ?? 0, upperBound: playerScrollAway) + func contentTopInset(for _: VideoPageTab, playerScrollAway: CGFloat) -> CGFloat { + max(playerScrollAway, 0) } func storedOffset(for tab: VideoPageTab) -> CGFloat? { @@ -1001,6 +1004,7 @@ private final class VideoDetailVerticalScrollPageCoordinator: NSObject, UIScroll var onOffsetChange: (VideoPageTab, CGFloat) -> Void var onInteractionBegan: (VideoPageTab) -> Void var onTopPullDelta: (VideoPageTab, CGFloat) -> Void + var visualTopContentOffsetY: CGFloat = 0 private var lastReportedOffset: CGFloat? private var lastTopPullTranslationY: CGFloat = 0 @@ -1030,7 +1034,7 @@ private final class VideoDetailVerticalScrollPageCoordinator: NSObject, UIScroll onInteractionBegan(tab) lastTopPullTranslationY = 0 case .changed: - guard scrollView.verticalContentOffsetExcludingBounce <= 0.5 else { + guard scrollView.verticalContentOffsetExcludingBounce <= visualTopContentOffsetY + 0.5 else { lastTopPullTranslationY = panGestureRecognizer.translation(in: scrollView).y return } @@ -1157,48 +1161,71 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle coordinator.onOffsetChange = page.onOffsetChange coordinator.onInteractionBegan = page.onInteractionBegan coordinator.onTopPullDelta = page.onTopPullDelta - let shouldAlignTopOnActivation = page.isSelected - && !wasSelected - && (page.storedContentOffset ?? 0) <= 0.5 + let wasPageSelected = wasSelected wasSelected = page.isSelected if contentUpdateRevision != page.contentUpdateRevision { contentUpdateRevision = page.contentUpdateRevision host.rootView = page.content() } - let contentTopInset = effectiveContentTopInset(for: page) + let previousVisualTopContentOffsetY = coordinator.visualTopContentOffsetY + let contentTopInset = resolvedContentTopInset(for: page) + coordinator.visualTopContentOffsetY = contentTopInset + let shouldAlignTopOnActivation = page.isSelected + && !wasPageSelected + && (page.storedContentOffset ?? 0) <= 0.5 hostTopConstraint.constant = contentTopInset bottomPaddingHeightConstraint.constant = page.contentBottomPadding collapseSpacerHeightConstraint.constant = max(page.collapseDistance - contentTopInset + 1, 1) if shouldAlignTopOnActivation { - alignToTopAfterActivation() + alignToTopAfterActivation(targetOffsetY: contentTopInset) + } else if page.isSelected { + preserveVisualTopIfNeeded( + previousOffsetY: previousVisualTopContentOffsetY, + targetOffsetY: contentTopInset + ) } } - private func effectiveContentTopInset(for page: VideoDetailTabPage) -> CGFloat { - let desiredInset = min(max(page.contentTopInset, 0), max(page.collapseDistance, 0)) - let currentOffset = max(scrollView.verticalContentOffsetExcludingBounce, 0) - return min(desiredInset, currentOffset) + private func resolvedContentTopInset(for page: VideoDetailTabPage) -> CGFloat { + min(max(page.contentTopInset, 0), max(page.collapseDistance, 0)) } - private func alignToTopAfterActivation() { + private func alignToTopAfterActivation(targetOffsetY: CGFloat) { topAlignmentGeneration &+= 1 let generation = topAlignmentGeneration - alignToTopIfIdle() + alignToTopIfIdle(targetOffsetY: targetOffsetY) DispatchQueue.main.async { [weak self] in guard let self, self.topAlignmentGeneration == generation else { return } - self.alignToTopIfIdle() + self.alignToTopIfIdle(targetOffsetY: targetOffsetY) } } - private func alignToTopIfIdle() { + private func alignToTopIfIdle(targetOffsetY: CGFloat) { guard !scrollView.isTracking, !scrollView.isDragging, !scrollView.isDecelerating else { return } view.setNeedsLayout() view.layoutIfNeeded() - let topOffsetY = -scrollView.adjustedContentInset.top + let topOffsetY = clampedContentOffsetY(targetOffsetY) + guard abs(scrollView.contentOffset.y - topOffsetY) > 0.5 else { return } + scrollView.setContentOffset(CGPoint(x: scrollView.contentOffset.x, y: topOffsetY), animated: false) + } + + private func preserveVisualTopIfNeeded(previousOffsetY: CGFloat, targetOffsetY: CGFloat) { + guard abs(previousOffsetY - targetOffsetY) > 0.5 else { return } + guard abs(scrollView.verticalContentOffsetExcludingBounce - previousOffsetY) <= 1 else { return } + view.setNeedsLayout() + view.layoutIfNeeded() + let topOffsetY = clampedContentOffsetY(targetOffsetY) guard abs(scrollView.contentOffset.y - topOffsetY) > 0.5 else { return } scrollView.setContentOffset(CGPoint(x: scrollView.contentOffset.x, y: topOffsetY), animated: false) } + + private func clampedContentOffsetY(_ offsetY: CGFloat) -> CGFloat { + let inset = scrollView.adjustedContentInset + let minOffsetY = -inset.top + let maxOffsetY = max(minOffsetY, scrollView.contentSize.height - scrollView.bounds.height + inset.bottom) + return min(max(offsetY, minOffsetY), maxOffsetY) + } } private final class VerticalScrollView: UIScrollView { From 0137624bf9dc279226633b776d104c7a6857a7f8 Mon Sep 17 00:00:00 2001 From: pgs666 <74764545+pgs666@users.noreply.github.com> Date: Sat, 6 Jun 2026 02:48:06 +0800 Subject: [PATCH 097/216] Stabilize comment scroll alignment --- iosApp/VideoDetailView.swift | 101 +++++++++++++++++++++-------------- 1 file changed, 61 insertions(+), 40 deletions(-) diff --git a/iosApp/VideoDetailView.swift b/iosApp/VideoDetailView.swift index 5b94f0ae..3d32a4be 100644 --- a/iosApp/VideoDetailView.swift +++ b/iosApp/VideoDetailView.swift @@ -9,7 +9,7 @@ struct VideoDetailView: View { private let tabletLeftMinimumWidth: CGFloat = 620 private let tabletSidebarMinimumWidth: CGFloat = 360 private let playerContinuationStripHeight: CGFloat = 56 - private let commentComposerBottomSlack: CGFloat = 24 + private let commentComposerBottomSlack: CGFloat = 96 @StateObject private var viewModel: VideoDetailViewModel @StateObject private var commentViewModel: CommentViewModel @State private var pagerState = VideoDetailPagerState() @@ -607,39 +607,30 @@ private extension View { @ViewBuilder func commentComposerFieldChrome() -> some View { - if #available(iOS 26.0, *) { - contentShape(Capsule()) - .glassEffect(.regular.interactive(), in: Capsule()) - } else { - background(Color(.secondarySystemBackground), in: Capsule()) - .overlay { - Capsule() - .strokeBorder(Color.secondary.opacity(0.16), lineWidth: 0.5) - } - } + background(Color(.secondarySystemBackground), in: Capsule()) + .overlay { + Capsule() + .strokeBorder(Color.secondary.opacity(0.16), lineWidth: 0.5) + } + .contentShape(Capsule()) } - @ViewBuilder - func commentComposerSendButtonChrome(isEnabled: Bool) -> some View { - if #available(iOS 26.0, *) { - buttonStyle(.plain) - .contentShape(Circle()) - .glassEffect(.regular.interactive(isEnabled), in: Circle()) - } else { - buttonStyle(.plain) - } + func commentComposerSendButtonChrome(isEnabled _: Bool) -> some View { + buttonStyle(.plain) + .background(Color(.secondarySystemBackground), in: Circle()) + .overlay { + Circle() + .strokeBorder(Color.secondary.opacity(0.14), lineWidth: 0.5) + } + .contentShape(Circle()) } - @ViewBuilder func commentComposerBarChrome() -> some View { - if #available(iOS 26.0, *) { - background(.clear) - } else { - background(.bar) - .overlay(alignment: .top) { - Divider() - } - } + background(Color(.systemGroupedBackground).opacity(0.96)) + .overlay(alignment: .top) { + Divider() + .opacity(0.35) + } } } @@ -1004,6 +995,7 @@ private final class VideoDetailVerticalScrollPageCoordinator: NSObject, UIScroll var onOffsetChange: (VideoPageTab, CGFloat) -> Void var onInteractionBegan: (VideoPageTab) -> Void var onTopPullDelta: (VideoPageTab, CGFloat) -> Void + var onVerticalInteractionBegan: () -> Void = {} var visualTopContentOffsetY: CGFloat = 0 private var lastReportedOffset: CGFloat? private var lastTopPullTranslationY: CGFloat = 0 @@ -1031,6 +1023,7 @@ private final class VideoDetailVerticalScrollPageCoordinator: NSObject, UIScroll guard let scrollView = panGestureRecognizer.view as? UIScrollView else { return } switch panGestureRecognizer.state { case .began: + onVerticalInteractionBegan() onInteractionBegan(tab) lastTopPullTranslationY = 0 case .changed: @@ -1064,6 +1057,7 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle private var contentUpdateRevision: Int? private var wasSelected = false private var topAlignmentGeneration = 0 + private var pendingTopAlignmentTargetOffsetY: CGFloat? init(tab: VideoPageTab) { coordinator = VideoDetailVerticalScrollPageCoordinator( @@ -1086,8 +1080,8 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle scrollView.translatesAutoresizingMaskIntoConstraints = false scrollView.backgroundColor = .clear - scrollView.bounces = false - scrollView.alwaysBounceVertical = false + scrollView.bounces = true + scrollView.alwaysBounceVertical = true scrollView.alwaysBounceHorizontal = false scrollView.showsHorizontalScrollIndicator = false scrollView.showsVerticalScrollIndicator = false @@ -1155,14 +1149,25 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle ]) } + override func viewDidLayoutSubviews() { + super.viewDidLayoutSubviews() + resolvePendingTopAlignmentIfPossible() + } + func update(page: VideoDetailTabPage) { loadViewIfNeeded() coordinator.tab = page.tab coordinator.onOffsetChange = page.onOffsetChange coordinator.onInteractionBegan = page.onInteractionBegan coordinator.onTopPullDelta = page.onTopPullDelta + coordinator.onVerticalInteractionBegan = { [weak self] in + self?.cancelPendingTopAlignment() + } let wasPageSelected = wasSelected wasSelected = page.isSelected + if !page.isSelected { + pendingTopAlignmentTargetOffsetY = nil + } if contentUpdateRevision != page.contentUpdateRevision { contentUpdateRevision = page.contentUpdateRevision host.rootView = page.content() @@ -1178,7 +1183,10 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle bottomPaddingHeightConstraint.constant = page.contentBottomPadding collapseSpacerHeightConstraint.constant = max(page.collapseDistance - contentTopInset + 1, 1) if shouldAlignTopOnActivation { - alignToTopAfterActivation(targetOffsetY: contentTopInset) + requestTopAlignment(targetOffsetY: contentTopInset) + } else if pendingTopAlignmentTargetOffsetY != nil { + pendingTopAlignmentTargetOffsetY = contentTopInset + resolvePendingTopAlignmentSoon() } else if page.isSelected { preserveVisualTopIfNeeded( previousOffsetY: previousVisualTopContentOffsetY, @@ -1191,23 +1199,36 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle min(max(page.contentTopInset, 0), max(page.collapseDistance, 0)) } - private func alignToTopAfterActivation(targetOffsetY: CGFloat) { + private func requestTopAlignment(targetOffsetY: CGFloat) { topAlignmentGeneration &+= 1 + pendingTopAlignmentTargetOffsetY = targetOffsetY + resolvePendingTopAlignmentSoon() + } + + private func resolvePendingTopAlignmentSoon() { let generation = topAlignmentGeneration - alignToTopIfIdle(targetOffsetY: targetOffsetY) + resolvePendingTopAlignmentIfPossible() DispatchQueue.main.async { [weak self] in guard let self, self.topAlignmentGeneration == generation else { return } - self.alignToTopIfIdle(targetOffsetY: targetOffsetY) + self.resolvePendingTopAlignmentIfPossible() } } - private func alignToTopIfIdle(targetOffsetY: CGFloat) { + private func resolvePendingTopAlignmentIfPossible() { + guard let targetOffsetY = pendingTopAlignmentTargetOffsetY else { return } guard !scrollView.isTracking, !scrollView.isDragging, !scrollView.isDecelerating else { return } - view.setNeedsLayout() - view.layoutIfNeeded() let topOffsetY = clampedContentOffsetY(targetOffsetY) - guard abs(scrollView.contentOffset.y - topOffsetY) > 0.5 else { return } - scrollView.setContentOffset(CGPoint(x: scrollView.contentOffset.x, y: topOffsetY), animated: false) + guard abs(topOffsetY - targetOffsetY) <= 0.5 else { return } + if abs(scrollView.contentOffset.y - topOffsetY) > 0.5 { + scrollView.setContentOffset(CGPoint(x: scrollView.contentOffset.x, y: topOffsetY), animated: false) + } + if abs(scrollView.verticalContentOffsetExcludingBounce - targetOffsetY) <= 0.5 { + pendingTopAlignmentTargetOffsetY = nil + } + } + + private func cancelPendingTopAlignment() { + pendingTopAlignmentTargetOffsetY = nil } private func preserveVisualTopIfNeeded(previousOffsetY: CGFloat, targetOffsetY: CGFloat) { From 943085047b6e0e0bbfe08eb2a6ebf83dd496072f Mon Sep 17 00:00:00 2001 From: pgs666 <74764545+pgs666@users.noreply.github.com> Date: Sat, 6 Jun 2026 03:00:37 +0800 Subject: [PATCH 098/216] Throttle pager state while scrolling --- iosApp/VideoDetailView.swift | 52 +++++++++++++++++++++--------------- 1 file changed, 30 insertions(+), 22 deletions(-) diff --git a/iosApp/VideoDetailView.swift b/iosApp/VideoDetailView.swift index 3d32a4be..74831e37 100644 --- a/iosApp/VideoDetailView.swift +++ b/iosApp/VideoDetailView.swift @@ -770,13 +770,37 @@ private struct VideoDetailPagerState: Equatable { mutating func updateTabOffset(_ tab: VideoPageTab, offset: CGFloat, collapseDistance: CGFloat) { guard tab == selectedTab else { return } + guard !isPlayerPlaying else { + collapseOffset = 0 + return + } + let trackedOffset = Self.clamp(offset, upperBound: collapseDistance) let previousActiveOffset = tabOffsets[tab] - tabOffsets[tab] = offset - updateCollapseOffset( - activeTabOffset: offset, + let previousCollapseOffset = collapseOffset + let nextCollapseOffset = nextCollapseOffset( + activeTabOffset: trackedOffset, previousActiveTabOffset: previousActiveOffset, collapseDistance: collapseDistance ) + guard previousActiveOffset != trackedOffset + || abs(previousCollapseOffset - nextCollapseOffset) > 0.5 else { + return + } + tabOffsets[tab] = trackedOffset + collapseOffset = nextCollapseOffset + } + + private func nextCollapseOffset( + activeTabOffset: CGFloat, + previousActiveTabOffset: CGFloat?, + collapseDistance: CGFloat + ) -> CGFloat { + VideoPlayerCollapseModel.nextCollapseOffset( + currentCollapseOffset: collapseOffset, + activeTabOffset: activeTabOffset, + previousActiveTabOffset: previousActiveTabOffset, + collapseDistance: collapseDistance + ) } mutating func beginInteracting(with tab: VideoPageTab, collapseDistance: CGFloat) { @@ -793,24 +817,6 @@ private struct VideoDetailPagerState: Equatable { collapseOffset = Self.clamp(collapseOffset, upperBound: collapseDistance) } - private mutating func updateCollapseOffset( - activeTabOffset: CGFloat, - previousActiveTabOffset: CGFloat?, - collapseDistance: CGFloat - ) { - guard !isPlayerPlaying else { - collapseOffset = 0 - return - } - - collapseOffset = VideoPlayerCollapseModel.nextCollapseOffset( - currentCollapseOffset: collapseOffset, - activeTabOffset: activeTabOffset, - previousActiveTabOffset: previousActiveTabOffset, - collapseDistance: collapseDistance - ) - } - private static func clamp(_ value: CGFloat, upperBound: CGFloat) -> CGFloat { min(max(value, 0), max(upperBound, 0)) } @@ -985,7 +991,9 @@ private struct VideoDetailPagerContainer: Vi withTransaction(Transaction(animation: nil)) { var nextState = state mutation(&nextState) - state = nextState + if nextState != state { + state = nextState + } } } } From f0c93654e7d53728c884a24f531cbdca30e351cc Mon Sep 17 00:00:00 2001 From: pgs666 <74764545+pgs666@users.noreply.github.com> Date: Sun, 7 Jun 2026 01:13:32 +0800 Subject: [PATCH 099/216] Fix comment pager scroll alignment --- iosApp/VideoDetailView.swift | 127 +++++++++++++++++++++++++++-------- 1 file changed, 98 insertions(+), 29 deletions(-) diff --git a/iosApp/VideoDetailView.swift b/iosApp/VideoDetailView.swift index 74831e37..aac156f0 100644 --- a/iosApp/VideoDetailView.swift +++ b/iosApp/VideoDetailView.swift @@ -639,9 +639,11 @@ private enum CommentKeyboardTransparency { static func applySoon() { guard #available(iOS 26.0, *) else { return } apply() - Task { @MainActor in - try? await Task.sleep(nanoseconds: 80_000_000) - apply() + for delay in [40_000_000, 120_000_000, 260_000_000] { + Task { @MainActor in + try? await Task.sleep(nanoseconds: UInt64(delay)) + apply() + } } } @@ -667,10 +669,17 @@ private enum CommentKeyboardTransparency { if depth <= 2 || className.contains("InputSet") || className.contains("Keyboard") - || className.contains("TextEffects") { + || className.contains("TextEffects") + || className.contains("VisualEffect") + || className.contains("Backdrop") + || className.contains("Material") { view.backgroundColor = .clear view.isOpaque = false } + if let visualEffectView = view as? UIVisualEffectView { + visualEffectView.effect = nil + visualEffectView.contentView.backgroundColor = .clear + } view.subviews.forEach { clearKeyboardChrome(in: $0, depth: depth + 1) } } } @@ -1055,16 +1064,16 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle private let coordinator: VideoDetailVerticalScrollPageCoordinator private let scrollView = VerticalScrollView() private let contentView = UIView() - private let bottomPaddingView = UIView() private let collapseSpacerView = UIView() private let host = UIHostingController(rootView: AnyView(EmptyView())) private var hostTopConstraint: NSLayoutConstraint! private var hostMinimumHeightConstraint: NSLayoutConstraint! - private var bottomPaddingHeightConstraint: NSLayoutConstraint! + private var contentMinimumHeightConstraint: NSLayoutConstraint! private var collapseSpacerHeightConstraint: NSLayoutConstraint! private var contentUpdateRevision: Int? private var wasSelected = false private var topAlignmentGeneration = 0 + private var pendingTopAlignmentRetryCount = 0 private var pendingTopAlignmentTargetOffsetY: CGFloat? init(tab: VideoPageTab) { @@ -1098,7 +1107,8 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle scrollView.keyboardDismissMode = .interactive scrollView.delegate = coordinator scrollView.panGestureRecognizer.addTarget(coordinator, action: #selector(VideoDetailVerticalScrollPageCoordinator.handlePan(_:))) - scrollView.shouldBeginVerticalPan = { panGestureRecognizer, view in + scrollView.shouldBeginVerticalPan = { [weak self] panGestureRecognizer, view in + self?.resolvePendingTopAlignmentIfPossible(allowDuringInteraction: true) let velocity = panGestureRecognizer.velocity(in: view) return abs(velocity.x) <= abs(velocity.y) * 1.05 } @@ -1114,17 +1124,16 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle contentView.addSubview(host.view) host.didMove(toParent: self) - bottomPaddingView.translatesAutoresizingMaskIntoConstraints = false - bottomPaddingView.backgroundColor = .clear - contentView.addSubview(bottomPaddingView) - collapseSpacerView.translatesAutoresizingMaskIntoConstraints = false collapseSpacerView.backgroundColor = .clear contentView.addSubview(collapseSpacerView) hostTopConstraint = host.view.topAnchor.constraint(equalTo: contentView.topAnchor) hostMinimumHeightConstraint = host.view.heightAnchor.constraint(greaterThanOrEqualTo: scrollView.frameLayoutGuide.heightAnchor) - bottomPaddingHeightConstraint = bottomPaddingView.heightAnchor.constraint(equalToConstant: 24) + contentMinimumHeightConstraint = contentView.heightAnchor.constraint( + greaterThanOrEqualTo: scrollView.frameLayoutGuide.heightAnchor, + constant: 1 + ) collapseSpacerHeightConstraint = collapseSpacerView.heightAnchor.constraint(equalToConstant: 1) NSLayoutConstraint.activate([ @@ -1138,20 +1147,16 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle contentView.topAnchor.constraint(equalTo: scrollView.contentLayoutGuide.topAnchor), contentView.bottomAnchor.constraint(equalTo: scrollView.contentLayoutGuide.bottomAnchor), contentView.widthAnchor.constraint(equalTo: scrollView.frameLayoutGuide.widthAnchor), + contentMinimumHeightConstraint, host.view.leadingAnchor.constraint(equalTo: contentView.leadingAnchor), host.view.trailingAnchor.constraint(equalTo: contentView.trailingAnchor), hostTopConstraint, hostMinimumHeightConstraint, - bottomPaddingView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor), - bottomPaddingView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor), - bottomPaddingView.topAnchor.constraint(equalTo: host.view.bottomAnchor), - bottomPaddingHeightConstraint, - collapseSpacerView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor), collapseSpacerView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor), - collapseSpacerView.topAnchor.constraint(equalTo: bottomPaddingView.bottomAnchor), + collapseSpacerView.topAnchor.constraint(equalTo: host.view.bottomAnchor), collapseSpacerView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor), collapseSpacerHeightConstraint ]) @@ -1169,12 +1174,12 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle coordinator.onInteractionBegan = page.onInteractionBegan coordinator.onTopPullDelta = page.onTopPullDelta coordinator.onVerticalInteractionBegan = { [weak self] in - self?.cancelPendingTopAlignment() + self?.resolvePendingTopAlignmentIfPossible(allowDuringInteraction: true) } let wasPageSelected = wasSelected wasSelected = page.isSelected if !page.isSelected { - pendingTopAlignmentTargetOffsetY = nil + cancelPendingTopAlignment() } if contentUpdateRevision != page.contentUpdateRevision { contentUpdateRevision = page.contentUpdateRevision @@ -1184,18 +1189,22 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle let previousVisualTopContentOffsetY = coordinator.visualTopContentOffsetY let contentTopInset = resolvedContentTopInset(for: page) coordinator.visualTopContentOffsetY = contentTopInset + let storedContentOffset = page.storedContentOffset ?? 0 let shouldAlignTopOnActivation = page.isSelected && !wasPageSelected - && (page.storedContentOffset ?? 0) <= 0.5 + && storedContentOffset <= contentTopInset + 0.5 hostTopConstraint.constant = contentTopInset - bottomPaddingHeightConstraint.constant = page.contentBottomPadding - collapseSpacerHeightConstraint.constant = max(page.collapseDistance - contentTopInset + 1, 1) + applyBottomContentInset(page.contentBottomPadding) + let collapseSpacerHeight = max(page.collapseDistance - contentTopInset + 1, 1) + collapseSpacerHeightConstraint.constant = collapseSpacerHeight + contentMinimumHeightConstraint.constant = contentTopInset + + collapseSpacerHeight if shouldAlignTopOnActivation { requestTopAlignment(targetOffsetY: contentTopInset) } else if pendingTopAlignmentTargetOffsetY != nil { pendingTopAlignmentTargetOffsetY = contentTopInset resolvePendingTopAlignmentSoon() - } else if page.isSelected { + } else { preserveVisualTopIfNeeded( previousOffsetY: previousVisualTopContentOffsetY, targetOffsetY: contentTopInset @@ -1207,8 +1216,16 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle min(max(page.contentTopInset, 0), max(page.collapseDistance, 0)) } + private func applyBottomContentInset(_ bottomInset: CGFloat) { + let resolvedBottomInset = max(bottomInset, 0) + guard abs(scrollView.contentInset.bottom - resolvedBottomInset) > 0.5 else { return } + scrollView.contentInset.bottom = resolvedBottomInset + scrollView.verticalScrollIndicatorInsets.bottom = resolvedBottomInset + } + private func requestTopAlignment(targetOffsetY: CGFloat) { topAlignmentGeneration &+= 1 + pendingTopAlignmentRetryCount = 0 pendingTopAlignmentTargetOffsetY = targetOffsetY resolvePendingTopAlignmentSoon() } @@ -1216,29 +1233,54 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle private func resolvePendingTopAlignmentSoon() { let generation = topAlignmentGeneration resolvePendingTopAlignmentIfPossible() - DispatchQueue.main.async { [weak self] in + schedulePendingTopAlignmentRetry(generation: generation) + } + + private func schedulePendingTopAlignmentRetry(generation: Int) { + guard pendingTopAlignmentTargetOffsetY != nil, + pendingTopAlignmentRetryCount < 8 else { + return + } + pendingTopAlignmentRetryCount += 1 + DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(16)) { [weak self] in guard let self, self.topAlignmentGeneration == generation else { return } self.resolvePendingTopAlignmentIfPossible() + self.schedulePendingTopAlignmentRetry(generation: generation) } } - private func resolvePendingTopAlignmentIfPossible() { + private func resolvePendingTopAlignmentIfPossible(allowDuringInteraction: Bool = false) { guard let targetOffsetY = pendingTopAlignmentTargetOffsetY else { return } - guard !scrollView.isTracking, !scrollView.isDragging, !scrollView.isDecelerating else { return } + if !allowDuringInteraction { + guard !scrollView.isTracking, !scrollView.isDragging, !scrollView.isDecelerating else { return } + } + view.setNeedsLayout() + view.layoutIfNeeded() + scrollView.layoutIfNeeded() + contentView.layoutIfNeeded() let topOffsetY = clampedContentOffsetY(targetOffsetY) guard abs(topOffsetY - targetOffsetY) <= 0.5 else { return } if abs(scrollView.contentOffset.y - topOffsetY) > 0.5 { scrollView.setContentOffset(CGPoint(x: scrollView.contentOffset.x, y: topOffsetY), animated: false) } if abs(scrollView.verticalContentOffsetExcludingBounce - targetOffsetY) <= 0.5 { - pendingTopAlignmentTargetOffsetY = nil + cancelPendingTopAlignment() } } private func cancelPendingTopAlignment() { + pendingTopAlignmentRetryCount = 0 pendingTopAlignmentTargetOffsetY = nil } + func settleAfterHorizontalActivation() { + loadViewIfNeeded() + let targetOffsetY = coordinator.visualTopContentOffsetY + guard scrollView.verticalContentOffsetExcludingBounce <= targetOffsetY + 0.5 else { return } + requestTopAlignment(targetOffsetY: targetOffsetY) + resolvePendingTopAlignmentIfPossible(allowDuringInteraction: true) + } + private func preserveVisualTopIfNeeded(previousOffsetY: CGFloat, targetOffsetY: CGFloat) { guard abs(previousOffsetY - targetOffsetY) > 0.5 else { return } guard abs(scrollView.verticalContentOffsetExcludingBounce - previousOffsetY) <= 1 else { return } @@ -1313,6 +1355,7 @@ private struct VideoDetailTabPager: UIViewControllerRepresentable { final class Coordinator: NSObject, UIScrollViewDelegate { var selectedTab: Binding + var onSelectedIndexSettled: ((Int) -> Void)? private var lastProgrammaticIndex: Int? init(selectedTab: Binding) { @@ -1364,6 +1407,9 @@ private struct VideoDetailTabPager: UIViewControllerRepresentable { if selectedTab.wrappedValue != tab { selectedTab.wrappedValue = tab } + DispatchQueue.main.async { [weak self] in + self?.onSelectedIndexSettled?(index) + } } } @@ -1374,6 +1420,7 @@ private struct VideoDetailTabPager: UIViewControllerRepresentable { private let introductionPage = VideoDetailVerticalScrollPageViewController(tab: .introduction) private let commentsPage = VideoDetailVerticalScrollPageViewController(tab: .comments) private var selectedIndex = 0 + private var lastSettledSelectedIndex: Int? private var pendingSelectedIndex: Int? private var lastLaidOutWidth: CGFloat = 0 @@ -1402,6 +1449,9 @@ private struct VideoDetailTabPager: UIViewControllerRepresentable { scrollView.isDirectionalLockEnabled = true scrollView.contentInsetAdjustmentBehavior = .never scrollView.delegate = coordinator + coordinator.onSelectedIndexSettled = { [weak self] index in + self?.settlePageAfterHorizontalSelection(index) + } scrollView.shouldBeginPagingPan = { [weak self] panGestureRecognizer, view in self?.coordinator.shouldBeginHorizontalPagingPan( panGestureRecognizer: panGestureRecognizer, @@ -1464,10 +1514,12 @@ private struct VideoDetailTabPager: UIViewControllerRepresentable { animated: Bool ) { loadViewIfNeeded() + self.selectedIndex = min(max(selectedIndex, 0), VideoPageTab.allCases.count - 1) introductionPage.update(page: introduction) commentsPage.update(page: comments) + settleSelectedPageIfNeeded(self.selectedIndex) guard !scrollView.isTracking, !scrollView.isDragging, !scrollView.isDecelerating else { return } - setSelectedIndex(selectedIndex, animated: animated) + setSelectedIndex(self.selectedIndex, animated: animated) } private func addPage(_ page: UIViewController) { @@ -1489,6 +1541,23 @@ private struct VideoDetailTabPager: UIViewControllerRepresentable { guard abs(scrollView.contentOffset.x - targetOffset.x) > 0.5 else { return } scrollView.setContentOffset(targetOffset, animated: animated) } + + private func settleSelectedPageIfNeeded(_ index: Int) { + let clampedIndex = min(max(index, 0), VideoPageTab.allCases.count - 1) + guard lastSettledSelectedIndex != clampedIndex else { return } + lastSettledSelectedIndex = clampedIndex + settlePageAfterHorizontalSelection(clampedIndex) + } + + private func settlePageAfterHorizontalSelection(_ index: Int) { + lastSettledSelectedIndex = min(max(index, 0), VideoPageTab.allCases.count - 1) + switch VideoPageTab.page(at: index) { + case .introduction: + introductionPage.settleAfterHorizontalActivation() + case .comments: + commentsPage.settleAfterHorizontalActivation() + } + } } final class PagingScrollView: UIScrollView { From bd8afc39419d5b1a2e9e5aa0006e2dcb32d32bc1 Mon Sep 17 00:00:00 2001 From: pgs666 <74764545+pgs666@users.noreply.github.com> Date: Sun, 7 Jun 2026 01:38:33 +0800 Subject: [PATCH 100/216] Avoid recursive comment pager layout --- iosApp/VideoDetailView.swift | 24 +++++++++++++++++++----- 1 file changed, 19 insertions(+), 5 deletions(-) diff --git a/iosApp/VideoDetailView.swift b/iosApp/VideoDetailView.swift index aac156f0..5fb96445 100644 --- a/iosApp/VideoDetailView.swift +++ b/iosApp/VideoDetailView.swift @@ -1075,6 +1075,7 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle private var topAlignmentGeneration = 0 private var pendingTopAlignmentRetryCount = 0 private var pendingTopAlignmentTargetOffsetY: CGFloat? + private var isInLayoutPass = false init(tab: VideoPageTab) { coordinator = VideoDetailVerticalScrollPageCoordinator( @@ -1164,7 +1165,12 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle override func viewDidLayoutSubviews() { super.viewDidLayoutSubviews() - resolvePendingTopAlignmentIfPossible() + guard pendingTopAlignmentTargetOffsetY != nil else { return } + let generation = topAlignmentGeneration + DispatchQueue.main.async { [weak self] in + guard let self, self.topAlignmentGeneration == generation else { return } + self.resolvePendingTopAlignmentIfPossible() + } } func update(page: VideoDetailTabPage) { @@ -1251,11 +1257,19 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle private func resolvePendingTopAlignmentIfPossible(allowDuringInteraction: Bool = false) { guard let targetOffsetY = pendingTopAlignmentTargetOffsetY else { return } + guard !isInLayoutPass else { + let generation = topAlignmentGeneration + DispatchQueue.main.async { [weak self] in + guard let self, self.topAlignmentGeneration == generation else { return } + self.resolvePendingTopAlignmentIfPossible(allowDuringInteraction: allowDuringInteraction) + } + return + } if !allowDuringInteraction { guard !scrollView.isTracking, !scrollView.isDragging, !scrollView.isDecelerating else { return } } - view.setNeedsLayout() - view.layoutIfNeeded() + isInLayoutPass = true + defer { isInLayoutPass = false } scrollView.layoutIfNeeded() contentView.layoutIfNeeded() let topOffsetY = clampedContentOffsetY(targetOffsetY) @@ -1284,8 +1298,8 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle private func preserveVisualTopIfNeeded(previousOffsetY: CGFloat, targetOffsetY: CGFloat) { guard abs(previousOffsetY - targetOffsetY) > 0.5 else { return } guard abs(scrollView.verticalContentOffsetExcludingBounce - previousOffsetY) <= 1 else { return } - view.setNeedsLayout() - view.layoutIfNeeded() + scrollView.layoutIfNeeded() + contentView.layoutIfNeeded() let topOffsetY = clampedContentOffsetY(targetOffsetY) guard abs(scrollView.contentOffset.y - topOffsetY) > 0.5 else { return } scrollView.setContentOffset(CGPoint(x: scrollView.contentOffset.x, y: topOffsetY), animated: false) From cd45356b9f0d6f9ea324bc684801ff8c31856ea6 Mon Sep 17 00:00:00 2001 From: pgs666 <74764545+pgs666@users.noreply.github.com> Date: Sun, 7 Jun 2026 01:48:00 +0800 Subject: [PATCH 101/216] Use inset based pager top alignment --- iosApp/VideoDetailView.swift | 38 ++++++++++++++++++++++++------------ 1 file changed, 25 insertions(+), 13 deletions(-) diff --git a/iosApp/VideoDetailView.swift b/iosApp/VideoDetailView.swift index 5fb96445..10437e87 100644 --- a/iosApp/VideoDetailView.swift +++ b/iosApp/VideoDetailView.swift @@ -1199,12 +1199,12 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle let shouldAlignTopOnActivation = page.isSelected && !wasPageSelected && storedContentOffset <= contentTopInset + 0.5 - hostTopConstraint.constant = contentTopInset + hostTopConstraint.constant = 0 + applyTopContentInset(contentTopInset) applyBottomContentInset(page.contentBottomPadding) let collapseSpacerHeight = max(page.collapseDistance - contentTopInset + 1, 1) collapseSpacerHeightConstraint.constant = collapseSpacerHeight - contentMinimumHeightConstraint.constant = contentTopInset - + collapseSpacerHeight + contentMinimumHeightConstraint.constant = collapseSpacerHeight if shouldAlignTopOnActivation { requestTopAlignment(targetOffsetY: contentTopInset) } else if pendingTopAlignmentTargetOffsetY != nil { @@ -1222,6 +1222,13 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle min(max(page.contentTopInset, 0), max(page.collapseDistance, 0)) } + private func applyTopContentInset(_ topInset: CGFloat) { + let resolvedTopInset = max(topInset, 0) + guard abs(scrollView.contentInset.top - resolvedTopInset) > 0.5 else { return } + scrollView.contentInset.top = resolvedTopInset + scrollView.verticalScrollIndicatorInsets.top = resolvedTopInset + } + private func applyBottomContentInset(_ bottomInset: CGFloat) { let resolvedBottomInset = max(bottomInset, 0) guard abs(scrollView.contentInset.bottom - resolvedBottomInset) > 0.5 else { return } @@ -1272,10 +1279,10 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle defer { isInLayoutPass = false } scrollView.layoutIfNeeded() contentView.layoutIfNeeded() - let topOffsetY = clampedContentOffsetY(targetOffsetY) - guard abs(topOffsetY - targetOffsetY) <= 0.5 else { return } - if abs(scrollView.contentOffset.y - topOffsetY) > 0.5 { - scrollView.setContentOffset(CGPoint(x: scrollView.contentOffset.x, y: topOffsetY), animated: false) + let rawTopOffsetY = clampedRawContentOffsetY(forNormalizedOffsetY: targetOffsetY) + guard abs(normalizedContentOffsetY(forRawOffsetY: rawTopOffsetY) - targetOffsetY) <= 0.5 else { return } + if abs(scrollView.contentOffset.y - rawTopOffsetY) > 0.5 { + scrollView.setContentOffset(CGPoint(x: scrollView.contentOffset.x, y: rawTopOffsetY), animated: false) } if abs(scrollView.verticalContentOffsetExcludingBounce - targetOffsetY) <= 0.5 { cancelPendingTopAlignment() @@ -1290,7 +1297,7 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle func settleAfterHorizontalActivation() { loadViewIfNeeded() let targetOffsetY = coordinator.visualTopContentOffsetY - guard scrollView.verticalContentOffsetExcludingBounce <= targetOffsetY + 0.5 else { return } + guard scrollView.verticalContentOffsetExcludingBounce <= targetOffsetY + 8 else { return } requestTopAlignment(targetOffsetY: targetOffsetY) resolvePendingTopAlignmentIfPossible(allowDuringInteraction: true) } @@ -1300,16 +1307,21 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle guard abs(scrollView.verticalContentOffsetExcludingBounce - previousOffsetY) <= 1 else { return } scrollView.layoutIfNeeded() contentView.layoutIfNeeded() - let topOffsetY = clampedContentOffsetY(targetOffsetY) - guard abs(scrollView.contentOffset.y - topOffsetY) > 0.5 else { return } - scrollView.setContentOffset(CGPoint(x: scrollView.contentOffset.x, y: topOffsetY), animated: false) + let rawTopOffsetY = clampedRawContentOffsetY(forNormalizedOffsetY: targetOffsetY) + guard abs(scrollView.contentOffset.y - rawTopOffsetY) > 0.5 else { return } + scrollView.setContentOffset(CGPoint(x: scrollView.contentOffset.x, y: rawTopOffsetY), animated: false) } - private func clampedContentOffsetY(_ offsetY: CGFloat) -> CGFloat { + private func clampedRawContentOffsetY(forNormalizedOffsetY offsetY: CGFloat) -> CGFloat { let inset = scrollView.adjustedContentInset let minOffsetY = -inset.top let maxOffsetY = max(minOffsetY, scrollView.contentSize.height - scrollView.bounds.height + inset.bottom) - return min(max(offsetY, minOffsetY), maxOffsetY) + let rawOffsetY = offsetY - inset.top + return min(max(rawOffsetY, minOffsetY), maxOffsetY) + } + + private func normalizedContentOffsetY(forRawOffsetY rawOffsetY: CGFloat) -> CGFloat { + rawOffsetY + scrollView.adjustedContentInset.top } } From b26f6e93422f364e367ede2e93a51d39f308094f Mon Sep 17 00:00:00 2001 From: pgs666 <74764545+pgs666@users.noreply.github.com> Date: Sun, 7 Jun 2026 07:57:33 +0800 Subject: [PATCH 102/216] Refactor comment pager smooth scroll model --- iosApp/VideoDetailView.swift | 96 +++++++++++++++--------------------- 1 file changed, 39 insertions(+), 57 deletions(-) diff --git a/iosApp/VideoDetailView.swift b/iosApp/VideoDetailView.swift index 10437e87..8354dc61 100644 --- a/iosApp/VideoDetailView.swift +++ b/iosApp/VideoDetailView.swift @@ -871,7 +871,6 @@ private struct VideoDetailTabPage { let tab: VideoPageTab let contentBottomPadding: CGFloat let contentTopInset: CGFloat - let storedContentOffset: CGFloat? let isSelected: Bool let collapseDistance: CGFloat let contentUpdateRevision: Int @@ -884,7 +883,6 @@ private struct VideoDetailTabPage { tab: VideoPageTab, contentBottomPadding: CGFloat, contentTopInset: CGFloat, - storedContentOffset: CGFloat?, isSelected: Bool, collapseDistance: CGFloat, contentUpdateRevision: Int, @@ -896,7 +894,6 @@ private struct VideoDetailTabPage { self.tab = tab self.contentBottomPadding = contentBottomPadding self.contentTopInset = contentTopInset - self.storedContentOffset = storedContentOffset self.isSelected = isSelected self.collapseDistance = collapseDistance self.contentUpdateRevision = contentUpdateRevision @@ -973,7 +970,6 @@ private struct VideoDetailPagerContainer: Vi tab: tab, contentBottomPadding: contentBottomPadding, contentTopInset: state.contentTopInset(for: tab, playerScrollAway: playerScrollAway), - storedContentOffset: state.storedOffset(for: tab), isSelected: state.selectedTab == tab, collapseDistance: collapseDistance, contentUpdateRevision: contentUpdateRevision, @@ -1066,16 +1062,12 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle private let contentView = UIView() private let collapseSpacerView = UIView() private let host = UIHostingController(rootView: AnyView(EmptyView())) - private var hostTopConstraint: NSLayoutConstraint! private var hostMinimumHeightConstraint: NSLayoutConstraint! private var contentMinimumHeightConstraint: NSLayoutConstraint! private var collapseSpacerHeightConstraint: NSLayoutConstraint! private var contentUpdateRevision: Int? - private var wasSelected = false private var topAlignmentGeneration = 0 - private var pendingTopAlignmentRetryCount = 0 private var pendingTopAlignmentTargetOffsetY: CGFloat? - private var isInLayoutPass = false init(tab: VideoPageTab) { coordinator = VideoDetailVerticalScrollPageCoordinator( @@ -1108,6 +1100,9 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle scrollView.keyboardDismissMode = .interactive scrollView.delegate = coordinator scrollView.panGestureRecognizer.addTarget(coordinator, action: #selector(VideoDetailVerticalScrollPageCoordinator.handlePan(_:))) + scrollView.onGeometryChange = { [weak self] in + self?.resolvePendingTopAlignmentIfPossible() + } scrollView.shouldBeginVerticalPan = { [weak self] panGestureRecognizer, view in self?.resolvePendingTopAlignmentIfPossible(allowDuringInteraction: true) let velocity = panGestureRecognizer.velocity(in: view) @@ -1129,7 +1124,6 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle collapseSpacerView.backgroundColor = .clear contentView.addSubview(collapseSpacerView) - hostTopConstraint = host.view.topAnchor.constraint(equalTo: contentView.topAnchor) hostMinimumHeightConstraint = host.view.heightAnchor.constraint(greaterThanOrEqualTo: scrollView.frameLayoutGuide.heightAnchor) contentMinimumHeightConstraint = contentView.heightAnchor.constraint( greaterThanOrEqualTo: scrollView.frameLayoutGuide.heightAnchor, @@ -1152,7 +1146,7 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle host.view.leadingAnchor.constraint(equalTo: contentView.leadingAnchor), host.view.trailingAnchor.constraint(equalTo: contentView.trailingAnchor), - hostTopConstraint, + host.view.topAnchor.constraint(equalTo: contentView.topAnchor), hostMinimumHeightConstraint, collapseSpacerView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor), @@ -1165,12 +1159,7 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle override func viewDidLayoutSubviews() { super.viewDidLayoutSubviews() - guard pendingTopAlignmentTargetOffsetY != nil else { return } - let generation = topAlignmentGeneration - DispatchQueue.main.async { [weak self] in - guard let self, self.topAlignmentGeneration == generation else { return } - self.resolvePendingTopAlignmentIfPossible() - } + resolvePendingTopAlignmentIfPossible() } func update(page: VideoDetailTabPage) { @@ -1182,8 +1171,6 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle coordinator.onVerticalInteractionBegan = { [weak self] in self?.resolvePendingTopAlignmentIfPossible(allowDuringInteraction: true) } - let wasPageSelected = wasSelected - wasSelected = page.isSelected if !page.isSelected { cancelPendingTopAlignment() } @@ -1195,19 +1182,12 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle let previousVisualTopContentOffsetY = coordinator.visualTopContentOffsetY let contentTopInset = resolvedContentTopInset(for: page) coordinator.visualTopContentOffsetY = contentTopInset - let storedContentOffset = page.storedContentOffset ?? 0 - let shouldAlignTopOnActivation = page.isSelected - && !wasPageSelected - && storedContentOffset <= contentTopInset + 0.5 - hostTopConstraint.constant = 0 applyTopContentInset(contentTopInset) applyBottomContentInset(page.contentBottomPadding) let collapseSpacerHeight = max(page.collapseDistance - contentTopInset + 1, 1) collapseSpacerHeightConstraint.constant = collapseSpacerHeight contentMinimumHeightConstraint.constant = collapseSpacerHeight - if shouldAlignTopOnActivation { - requestTopAlignment(targetOffsetY: contentTopInset) - } else if pendingTopAlignmentTargetOffsetY != nil { + if pendingTopAlignmentTargetOffsetY != nil { pendingTopAlignmentTargetOffsetY = contentTopInset resolvePendingTopAlignmentSoon() } else { @@ -1238,7 +1218,6 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle private func requestTopAlignment(targetOffsetY: CGFloat) { topAlignmentGeneration &+= 1 - pendingTopAlignmentRetryCount = 0 pendingTopAlignmentTargetOffsetY = targetOffsetY resolvePendingTopAlignmentSoon() } @@ -1246,39 +1225,17 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle private func resolvePendingTopAlignmentSoon() { let generation = topAlignmentGeneration resolvePendingTopAlignmentIfPossible() - schedulePendingTopAlignmentRetry(generation: generation) - } - - private func schedulePendingTopAlignmentRetry(generation: Int) { - guard pendingTopAlignmentTargetOffsetY != nil, - pendingTopAlignmentRetryCount < 8 else { - return - } - pendingTopAlignmentRetryCount += 1 - DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(16)) { [weak self] in + DispatchQueue.main.async { [weak self] in guard let self, self.topAlignmentGeneration == generation else { return } self.resolvePendingTopAlignmentIfPossible() - self.schedulePendingTopAlignmentRetry(generation: generation) } } private func resolvePendingTopAlignmentIfPossible(allowDuringInteraction: Bool = false) { guard let targetOffsetY = pendingTopAlignmentTargetOffsetY else { return } - guard !isInLayoutPass else { - let generation = topAlignmentGeneration - DispatchQueue.main.async { [weak self] in - guard let self, self.topAlignmentGeneration == generation else { return } - self.resolvePendingTopAlignmentIfPossible(allowDuringInteraction: allowDuringInteraction) - } - return - } if !allowDuringInteraction { guard !scrollView.isTracking, !scrollView.isDragging, !scrollView.isDecelerating else { return } } - isInLayoutPass = true - defer { isInLayoutPass = false } - scrollView.layoutIfNeeded() - contentView.layoutIfNeeded() let rawTopOffsetY = clampedRawContentOffsetY(forNormalizedOffsetY: targetOffsetY) guard abs(normalizedContentOffsetY(forRawOffsetY: rawTopOffsetY) - targetOffsetY) <= 0.5 else { return } if abs(scrollView.contentOffset.y - rawTopOffsetY) > 0.5 { @@ -1290,7 +1247,6 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle } private func cancelPendingTopAlignment() { - pendingTopAlignmentRetryCount = 0 pendingTopAlignmentTargetOffsetY = nil } @@ -1305,8 +1261,6 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle private func preserveVisualTopIfNeeded(previousOffsetY: CGFloat, targetOffsetY: CGFloat) { guard abs(previousOffsetY - targetOffsetY) > 0.5 else { return } guard abs(scrollView.verticalContentOffsetExcludingBounce - previousOffsetY) <= 1 else { return } - scrollView.layoutIfNeeded() - contentView.layoutIfNeeded() let rawTopOffsetY = clampedRawContentOffsetY(forNormalizedOffsetY: targetOffsetY) guard abs(scrollView.contentOffset.y - rawTopOffsetY) > 0.5 else { return } scrollView.setContentOffset(CGPoint(x: scrollView.contentOffset.x, y: rawTopOffsetY), animated: false) @@ -1327,6 +1281,23 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle private final class VerticalScrollView: UIScrollView { var shouldBeginVerticalPan: ((UIPanGestureRecognizer, UIView) -> Bool)? + var onGeometryChange: (() -> Void)? + + override var bounds: CGRect { + didSet { + guard abs(bounds.height - oldValue.height) > 0.5 + || abs(bounds.width - oldValue.width) > 0.5 else { return } + onGeometryChange?() + } + } + + override var contentSize: CGSize { + didSet { + guard abs(contentSize.height - oldValue.height) > 0.5 + || abs(contentSize.width - oldValue.width) > 0.5 else { return } + onGeometryChange?() + } + } override func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool { if let panGestureRecognizer = gestureRecognizer as? UIPanGestureRecognizer, @@ -1383,6 +1354,7 @@ private struct VideoDetailTabPager: UIViewControllerRepresentable { var selectedTab: Binding var onSelectedIndexSettled: ((Int) -> Void)? private var lastProgrammaticIndex: Int? + private var pendingSettledIndex: Int? init(selectedTab: Binding) { self.selectedTab = selectedTab @@ -1431,11 +1403,18 @@ private struct VideoDetailTabPager: UIViewControllerRepresentable { let index = Int(round(scrollView.contentOffset.x / width)) let tab = VideoPageTab.page(at: index) if selectedTab.wrappedValue != tab { + pendingSettledIndex = index selectedTab.wrappedValue = tab + } else { + onSelectedIndexSettled?(index) } - DispatchQueue.main.async { [weak self] in - self?.onSelectedIndexSettled?(index) - } + } + + func consumePendingSettledIndex(for selectedIndex: Int) -> Bool { + guard pendingSettledIndex == selectedIndex else { return false } + pendingSettledIndex = nil + onSelectedIndexSettled?(selectedIndex) + return true } } @@ -1543,9 +1522,12 @@ private struct VideoDetailTabPager: UIViewControllerRepresentable { self.selectedIndex = min(max(selectedIndex, 0), VideoPageTab.allCases.count - 1) introductionPage.update(page: introduction) commentsPage.update(page: comments) - settleSelectedPageIfNeeded(self.selectedIndex) + let consumedSettledIndex = coordinator.consumePendingSettledIndex(for: self.selectedIndex) guard !scrollView.isTracking, !scrollView.isDragging, !scrollView.isDecelerating else { return } setSelectedIndex(self.selectedIndex, animated: animated) + if !animated && !consumedSettledIndex { + settleSelectedPageIfNeeded(self.selectedIndex) + } } private func addPage(_ page: UIViewController) { From f3d1ca2d2c7df2c014aec9e705e3dacfc8461087 Mon Sep 17 00:00:00 2001 From: pgs666 <74764545+pgs666@users.noreply.github.com> Date: Sun, 7 Jun 2026 08:17:07 +0800 Subject: [PATCH 103/216] Refactor video pager to stable header layout --- iosApp/VideoDetailView.swift | 147 +++++++++++++++++++++++++---------- 1 file changed, 105 insertions(+), 42 deletions(-) diff --git a/iosApp/VideoDetailView.swift b/iosApp/VideoDetailView.swift index 8354dc61..a09df408 100644 --- a/iosApp/VideoDetailView.swift +++ b/iosApp/VideoDetailView.swift @@ -9,6 +9,7 @@ struct VideoDetailView: View { private let tabletLeftMinimumWidth: CGFloat = 620 private let tabletSidebarMinimumWidth: CGFloat = 360 private let playerContinuationStripHeight: CGFloat = 56 + private let pagerPinHeaderHeight: CGFloat = 48 private let commentComposerBottomSlack: CGFloat = 96 @StateObject private var viewModel: VideoDetailViewModel @StateObject private var commentViewModel: CommentViewModel @@ -247,7 +248,6 @@ struct VideoDetailView: View { let leftWidth = leftPanelWidth(for: proxy.size) let inlineHeight = inlinePlayerHeight(panelWidth: leftWidth) let pagerMetrics = pagerState.layoutMetrics( - inlinePlayerHeight: inlineHeight, containerHeight: proxy.size.height, rawCollapseDistance: playerCollapseDistance(panelWidth: leftWidth) ) @@ -258,12 +258,6 @@ struct VideoDetailView: View { panelWidth: leftWidth, parentHeight: proxy.size.height ) - playerArea(snapshot: snapshot) - .frame( - width: leftWidth, - height: currentPlayerHeight - ) - // Keep the tab pager mounted during fullscreen. Rebuilding // the SwiftUI-hosted intro/comment pages while the player // animates back to inline is a visible hitch on iPadOS 16. @@ -273,17 +267,26 @@ struct VideoDetailView: View { snapshot: snapshot, showsRelated: !isWide, collapseDistance: pagerMetrics.collapseDistance, + headerHeight: inlineHeight, + pinHeaderHeight: pagerPinHeaderHeight, + pinnedVisibleHeight: playerContinuationStripHeight + pagerPinHeaderHeight, playerScrollAway: pagerMetrics.playerScrollAway, introductionContentClearance: introductionContentClearance(), composerContentClearance: commentComposerContentClearance( safeAreaBottom: proxy.safeAreaInsets.bottom ) ) - .frame(height: pagerMetrics.belowPlayerHeight) - .offset(y: pagerMetrics.belowPlayerTopOffset) + .frame(height: proxy.size.height) .opacity(isPlayerFullscreen ? 0 : 1) .allowsHitTesting(!isPlayerFullscreen) + playerArea(snapshot: snapshot) + .frame( + width: leftWidth, + height: currentPlayerHeight + ) + .offset(y: isPlayerFullscreen ? 0 : -pagerMetrics.playerScrollAway) + if !isPlayerFullscreen, pagerMetrics.continuationProgress > 0 { continuePlayingStrip(snapshot: snapshot, progress: pagerMetrics.continuationProgress) @@ -399,6 +402,9 @@ struct VideoDetailView: View { snapshot: VideoDetailScreenSnapshot, showsRelated: Bool, collapseDistance: CGFloat, + headerHeight: CGFloat, + pinHeaderHeight: CGFloat, + pinnedVisibleHeight: CGFloat, playerScrollAway: CGFloat, introductionContentClearance: CGFloat, composerContentClearance: CGFloat @@ -408,6 +414,9 @@ struct VideoDetailView: View { return VideoDetailPagerContainer( state: $pagerState, collapseDistance: collapseDistance, + headerHeight: headerHeight, + pinHeaderHeight: pinHeaderHeight, + pinnedVisibleHeight: pinnedVisibleHeight, playerScrollAway: playerScrollAway, introductionContentBottomPadding: introductionContentClearance, commentsContentBottomPadding: composerContentClearance, @@ -504,6 +513,23 @@ struct VideoDetailView: View { var commentsHasher = Hasher() commentsHasher.combine(ObjectIdentifier(commentViewModel)) + commentsHasher.combine(commentViewModel.sortMode.id) + commentsHasher.combine(commentViewModel.sortedComments.count) + for comment in commentViewModel.sortedComments { + commentsHasher.combine(comment.id) + } + switch commentViewModel.state { + case .idle: + commentsHasher.combine("idle") + case .loading: + commentsHasher.combine("loading") + case .failed(let message): + commentsHasher.combine("failed") + commentsHasher.combine(message) + case .loaded(let snapshot): + commentsHasher.combine("loaded") + commentsHasher.combine(snapshot.comments.count) + } return VideoDetailTabContentRevision( introduction: introductionHasher.finalize(), @@ -714,8 +740,6 @@ private enum VideoPlayerCollapseModel { private struct VideoDetailPagerLayoutMetrics { let collapseDistance: CGFloat let playerScrollAway: CGFloat - let belowPlayerTopOffset: CGFloat - let belowPlayerHeight: CGFloat let continuationProgress: CGFloat } @@ -726,13 +750,11 @@ private struct VideoDetailPagerState: Equatable { var isPlayerPlaying: Bool = false func layoutMetrics( - inlinePlayerHeight: CGFloat, - containerHeight: CGFloat, + containerHeight _: CGFloat, rawCollapseDistance: CGFloat ) -> VideoDetailPagerLayoutMetrics { let collapseDistance = isPlayerPlaying ? 0 : max(rawCollapseDistance, 0) let playerScrollAway = isPlayerPlaying ? 0 : Self.clamp(collapseOffset, upperBound: collapseDistance) - let belowPlayerTopOffset = max(inlinePlayerHeight - playerScrollAway, 0) let fadeDistance: CGFloat = 72 let continuationProgress: CGFloat if rawCollapseDistance > 1 { @@ -747,20 +769,10 @@ private struct VideoDetailPagerState: Equatable { return VideoDetailPagerLayoutMetrics( collapseDistance: collapseDistance, playerScrollAway: playerScrollAway, - belowPlayerTopOffset: belowPlayerTopOffset, - belowPlayerHeight: max(0, containerHeight - belowPlayerTopOffset), continuationProgress: continuationProgress ) } - func contentTopInset(for _: VideoPageTab, playerScrollAway: CGFloat) -> CGFloat { - max(playerScrollAway, 0) - } - - func storedOffset(for tab: VideoPageTab) -> CGFloat? { - tabOffsets[tab] - } - mutating func setPlayerPlaying(_ isPlaying: Bool) { isPlayerPlaying = isPlaying if isPlaying { @@ -871,8 +883,10 @@ private struct VideoDetailTabPage { let tab: VideoPageTab let contentBottomPadding: CGFloat let contentTopInset: CGFloat + let visualTopOffset: CGFloat let isSelected: Bool let collapseDistance: CGFloat + let pinnedVisibleHeight: CGFloat let contentUpdateRevision: Int let onOffsetChange: (VideoPageTab, CGFloat) -> Void let onInteractionBegan: (VideoPageTab) -> Void @@ -883,8 +897,10 @@ private struct VideoDetailTabPage { tab: VideoPageTab, contentBottomPadding: CGFloat, contentTopInset: CGFloat, + visualTopOffset: CGFloat, isSelected: Bool, collapseDistance: CGFloat, + pinnedVisibleHeight: CGFloat, contentUpdateRevision: Int, onOffsetChange: @escaping (VideoPageTab, CGFloat) -> Void, onInteractionBegan: @escaping (VideoPageTab) -> Void, @@ -894,8 +910,10 @@ private struct VideoDetailTabPage { self.tab = tab self.contentBottomPadding = contentBottomPadding self.contentTopInset = contentTopInset + self.visualTopOffset = visualTopOffset self.isSelected = isSelected self.collapseDistance = collapseDistance + self.pinnedVisibleHeight = pinnedVisibleHeight self.contentUpdateRevision = contentUpdateRevision self.onOffsetChange = onOffsetChange self.onInteractionBegan = onInteractionBegan @@ -907,6 +925,9 @@ private struct VideoDetailTabPage { private struct VideoDetailPagerContainer: View { @Binding var state: VideoDetailPagerState let collapseDistance: CGFloat + let headerHeight: CGFloat + let pinHeaderHeight: CGFloat + let pinnedVisibleHeight: CGFloat let playerScrollAway: CGFloat let introductionContentBottomPadding: CGFloat let commentsContentBottomPadding: CGFloat @@ -925,17 +946,7 @@ private struct VideoDetailPagerContainer: Vi } var body: some View { - VStack(spacing: 0) { - Picker("Content", selection: selectedTabBinding) { - ForEach(VideoPageTab.allCases) { tab in - Text(tab.title).tag(tab) - } - } - .pickerStyle(.segmented) - .padding(.horizontal, 16) - .padding(.vertical, 8) - .background(.background) - + ZStack(alignment: .top) { VideoDetailTabPager( selectedTab: selectedTabBinding, introduction: tabPage( @@ -952,6 +963,19 @@ private struct VideoDetailPagerContainer: Vi ) ) .frame(maxHeight: .infinity) + + Picker("Content", selection: selectedTabBinding) { + ForEach(VideoPageTab.allCases) { tab in + Text(tab.title).tag(tab) + } + } + .pickerStyle(.segmented) + .padding(.horizontal, 16) + .frame(height: pinHeaderHeight) + .frame(maxWidth: .infinity) + .background(.background) + .offset(y: max(headerHeight - playerScrollAway, pinnedVisibleHeight - pinHeaderHeight)) + .zIndex(1) } .frame(maxHeight: .infinity) .background(Color(.systemGroupedBackground)) @@ -969,9 +993,11 @@ private struct VideoDetailPagerContainer: Vi VideoDetailTabPage( tab: tab, contentBottomPadding: contentBottomPadding, - contentTopInset: state.contentTopInset(for: tab, playerScrollAway: playerScrollAway), + contentTopInset: headerHeight + pinHeaderHeight, + visualTopOffset: playerScrollAway, isSelected: state.selectedTab == tab, collapseDistance: collapseDistance, + pinnedVisibleHeight: pinnedVisibleHeight, contentUpdateRevision: contentUpdateRevision, onOffsetChange: { tab, offset in mutateState { @@ -1066,6 +1092,7 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle private var contentMinimumHeightConstraint: NSLayoutConstraint! private var collapseSpacerHeightConstraint: NSLayoutConstraint! private var contentUpdateRevision: Int? + private var lastAppliedPage: VideoDetailTabPage? private var topAlignmentGeneration = 0 private var pendingTopAlignmentTargetOffsetY: CGFloat? @@ -1101,6 +1128,7 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle scrollView.delegate = coordinator scrollView.panGestureRecognizer.addTarget(coordinator, action: #selector(VideoDetailVerticalScrollPageCoordinator.handlePan(_:))) scrollView.onGeometryChange = { [weak self] in + self?.applyCurrentPageGeometryRules() self?.resolvePendingTopAlignmentIfPossible() } scrollView.shouldBeginVerticalPan = { [weak self] panGestureRecognizer, view in @@ -1159,6 +1187,7 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle override func viewDidLayoutSubviews() { super.viewDidLayoutSubviews() + applyCurrentPageGeometryRules() resolvePendingTopAlignmentIfPossible() } @@ -1177,29 +1206,63 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle if contentUpdateRevision != page.contentUpdateRevision { contentUpdateRevision = page.contentUpdateRevision host.rootView = page.content() + if page.isSelected { + requestTopAlignment(targetOffsetY: page.visualTopOffset) + } } let previousVisualTopContentOffsetY = coordinator.visualTopContentOffsetY let contentTopInset = resolvedContentTopInset(for: page) - coordinator.visualTopContentOffsetY = contentTopInset + let visualTopOffset = resolvedVisualTopOffset(for: page) + lastAppliedPage = page + coordinator.visualTopContentOffsetY = visualTopOffset applyTopContentInset(contentTopInset) applyBottomContentInset(page.contentBottomPadding) - let collapseSpacerHeight = max(page.collapseDistance - contentTopInset + 1, 1) + let collapseSpacerHeight = max(page.collapseDistance - visualTopOffset + 1, 1) + let minimumContentHeight = minimumContentHeight(for: page) collapseSpacerHeightConstraint.constant = collapseSpacerHeight - contentMinimumHeightConstraint.constant = collapseSpacerHeight + contentMinimumHeightConstraint.constant = minimumContentHeight if pendingTopAlignmentTargetOffsetY != nil { - pendingTopAlignmentTargetOffsetY = contentTopInset + pendingTopAlignmentTargetOffsetY = visualTopOffset resolvePendingTopAlignmentSoon() } else { preserveVisualTopIfNeeded( previousOffsetY: previousVisualTopContentOffsetY, - targetOffsetY: contentTopInset + targetOffsetY: visualTopOffset ) } } private func resolvedContentTopInset(for page: VideoDetailTabPage) -> CGFloat { - min(max(page.contentTopInset, 0), max(page.collapseDistance, 0)) + max(page.contentTopInset, 0) + } + + private func resolvedVisualTopOffset(for page: VideoDetailTabPage) -> CGFloat { + min(max(page.visualTopOffset, 0), max(page.collapseDistance, 0)) + } + + private func minimumContentHeight(for page: VideoDetailTabPage) -> CGFloat { + max( + scrollView.bounds.height - max(page.pinnedVisibleHeight, 0), + max(page.collapseDistance, 0) + 1, + 1 + ) + } + + private func applyCurrentPageGeometryRules() { + guard let page = lastAppliedPage else { return } + let minimumContentHeight = minimumContentHeight(for: page) + if abs(contentMinimumHeightConstraint.constant - minimumContentHeight) > 0.5 { + contentMinimumHeightConstraint.constant = minimumContentHeight + } + guard page.isSelected else { return } + let visualTopOffset = resolvedVisualTopOffset(for: page) + if pendingTopAlignmentTargetOffsetY != nil { + pendingTopAlignmentTargetOffsetY = visualTopOffset + return + } + guard scrollView.verticalContentOffsetExcludingBounce <= visualTopOffset + 8 else { return } + requestTopAlignment(targetOffsetY: visualTopOffset) } private func applyTopContentInset(_ topInset: CGFloat) { From 7322684877f75f082642c78daa3ec9294130e090 Mon Sep 17 00:00:00 2001 From: pgs666 <74764545+pgs666@users.noreply.github.com> Date: Sun, 7 Jun 2026 08:26:24 +0800 Subject: [PATCH 104/216] Sync pager header offsets across tabs --- iosApp/VideoDetailView.swift | 62 +++++++++++++++++++++++++++++++++--- 1 file changed, 58 insertions(+), 4 deletions(-) diff --git a/iosApp/VideoDetailView.swift b/iosApp/VideoDetailView.swift index a09df408..2dbd2edf 100644 --- a/iosApp/VideoDetailView.swift +++ b/iosApp/VideoDetailView.swift @@ -1036,6 +1036,7 @@ private final class VideoDetailVerticalScrollPageCoordinator: NSObject, UIScroll var onTopPullDelta: (VideoPageTab, CGFloat) -> Void var onVerticalInteractionBegan: () -> Void = {} var visualTopContentOffsetY: CGFloat = 0 + var isApplyingExternalOffset = false private var lastReportedOffset: CGFloat? private var lastTopPullTranslationY: CGFloat = 0 @@ -1052,12 +1053,17 @@ private final class VideoDetailVerticalScrollPageCoordinator: NSObject, UIScroll } func scrollViewDidScroll(_ scrollView: UIScrollView) { + guard !isApplyingExternalOffset else { return } let offset = scrollView.verticalContentOffsetExcludingBounce guard lastReportedOffset.map({ abs($0 - offset) > 0.5 }) ?? true else { return } lastReportedOffset = offset onOffsetChange(tab, offset) } + func resetReportedOffset(_ offset: CGFloat) { + lastReportedOffset = offset + } + @objc func handlePan(_ panGestureRecognizer: UIPanGestureRecognizer) { guard let scrollView = panGestureRecognizer.view as? UIScrollView else { return } switch panGestureRecognizer.state { @@ -1301,9 +1307,7 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle } let rawTopOffsetY = clampedRawContentOffsetY(forNormalizedOffsetY: targetOffsetY) guard abs(normalizedContentOffsetY(forRawOffsetY: rawTopOffsetY) - targetOffsetY) <= 0.5 else { return } - if abs(scrollView.contentOffset.y - rawTopOffsetY) > 0.5 { - scrollView.setContentOffset(CGPoint(x: scrollView.contentOffset.x, y: rawTopOffsetY), animated: false) - } + setRawContentOffsetYIfNeeded(rawTopOffsetY) if abs(scrollView.verticalContentOffsetExcludingBounce - targetOffsetY) <= 0.5 { cancelPendingTopAlignment() } @@ -1321,12 +1325,40 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle resolvePendingTopAlignmentIfPossible(allowDuringInteraction: true) } + var normalizedContentOffsetY: CGFloat { + loadViewIfNeeded() + return scrollView.verticalContentOffsetExcludingBounce + } + + var visualTopContentOffsetY: CGFloat { + coordinator.visualTopContentOffsetY + } + + func syncHeaderOffsetFromActivePage(_ offsetY: CGFloat) { + loadViewIfNeeded() + guard let page = lastAppliedPage else { return } + cancelPendingTopAlignment() + let targetOffsetY = min(max(offsetY, 0), max(page.collapseDistance, 0)) + setNormalizedContentOffsetY(targetOffsetY) + } + private func preserveVisualTopIfNeeded(previousOffsetY: CGFloat, targetOffsetY: CGFloat) { guard abs(previousOffsetY - targetOffsetY) > 0.5 else { return } guard abs(scrollView.verticalContentOffsetExcludingBounce - previousOffsetY) <= 1 else { return } - let rawTopOffsetY = clampedRawContentOffsetY(forNormalizedOffsetY: targetOffsetY) + setNormalizedContentOffsetY(targetOffsetY) + } + + private func setNormalizedContentOffsetY(_ offsetY: CGFloat) { + let rawTopOffsetY = clampedRawContentOffsetY(forNormalizedOffsetY: offsetY) + setRawContentOffsetYIfNeeded(rawTopOffsetY) + } + + private func setRawContentOffsetYIfNeeded(_ rawTopOffsetY: CGFloat) { guard abs(scrollView.contentOffset.y - rawTopOffsetY) > 0.5 else { return } + coordinator.isApplyingExternalOffset = true + defer { coordinator.isApplyingExternalOffset = false } scrollView.setContentOffset(CGPoint(x: scrollView.contentOffset.x, y: rawTopOffsetY), animated: false) + coordinator.resetReportedOffset(scrollView.verticalContentOffsetExcludingBounce) } private func clampedRawContentOffsetY(forNormalizedOffsetY offsetY: CGFloat) -> CGFloat { @@ -1585,6 +1617,7 @@ private struct VideoDetailTabPager: UIViewControllerRepresentable { self.selectedIndex = min(max(selectedIndex, 0), VideoPageTab.allCases.count - 1) introductionPage.update(page: introduction) commentsPage.update(page: comments) + syncInactivePageHeaderOffset() let consumedSettledIndex = coordinator.consumePendingSettledIndex(for: self.selectedIndex) guard !scrollView.isTracking, !scrollView.isDragging, !scrollView.isDecelerating else { return } setSelectedIndex(self.selectedIndex, animated: animated) @@ -1622,6 +1655,7 @@ private struct VideoDetailTabPager: UIViewControllerRepresentable { private func settlePageAfterHorizontalSelection(_ index: Int) { lastSettledSelectedIndex = min(max(index, 0), VideoPageTab.allCases.count - 1) + syncInactivePageHeaderOffset() switch VideoPageTab.page(at: index) { case .introduction: introductionPage.settleAfterHorizontalActivation() @@ -1629,6 +1663,26 @@ private struct VideoDetailTabPager: UIViewControllerRepresentable { commentsPage.settleAfterHorizontalActivation() } } + + private func syncInactivePageHeaderOffset() { + guard let activePage = verticalPageController(for: VideoPageTab.page(at: selectedIndex)) else { + return + } + let activeOffset = activePage.normalizedContentOffsetY + let syncOffset = min(max(activeOffset, 0), activePage.visualTopContentOffsetY) + for tab in VideoPageTab.allCases where tab.pageIndex != selectedIndex { + verticalPageController(for: tab)?.syncHeaderOffsetFromActivePage(syncOffset) + } + } + + private func verticalPageController(for tab: VideoPageTab) -> VideoDetailVerticalScrollPageViewController? { + switch tab { + case .introduction: + return introductionPage + case .comments: + return commentsPage + } + } } final class PagingScrollView: UIScrollView { From 8b8067f1342597267e22a05626980a31de1a4da6 Mon Sep 17 00:00:00 2001 From: pgs666 <74764545+pgs666@users.noreply.github.com> Date: Sun, 7 Jun 2026 08:34:11 +0800 Subject: [PATCH 105/216] Centralize video pager header geometry --- iosApp/VideoDetailView.swift | 127 ++++++++++++++++++++--------------- 1 file changed, 73 insertions(+), 54 deletions(-) diff --git a/iosApp/VideoDetailView.swift b/iosApp/VideoDetailView.swift index 2dbd2edf..a918fe5b 100644 --- a/iosApp/VideoDetailView.swift +++ b/iosApp/VideoDetailView.swift @@ -879,14 +879,55 @@ private struct VideoDetailTabContentRevision: Equatable { let comments: Int } +private struct VideoDetailSmoothHeaderGeometry: Equatable { + let headerHeight: CGFloat + let pinHeaderHeight: CGFloat + let collapseDistance: CGFloat + let visualTopOffset: CGFloat + let pinnedVisibleHeight: CGFloat + + var contentTopInset: CGFloat { + max(headerHeight, 0) + max(pinHeaderHeight, 0) + } + + var resolvedVisualTopOffset: CGFloat { + min(max(visualTopOffset, 0), max(collapseDistance, 0)) + } + + var collapseSpacerHeight: CGFloat { + max(collapseDistance - resolvedVisualTopOffset + 1, 1) + } + + func minimumContentHeight(in scrollBoundsHeight: CGFloat) -> CGFloat { + max( + scrollBoundsHeight - max(pinnedVisibleHeight, 0), + max(collapseDistance, 0) + 1, + 1 + ) + } + + func rawContentOffsetY(forNormalizedOffsetY offsetY: CGFloat, in scrollView: UIScrollView) -> CGFloat { + let inset = scrollView.adjustedContentInset + let minOffsetY = -inset.top + let maxOffsetY = max(minOffsetY, scrollView.contentSize.height - scrollView.bounds.height + inset.bottom) + let rawOffsetY = offsetY - inset.top + return min(max(rawOffsetY, minOffsetY), maxOffsetY) + } + + func normalizedContentOffsetY(forRawOffsetY rawOffsetY: CGFloat, in scrollView: UIScrollView) -> CGFloat { + rawOffsetY + scrollView.adjustedContentInset.top + } + + func syncOffset(fromActiveOffset activeOffset: CGFloat) -> CGFloat { + min(max(activeOffset, 0), resolvedVisualTopOffset) + } +} + private struct VideoDetailTabPage { let tab: VideoPageTab let contentBottomPadding: CGFloat - let contentTopInset: CGFloat - let visualTopOffset: CGFloat let isSelected: Bool - let collapseDistance: CGFloat - let pinnedVisibleHeight: CGFloat + let headerGeometry: VideoDetailSmoothHeaderGeometry let contentUpdateRevision: Int let onOffsetChange: (VideoPageTab, CGFloat) -> Void let onInteractionBegan: (VideoPageTab) -> Void @@ -896,11 +937,8 @@ private struct VideoDetailTabPage { init( tab: VideoPageTab, contentBottomPadding: CGFloat, - contentTopInset: CGFloat, - visualTopOffset: CGFloat, isSelected: Bool, - collapseDistance: CGFloat, - pinnedVisibleHeight: CGFloat, + headerGeometry: VideoDetailSmoothHeaderGeometry, contentUpdateRevision: Int, onOffsetChange: @escaping (VideoPageTab, CGFloat) -> Void, onInteractionBegan: @escaping (VideoPageTab) -> Void, @@ -909,11 +947,8 @@ private struct VideoDetailTabPage { ) { self.tab = tab self.contentBottomPadding = contentBottomPadding - self.contentTopInset = contentTopInset - self.visualTopOffset = visualTopOffset self.isSelected = isSelected - self.collapseDistance = collapseDistance - self.pinnedVisibleHeight = pinnedVisibleHeight + self.headerGeometry = headerGeometry self.contentUpdateRevision = contentUpdateRevision self.onOffsetChange = onOffsetChange self.onInteractionBegan = onInteractionBegan @@ -993,11 +1028,14 @@ private struct VideoDetailPagerContainer: Vi VideoDetailTabPage( tab: tab, contentBottomPadding: contentBottomPadding, - contentTopInset: headerHeight + pinHeaderHeight, - visualTopOffset: playerScrollAway, isSelected: state.selectedTab == tab, - collapseDistance: collapseDistance, - pinnedVisibleHeight: pinnedVisibleHeight, + headerGeometry: VideoDetailSmoothHeaderGeometry( + headerHeight: headerHeight, + pinHeaderHeight: pinHeaderHeight, + collapseDistance: collapseDistance, + visualTopOffset: playerScrollAway, + pinnedVisibleHeight: pinnedVisibleHeight + ), contentUpdateRevision: contentUpdateRevision, onOffsetChange: { tab, offset in mutateState { @@ -1213,21 +1251,19 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle contentUpdateRevision = page.contentUpdateRevision host.rootView = page.content() if page.isSelected { - requestTopAlignment(targetOffsetY: page.visualTopOffset) + requestTopAlignment(targetOffsetY: page.headerGeometry.resolvedVisualTopOffset) } } let previousVisualTopContentOffsetY = coordinator.visualTopContentOffsetY - let contentTopInset = resolvedContentTopInset(for: page) - let visualTopOffset = resolvedVisualTopOffset(for: page) + let geometry = page.headerGeometry + let visualTopOffset = geometry.resolvedVisualTopOffset lastAppliedPage = page coordinator.visualTopContentOffsetY = visualTopOffset - applyTopContentInset(contentTopInset) + applyTopContentInset(geometry.contentTopInset) applyBottomContentInset(page.contentBottomPadding) - let collapseSpacerHeight = max(page.collapseDistance - visualTopOffset + 1, 1) - let minimumContentHeight = minimumContentHeight(for: page) - collapseSpacerHeightConstraint.constant = collapseSpacerHeight - contentMinimumHeightConstraint.constant = minimumContentHeight + collapseSpacerHeightConstraint.constant = geometry.collapseSpacerHeight + contentMinimumHeightConstraint.constant = geometry.minimumContentHeight(in: scrollView.bounds.height) if pendingTopAlignmentTargetOffsetY != nil { pendingTopAlignmentTargetOffsetY = visualTopOffset resolvePendingTopAlignmentSoon() @@ -1239,30 +1275,15 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle } } - private func resolvedContentTopInset(for page: VideoDetailTabPage) -> CGFloat { - max(page.contentTopInset, 0) - } - - private func resolvedVisualTopOffset(for page: VideoDetailTabPage) -> CGFloat { - min(max(page.visualTopOffset, 0), max(page.collapseDistance, 0)) - } - - private func minimumContentHeight(for page: VideoDetailTabPage) -> CGFloat { - max( - scrollView.bounds.height - max(page.pinnedVisibleHeight, 0), - max(page.collapseDistance, 0) + 1, - 1 - ) - } - private func applyCurrentPageGeometryRules() { guard let page = lastAppliedPage else { return } - let minimumContentHeight = minimumContentHeight(for: page) + let geometry = page.headerGeometry + let minimumContentHeight = geometry.minimumContentHeight(in: scrollView.bounds.height) if abs(contentMinimumHeightConstraint.constant - minimumContentHeight) > 0.5 { contentMinimumHeightConstraint.constant = minimumContentHeight } guard page.isSelected else { return } - let visualTopOffset = resolvedVisualTopOffset(for: page) + let visualTopOffset = geometry.resolvedVisualTopOffset if pendingTopAlignmentTargetOffsetY != nil { pendingTopAlignmentTargetOffsetY = visualTopOffset return @@ -1330,16 +1351,15 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle return scrollView.verticalContentOffsetExcludingBounce } - var visualTopContentOffsetY: CGFloat { - coordinator.visualTopContentOffsetY + func headerSyncOffset(fromActiveOffset activeOffset: CGFloat) -> CGFloat { + lastAppliedPage?.headerGeometry.syncOffset(fromActiveOffset: activeOffset) ?? 0 } func syncHeaderOffsetFromActivePage(_ offsetY: CGFloat) { loadViewIfNeeded() guard let page = lastAppliedPage else { return } cancelPendingTopAlignment() - let targetOffsetY = min(max(offsetY, 0), max(page.collapseDistance, 0)) - setNormalizedContentOffsetY(targetOffsetY) + setNormalizedContentOffsetY(page.headerGeometry.syncOffset(fromActiveOffset: offsetY)) } private func preserveVisualTopIfNeeded(previousOffsetY: CGFloat, targetOffsetY: CGFloat) { @@ -1349,7 +1369,8 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle } private func setNormalizedContentOffsetY(_ offsetY: CGFloat) { - let rawTopOffsetY = clampedRawContentOffsetY(forNormalizedOffsetY: offsetY) + guard let page = lastAppliedPage else { return } + let rawTopOffsetY = page.headerGeometry.rawContentOffsetY(forNormalizedOffsetY: offsetY, in: scrollView) setRawContentOffsetYIfNeeded(rawTopOffsetY) } @@ -1362,15 +1383,13 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle } private func clampedRawContentOffsetY(forNormalizedOffsetY offsetY: CGFloat) -> CGFloat { - let inset = scrollView.adjustedContentInset - let minOffsetY = -inset.top - let maxOffsetY = max(minOffsetY, scrollView.contentSize.height - scrollView.bounds.height + inset.bottom) - let rawOffsetY = offsetY - inset.top - return min(max(rawOffsetY, minOffsetY), maxOffsetY) + guard let page = lastAppliedPage else { return 0 } + return page.headerGeometry.rawContentOffsetY(forNormalizedOffsetY: offsetY, in: scrollView) } private func normalizedContentOffsetY(forRawOffsetY rawOffsetY: CGFloat) -> CGFloat { - rawOffsetY + scrollView.adjustedContentInset.top + guard let page = lastAppliedPage else { return rawOffsetY + scrollView.adjustedContentInset.top } + return page.headerGeometry.normalizedContentOffsetY(forRawOffsetY: rawOffsetY, in: scrollView) } } @@ -1669,7 +1688,7 @@ private struct VideoDetailTabPager: UIViewControllerRepresentable { return } let activeOffset = activePage.normalizedContentOffsetY - let syncOffset = min(max(activeOffset, 0), activePage.visualTopContentOffsetY) + let syncOffset = activePage.headerSyncOffset(fromActiveOffset: activeOffset) for tab in VideoPageTab.allCases where tab.pageIndex != selectedIndex { verticalPageController(for: tab)?.syncHeaderOffsetFromActivePage(syncOffset) } From 8041f5abc4f0d9453a69ff084329f86c1846f0b2 Mon Sep 17 00:00:00 2001 From: pgs666 <74764545+pgs666@users.noreply.github.com> Date: Sun, 7 Jun 2026 08:43:26 +0800 Subject: [PATCH 106/216] Ignore vertical pager updates during horizontal paging --- iosApp/VideoDetailView.swift | 35 ++++++++++++++++++++++++++++++++++- 1 file changed, 34 insertions(+), 1 deletion(-) diff --git a/iosApp/VideoDetailView.swift b/iosApp/VideoDetailView.swift index a918fe5b..64a95433 100644 --- a/iosApp/VideoDetailView.swift +++ b/iosApp/VideoDetailView.swift @@ -1075,6 +1075,7 @@ private final class VideoDetailVerticalScrollPageCoordinator: NSObject, UIScroll var onVerticalInteractionBegan: () -> Void = {} var visualTopContentOffsetY: CGFloat = 0 var isApplyingExternalOffset = false + var isHorizontalPagingActive = false private var lastReportedOffset: CGFloat? private var lastTopPullTranslationY: CGFloat = 0 @@ -1091,7 +1092,7 @@ private final class VideoDetailVerticalScrollPageCoordinator: NSObject, UIScroll } func scrollViewDidScroll(_ scrollView: UIScrollView) { - guard !isApplyingExternalOffset else { return } + guard !isApplyingExternalOffset, !isHorizontalPagingActive else { return } let offset = scrollView.verticalContentOffsetExcludingBounce guard lastReportedOffset.map({ abs($0 - offset) > 0.5 }) ?? true else { return } lastReportedOffset = offset @@ -1362,6 +1363,13 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle setNormalizedContentOffsetY(page.headerGeometry.syncOffset(fromActiveOffset: offsetY)) } + func setHorizontalPagingActive(_ isActive: Bool) { + coordinator.isHorizontalPagingActive = isActive + if !isActive { + coordinator.resetReportedOffset(scrollView.verticalContentOffsetExcludingBounce) + } + } + private func preserveVisualTopIfNeeded(previousOffsetY: CGFloat, targetOffsetY: CGFloat) { guard abs(previousOffsetY - targetOffsetY) > 0.5 else { return } guard abs(scrollView.verticalContentOffsetExcludingBounce - previousOffsetY) <= 1 else { return } @@ -1467,6 +1475,7 @@ private struct VideoDetailTabPager: UIViewControllerRepresentable { final class Coordinator: NSObject, UIScrollViewDelegate { var selectedTab: Binding var onSelectedIndexSettled: ((Int) -> Void)? + var onPagingActivityChanged: ((Bool) -> Void)? private var lastProgrammaticIndex: Int? private var pendingSettledIndex: Int? @@ -1480,16 +1489,23 @@ private struct VideoDetailTabPager: UIViewControllerRepresentable { return lastProgrammaticIndex != index } + func scrollViewWillBeginDragging(_ scrollView: UIScrollView) { + onPagingActivityChanged?(true) + } + func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) { + onPagingActivityChanged?(false) updateSelectedTab(from: scrollView) } func scrollViewDidEndScrollingAnimation(_ scrollView: UIScrollView) { + onPagingActivityChanged?(false) updateSelectedTab(from: scrollView) } func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) { if !decelerate { + onPagingActivityChanged?(false) updateSelectedTab(from: scrollView) } } @@ -1542,6 +1558,7 @@ private struct VideoDetailTabPager: UIViewControllerRepresentable { private var lastSettledSelectedIndex: Int? private var pendingSelectedIndex: Int? private var lastLaidOutWidth: CGFloat = 0 + private var isHorizontalPagingActive = false init(coordinator: Coordinator) { self.coordinator = coordinator @@ -1577,6 +1594,9 @@ private struct VideoDetailTabPager: UIViewControllerRepresentable { in: view ) ?? true } + coordinator.onPagingActivityChanged = { [weak self] isActive in + self?.setHorizontalPagingActive(isActive) + } view.addSubview(scrollView) contentView.translatesAutoresizingMaskIntoConstraints = false @@ -1662,6 +1682,9 @@ private struct VideoDetailTabPager: UIViewControllerRepresentable { } let targetOffset = CGPoint(x: CGFloat(selectedIndex) * width, y: 0) guard abs(scrollView.contentOffset.x - targetOffset.x) > 0.5 else { return } + if animated { + setHorizontalPagingActive(true) + } scrollView.setContentOffset(targetOffset, animated: animated) } @@ -1683,6 +1706,16 @@ private struct VideoDetailTabPager: UIViewControllerRepresentable { } } + private func setHorizontalPagingActive(_ isActive: Bool) { + guard isHorizontalPagingActive != isActive else { return } + isHorizontalPagingActive = isActive + introductionPage.setHorizontalPagingActive(isActive) + commentsPage.setHorizontalPagingActive(isActive) + if !isActive { + syncInactivePageHeaderOffset() + } + } + private func syncInactivePageHeaderOffset() { guard let activePage = verticalPageController(for: VideoPageTab.page(at: selectedIndex)) else { return From d86804533db54fb75d67aade46f11113f2f8d60d Mon Sep 17 00:00:00 2001 From: pgs666 <74764545+pgs666@users.noreply.github.com> Date: Sun, 7 Jun 2026 08:56:15 +0800 Subject: [PATCH 107/216] Preserve initial pager header offsets --- iosApp/VideoDetailView.swift | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/iosApp/VideoDetailView.swift b/iosApp/VideoDetailView.swift index 64a95433..83f6cdc1 100644 --- a/iosApp/VideoDetailView.swift +++ b/iosApp/VideoDetailView.swift @@ -1140,6 +1140,7 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle private var lastAppliedPage: VideoDetailTabPage? private var topAlignmentGeneration = 0 private var pendingTopAlignmentTargetOffsetY: CGFloat? + private var needsInitialHeaderOffsetReset = true init(tab: VideoPageTab) { coordinator = VideoDetailVerticalScrollPageCoordinator( @@ -1251,9 +1252,7 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle if contentUpdateRevision != page.contentUpdateRevision { contentUpdateRevision = page.contentUpdateRevision host.rootView = page.content() - if page.isSelected { - requestTopAlignment(targetOffsetY: page.headerGeometry.resolvedVisualTopOffset) - } + needsInitialHeaderOffsetReset = true } let previousVisualTopContentOffsetY = coordinator.visualTopContentOffsetY @@ -1265,7 +1264,9 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle applyBottomContentInset(page.contentBottomPadding) collapseSpacerHeightConstraint.constant = geometry.collapseSpacerHeight contentMinimumHeightConstraint.constant = geometry.minimumContentHeight(in: scrollView.bounds.height) - if pendingTopAlignmentTargetOffsetY != nil { + if page.isSelected, needsInitialHeaderOffsetReset { + requestTopAlignment(targetOffsetY: visualTopOffset) + } else if pendingTopAlignmentTargetOffsetY != nil { pendingTopAlignmentTargetOffsetY = visualTopOffset resolvePendingTopAlignmentSoon() } else { @@ -1289,6 +1290,10 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle pendingTopAlignmentTargetOffsetY = visualTopOffset return } + if needsInitialHeaderOffsetReset { + requestTopAlignment(targetOffsetY: visualTopOffset) + return + } guard scrollView.verticalContentOffsetExcludingBounce <= visualTopOffset + 8 else { return } requestTopAlignment(targetOffsetY: visualTopOffset) } @@ -1331,6 +1336,7 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle guard abs(normalizedContentOffsetY(forRawOffsetY: rawTopOffsetY) - targetOffsetY) <= 0.5 else { return } setRawContentOffsetYIfNeeded(rawTopOffsetY) if abs(scrollView.verticalContentOffsetExcludingBounce - targetOffsetY) <= 0.5 { + needsInitialHeaderOffsetReset = false cancelPendingTopAlignment() } } @@ -1342,7 +1348,8 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle func settleAfterHorizontalActivation() { loadViewIfNeeded() let targetOffsetY = coordinator.visualTopContentOffsetY - guard scrollView.verticalContentOffsetExcludingBounce <= targetOffsetY + 8 else { return } + guard needsInitialHeaderOffsetReset + || scrollView.verticalContentOffsetExcludingBounce <= targetOffsetY + 8 else { return } requestTopAlignment(targetOffsetY: targetOffsetY) resolvePendingTopAlignmentIfPossible(allowDuringInteraction: true) } @@ -1360,6 +1367,7 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle loadViewIfNeeded() guard let page = lastAppliedPage else { return } cancelPendingTopAlignment() + guard !needsInitialHeaderOffsetReset else { return } setNormalizedContentOffsetY(page.headerGeometry.syncOffset(fromActiveOffset: offsetY)) } From 88a24a3428a1b4ddbb2a88f61aca3650eae346b8 Mon Sep 17 00:00:00 2001 From: pgs666 <74764545+pgs666@users.noreply.github.com> Date: Sun, 7 Jun 2026 09:03:45 +0800 Subject: [PATCH 108/216] Add pager list header carrier --- iosApp/VideoDetailView.swift | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/iosApp/VideoDetailView.swift b/iosApp/VideoDetailView.swift index 83f6cdc1..70b47d9c 100644 --- a/iosApp/VideoDetailView.swift +++ b/iosApp/VideoDetailView.swift @@ -1131,6 +1131,7 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle private let coordinator: VideoDetailVerticalScrollPageCoordinator private let scrollView = VerticalScrollView() private let contentView = UIView() + private let listHeaderView = UIView() private let collapseSpacerView = UIView() private let host = UIHostingController(rootView: AnyView(EmptyView())) private var hostMinimumHeightConstraint: NSLayoutConstraint! @@ -1188,6 +1189,10 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle contentView.backgroundColor = .clear scrollView.addSubview(contentView) + listHeaderView.backgroundColor = .clear + listHeaderView.isUserInteractionEnabled = false + scrollView.addSubview(listHeaderView) + addChild(host) host.view.translatesAutoresizingMaskIntoConstraints = false host.view.backgroundColor = .clear @@ -1233,6 +1238,7 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle override func viewDidLayoutSubviews() { super.viewDidLayoutSubviews() + applyListHeaderFrame() applyCurrentPageGeometryRules() resolvePendingTopAlignmentIfPossible() } @@ -1261,6 +1267,7 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle lastAppliedPage = page coordinator.visualTopContentOffsetY = visualTopOffset applyTopContentInset(geometry.contentTopInset) + applyListHeaderFrame() applyBottomContentInset(page.contentBottomPadding) collapseSpacerHeightConstraint.constant = geometry.collapseSpacerHeight contentMinimumHeightConstraint.constant = geometry.minimumContentHeight(in: scrollView.bounds.height) @@ -1303,6 +1310,7 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle guard abs(scrollView.contentInset.top - resolvedTopInset) > 0.5 else { return } scrollView.contentInset.top = resolvedTopInset scrollView.verticalScrollIndicatorInsets.top = resolvedTopInset + applyListHeaderFrame() } private func applyBottomContentInset(_ bottomInset: CGFloat) { @@ -1312,6 +1320,18 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle scrollView.verticalScrollIndicatorInsets.bottom = resolvedBottomInset } + private func applyListHeaderFrame() { + let topInset = max(scrollView.contentInset.top, 0) + let nextFrame = CGRect( + x: 0, + y: -topInset, + width: scrollView.bounds.width, + height: topInset + ) + guard !listHeaderView.frame.isApproximatelyEqual(to: nextFrame) else { return } + listHeaderView.frame = nextFrame + } + private func requestTopAlignment(targetOffsetY: CGFloat) { topAlignmentGeneration &+= 1 pendingTopAlignmentTargetOffsetY = targetOffsetY @@ -1447,6 +1467,15 @@ private extension UIScrollView { } } +private extension CGRect { + func isApproximatelyEqual(to other: CGRect) -> Bool { + abs(origin.x - other.origin.x) <= 0.5 + && abs(origin.y - other.origin.y) <= 0.5 + && abs(size.width - other.size.width) <= 0.5 + && abs(size.height - other.size.height) <= 0.5 + } +} + private struct VideoDetailTabPager: UIViewControllerRepresentable { @Binding var selectedTab: VideoPageTab let introduction: VideoDetailTabPage From 0d3c8b2c7c6bf7ccc84a9261f51096fc65d9b873 Mon Sep 17 00:00:00 2001 From: pgs666 <74764545+pgs666@users.noreply.github.com> Date: Sun, 7 Jun 2026 09:15:12 +0800 Subject: [PATCH 109/216] Extract video detail pager coordinator --- iosApp/VideoDetailPager.swift | 1097 +++++++++++++++++++++++++++++++++ iosApp/VideoDetailView.swift | 1094 -------------------------------- 2 files changed, 1097 insertions(+), 1094 deletions(-) create mode 100644 iosApp/VideoDetailPager.swift diff --git a/iosApp/VideoDetailPager.swift b/iosApp/VideoDetailPager.swift new file mode 100644 index 00000000..eb59ea35 --- /dev/null +++ b/iosApp/VideoDetailPager.swift @@ -0,0 +1,1097 @@ +import SwiftUI +import UIKit + +enum VideoPlayerCollapseModel { + static func nextCollapseOffset( + currentCollapseOffset: CGFloat, + activeTabOffset: CGFloat, + previousActiveTabOffset: CGFloat?, + collapseDistance: CGFloat + ) -> CGFloat { + let clampedCurrent = clamp(currentCollapseOffset, upperBound: collapseDistance) + + guard let previousActiveTabOffset else { + let nonnegativeActiveOffset = max(0, activeTabOffset) + return min(collapseDistance, max(clampedCurrent, nonnegativeActiveOffset)) + } + + let delta = activeTabOffset - previousActiveTabOffset + if abs(delta) > 0.5 { + return clamp(clampedCurrent + delta, upperBound: collapseDistance) + } + + return clampedCurrent + } + + private static func clamp(_ value: CGFloat, upperBound: CGFloat) -> CGFloat { + min(max(value, 0), max(upperBound, 0)) + } +} + +struct VideoDetailPagerLayoutMetrics { + let collapseDistance: CGFloat + let playerScrollAway: CGFloat + let continuationProgress: CGFloat +} + +struct VideoDetailPagerState: Equatable { + var selectedTab: VideoPageTab = .introduction + var collapseOffset: CGFloat = 0 + var tabOffsets: [VideoPageTab: CGFloat] = [:] + var isPlayerPlaying: Bool = false + + func layoutMetrics( + containerHeight _: CGFloat, + rawCollapseDistance: CGFloat + ) -> VideoDetailPagerLayoutMetrics { + let collapseDistance = isPlayerPlaying ? 0 : max(rawCollapseDistance, 0) + let playerScrollAway = isPlayerPlaying ? 0 : Self.clamp(collapseOffset, upperBound: collapseDistance) + let fadeDistance: CGFloat = 72 + let continuationProgress: CGFloat + if rawCollapseDistance > 1 { + continuationProgress = min( + max((playerScrollAway - (rawCollapseDistance - fadeDistance)) / fadeDistance, 0), + 1 + ) + } else { + continuationProgress = 0 + } + + return VideoDetailPagerLayoutMetrics( + collapseDistance: collapseDistance, + playerScrollAway: playerScrollAway, + continuationProgress: continuationProgress + ) + } + + mutating func setPlayerPlaying(_ isPlaying: Bool) { + isPlayerPlaying = isPlaying + if isPlaying { + collapseOffset = 0 + } + } + + mutating func expandPlayer() { + collapseOffset = 0 + } + + mutating func selectTab(_ tab: VideoPageTab, collapseDistance: CGFloat) { + selectedTab = tab + clampCollapse(to: collapseDistance) + } + + mutating func updateTabOffset(_ tab: VideoPageTab, offset: CGFloat, collapseDistance: CGFloat) { + guard tab == selectedTab else { return } + guard !isPlayerPlaying else { + collapseOffset = 0 + return + } + let trackedOffset = Self.clamp(offset, upperBound: collapseDistance) + let previousActiveOffset = tabOffsets[tab] + let previousCollapseOffset = collapseOffset + let nextCollapseOffset = nextCollapseOffset( + activeTabOffset: trackedOffset, + previousActiveTabOffset: previousActiveOffset, + collapseDistance: collapseDistance + ) + guard previousActiveOffset != trackedOffset + || abs(previousCollapseOffset - nextCollapseOffset) > 0.5 else { + return + } + tabOffsets[tab] = trackedOffset + collapseOffset = nextCollapseOffset + } + + private func nextCollapseOffset( + activeTabOffset: CGFloat, + previousActiveTabOffset: CGFloat?, + collapseDistance: CGFloat + ) -> CGFloat { + VideoPlayerCollapseModel.nextCollapseOffset( + currentCollapseOffset: collapseOffset, + activeTabOffset: activeTabOffset, + previousActiveTabOffset: previousActiveTabOffset, + collapseDistance: collapseDistance + ) + } + + mutating func beginInteracting(with tab: VideoPageTab, collapseDistance: CGFloat) { + guard selectedTab != tab else { return } + selectTab(tab, collapseDistance: collapseDistance) + } + + mutating func handleTopPull(tab: VideoPageTab, delta: CGFloat, collapseDistance: CGFloat) { + guard tab == selectedTab, !isPlayerPlaying, delta > 0, collapseOffset > 0 else { return } + collapseOffset = Self.clamp(collapseOffset - delta, upperBound: collapseDistance) + } + + mutating func clampCollapse(to collapseDistance: CGFloat) { + collapseOffset = Self.clamp(collapseOffset, upperBound: collapseDistance) + } + + private static func clamp(_ value: CGFloat, upperBound: CGFloat) -> CGFloat { + min(max(value, 0), max(upperBound, 0)) + } +} + +enum VideoPageTab: String, CaseIterable, Identifiable { + case introduction + case comments + + var id: String { rawValue } + + var pageIndex: Int { + switch self { + case .introduction: return 0 + case .comments: return 1 + } + } + + static func page(at index: Int) -> VideoPageTab { + index <= 0 ? .introduction : .comments + } + + var scrollCoordinateSpaceName: String { + "bottomScroll-\(rawValue)" + } + + var title: String { + switch self { + case .introduction: + return String(localized: "简介") + case .comments: + return String(localized: "评论") + } + } +} + +struct VideoDetailTabContentRevision: Equatable { + let introduction: Int + let comments: Int +} + +private struct VideoDetailSmoothHeaderGeometry: Equatable { + let headerHeight: CGFloat + let pinHeaderHeight: CGFloat + let collapseDistance: CGFloat + let visualTopOffset: CGFloat + let pinnedVisibleHeight: CGFloat + + var contentTopInset: CGFloat { + max(headerHeight, 0) + max(pinHeaderHeight, 0) + } + + var resolvedVisualTopOffset: CGFloat { + min(max(visualTopOffset, 0), max(collapseDistance, 0)) + } + + var collapseSpacerHeight: CGFloat { + max(collapseDistance - resolvedVisualTopOffset + 1, 1) + } + + func minimumContentHeight(in scrollBoundsHeight: CGFloat) -> CGFloat { + max( + scrollBoundsHeight - max(pinnedVisibleHeight, 0), + max(collapseDistance, 0) + 1, + 1 + ) + } + + func rawContentOffsetY(forNormalizedOffsetY offsetY: CGFloat, in scrollView: UIScrollView) -> CGFloat { + let inset = scrollView.adjustedContentInset + let minOffsetY = -inset.top + let maxOffsetY = max(minOffsetY, scrollView.contentSize.height - scrollView.bounds.height + inset.bottom) + let rawOffsetY = offsetY - inset.top + return min(max(rawOffsetY, minOffsetY), maxOffsetY) + } + + func normalizedContentOffsetY(forRawOffsetY rawOffsetY: CGFloat, in scrollView: UIScrollView) -> CGFloat { + rawOffsetY + scrollView.adjustedContentInset.top + } + + func syncOffset(fromActiveOffset activeOffset: CGFloat) -> CGFloat { + min(max(activeOffset, 0), resolvedVisualTopOffset) + } +} + +private struct VideoDetailTabPage { + let tab: VideoPageTab + let contentBottomPadding: CGFloat + let isSelected: Bool + let headerGeometry: VideoDetailSmoothHeaderGeometry + let contentUpdateRevision: Int + let onOffsetChange: (VideoPageTab, CGFloat) -> Void + let onInteractionBegan: (VideoPageTab) -> Void + let onTopPullDelta: (VideoPageTab, CGFloat) -> Void + let content: () -> AnyView + + init( + tab: VideoPageTab, + contentBottomPadding: CGFloat, + isSelected: Bool, + headerGeometry: VideoDetailSmoothHeaderGeometry, + contentUpdateRevision: Int, + onOffsetChange: @escaping (VideoPageTab, CGFloat) -> Void, + onInteractionBegan: @escaping (VideoPageTab) -> Void, + onTopPullDelta: @escaping (VideoPageTab, CGFloat) -> Void, + @ViewBuilder content: @escaping () -> Content + ) { + self.tab = tab + self.contentBottomPadding = contentBottomPadding + self.isSelected = isSelected + self.headerGeometry = headerGeometry + self.contentUpdateRevision = contentUpdateRevision + self.onOffsetChange = onOffsetChange + self.onInteractionBegan = onInteractionBegan + self.onTopPullDelta = onTopPullDelta + self.content = { AnyView(content()) } + } +} + +struct VideoDetailPagerContainer: View { + @Binding var state: VideoDetailPagerState + let collapseDistance: CGFloat + let headerHeight: CGFloat + let pinHeaderHeight: CGFloat + let pinnedVisibleHeight: CGFloat + let playerScrollAway: CGFloat + let introductionContentBottomPadding: CGFloat + let commentsContentBottomPadding: CGFloat + let introductionContentRevision: Int + let commentsContentRevision: Int + let introduction: () -> Introduction + let comments: () -> Comments + + private var selectedTabBinding: Binding { + Binding( + get: { state.selectedTab }, + set: { newTab in + mutateState { $0.selectTab(newTab, collapseDistance: collapseDistance) } + } + ) + } + + var body: some View { + ZStack(alignment: .top) { + VideoDetailTabPager( + selectedTab: selectedTabBinding, + introduction: tabPage( + .introduction, + contentBottomPadding: introductionContentBottomPadding, + contentUpdateRevision: introductionContentRevision, + content: introduction + ), + comments: tabPage( + .comments, + contentBottomPadding: commentsContentBottomPadding, + contentUpdateRevision: commentsContentRevision, + content: comments + ) + ) + .frame(maxHeight: .infinity) + + Picker("Content", selection: selectedTabBinding) { + ForEach(VideoPageTab.allCases) { tab in + Text(tab.title).tag(tab) + } + } + .pickerStyle(.segmented) + .padding(.horizontal, 16) + .frame(height: pinHeaderHeight) + .frame(maxWidth: .infinity) + .background(.background) + .offset(y: max(headerHeight - playerScrollAway, pinnedVisibleHeight - pinHeaderHeight)) + .zIndex(1) + } + .frame(maxHeight: .infinity) + .background(Color(.systemGroupedBackground)) + .onValueChange(of: collapseDistance) { newValue in + mutateState { $0.clampCollapse(to: newValue) } + } + } + + private func tabPage( + _ tab: VideoPageTab, + contentBottomPadding: CGFloat, + contentUpdateRevision: Int, + @ViewBuilder content: @escaping () -> Content + ) -> VideoDetailTabPage { + VideoDetailTabPage( + tab: tab, + contentBottomPadding: contentBottomPadding, + isSelected: state.selectedTab == tab, + headerGeometry: VideoDetailSmoothHeaderGeometry( + headerHeight: headerHeight, + pinHeaderHeight: pinHeaderHeight, + collapseDistance: collapseDistance, + visualTopOffset: playerScrollAway, + pinnedVisibleHeight: pinnedVisibleHeight + ), + contentUpdateRevision: contentUpdateRevision, + onOffsetChange: { tab, offset in + mutateState { + $0.updateTabOffset(tab, offset: offset, collapseDistance: collapseDistance) + } + }, + onInteractionBegan: { tab in + mutateState { + $0.beginInteracting(with: tab, collapseDistance: collapseDistance) + } + }, + onTopPullDelta: { tab, delta in + mutateState { + $0.handleTopPull(tab: tab, delta: delta, collapseDistance: collapseDistance) + } + }, + content: content + ) + } + + private func mutateState(_ mutation: (inout VideoDetailPagerState) -> Void) { + withTransaction(Transaction(animation: nil)) { + var nextState = state + mutation(&nextState) + if nextState != state { + state = nextState + } + } + } +} + +private final class VideoDetailVerticalScrollPageCoordinator: NSObject, UIScrollViewDelegate { + var tab: VideoPageTab + var onOffsetChange: (VideoPageTab, CGFloat) -> Void + var onInteractionBegan: (VideoPageTab) -> Void + var onTopPullDelta: (VideoPageTab, CGFloat) -> Void + var onVerticalInteractionBegan: () -> Void = {} + var visualTopContentOffsetY: CGFloat = 0 + var isApplyingExternalOffset = false + var isHorizontalPagingActive = false + private var lastReportedOffset: CGFloat? + private var lastTopPullTranslationY: CGFloat = 0 + + init( + tab: VideoPageTab, + onOffsetChange: @escaping (VideoPageTab, CGFloat) -> Void, + onInteractionBegan: @escaping (VideoPageTab) -> Void, + onTopPullDelta: @escaping (VideoPageTab, CGFloat) -> Void + ) { + self.tab = tab + self.onOffsetChange = onOffsetChange + self.onInteractionBegan = onInteractionBegan + self.onTopPullDelta = onTopPullDelta + } + + func scrollViewDidScroll(_ scrollView: UIScrollView) { + guard !isApplyingExternalOffset, !isHorizontalPagingActive else { return } + let offset = scrollView.verticalContentOffsetExcludingBounce + guard lastReportedOffset.map({ abs($0 - offset) > 0.5 }) ?? true else { return } + lastReportedOffset = offset + onOffsetChange(tab, offset) + } + + func resetReportedOffset(_ offset: CGFloat) { + lastReportedOffset = offset + } + + @objc func handlePan(_ panGestureRecognizer: UIPanGestureRecognizer) { + guard let scrollView = panGestureRecognizer.view as? UIScrollView else { return } + switch panGestureRecognizer.state { + case .began: + onVerticalInteractionBegan() + onInteractionBegan(tab) + lastTopPullTranslationY = 0 + case .changed: + guard scrollView.verticalContentOffsetExcludingBounce <= visualTopContentOffsetY + 0.5 else { + lastTopPullTranslationY = panGestureRecognizer.translation(in: scrollView).y + return + } + let translationY = panGestureRecognizer.translation(in: scrollView).y + let delta = translationY - lastTopPullTranslationY + lastTopPullTranslationY = translationY + if delta > 0 { + onTopPullDelta(tab, delta) + } + default: + lastTopPullTranslationY = 0 + } + } +} + +private final class VideoDetailVerticalScrollPageViewController: UIViewController { + private let coordinator: VideoDetailVerticalScrollPageCoordinator + private let scrollView = VerticalScrollView() + private let contentView = UIView() + private let listHeaderView = UIView() + private let collapseSpacerView = UIView() + private let host = UIHostingController(rootView: AnyView(EmptyView())) + private var hostMinimumHeightConstraint: NSLayoutConstraint! + private var contentMinimumHeightConstraint: NSLayoutConstraint! + private var collapseSpacerHeightConstraint: NSLayoutConstraint! + private var contentUpdateRevision: Int? + private var lastAppliedPage: VideoDetailTabPage? + private var topAlignmentGeneration = 0 + private var pendingTopAlignmentTargetOffsetY: CGFloat? + private var needsInitialHeaderOffsetReset = true + + init(tab: VideoPageTab) { + coordinator = VideoDetailVerticalScrollPageCoordinator( + tab: tab, + onOffsetChange: { _, _ in }, + onInteractionBegan: { _ in }, + onTopPullDelta: { _, _ in } + ) + super.init(nibName: nil, bundle: nil) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + view.backgroundColor = .clear + + scrollView.translatesAutoresizingMaskIntoConstraints = false + scrollView.backgroundColor = .clear + scrollView.bounces = true + scrollView.alwaysBounceVertical = true + scrollView.alwaysBounceHorizontal = false + scrollView.showsHorizontalScrollIndicator = false + scrollView.showsVerticalScrollIndicator = false + scrollView.isDirectionalLockEnabled = true + scrollView.contentInsetAdjustmentBehavior = .never + scrollView.keyboardDismissMode = .interactive + scrollView.delegate = coordinator + scrollView.panGestureRecognizer.addTarget(coordinator, action: #selector(VideoDetailVerticalScrollPageCoordinator.handlePan(_:))) + scrollView.onGeometryChange = { [weak self] in + self?.applyCurrentPageGeometryRules() + self?.resolvePendingTopAlignmentIfPossible() + } + scrollView.shouldBeginVerticalPan = { [weak self] panGestureRecognizer, view in + self?.resolvePendingTopAlignmentIfPossible(allowDuringInteraction: true) + let velocity = panGestureRecognizer.velocity(in: view) + return abs(velocity.x) <= abs(velocity.y) * 1.05 + } + view.addSubview(scrollView) + + contentView.translatesAutoresizingMaskIntoConstraints = false + contentView.backgroundColor = .clear + scrollView.addSubview(contentView) + + listHeaderView.backgroundColor = .clear + listHeaderView.isUserInteractionEnabled = false + scrollView.addSubview(listHeaderView) + + addChild(host) + host.view.translatesAutoresizingMaskIntoConstraints = false + host.view.backgroundColor = .clear + contentView.addSubview(host.view) + host.didMove(toParent: self) + + collapseSpacerView.translatesAutoresizingMaskIntoConstraints = false + collapseSpacerView.backgroundColor = .clear + contentView.addSubview(collapseSpacerView) + + hostMinimumHeightConstraint = host.view.heightAnchor.constraint(greaterThanOrEqualTo: scrollView.frameLayoutGuide.heightAnchor) + contentMinimumHeightConstraint = contentView.heightAnchor.constraint( + greaterThanOrEqualTo: scrollView.frameLayoutGuide.heightAnchor, + constant: 1 + ) + collapseSpacerHeightConstraint = collapseSpacerView.heightAnchor.constraint(equalToConstant: 1) + + NSLayoutConstraint.activate([ + scrollView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + scrollView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + scrollView.topAnchor.constraint(equalTo: view.topAnchor), + scrollView.bottomAnchor.constraint(equalTo: view.bottomAnchor), + + contentView.leadingAnchor.constraint(equalTo: scrollView.contentLayoutGuide.leadingAnchor), + contentView.trailingAnchor.constraint(equalTo: scrollView.contentLayoutGuide.trailingAnchor), + contentView.topAnchor.constraint(equalTo: scrollView.contentLayoutGuide.topAnchor), + contentView.bottomAnchor.constraint(equalTo: scrollView.contentLayoutGuide.bottomAnchor), + contentView.widthAnchor.constraint(equalTo: scrollView.frameLayoutGuide.widthAnchor), + contentMinimumHeightConstraint, + + host.view.leadingAnchor.constraint(equalTo: contentView.leadingAnchor), + host.view.trailingAnchor.constraint(equalTo: contentView.trailingAnchor), + host.view.topAnchor.constraint(equalTo: contentView.topAnchor), + hostMinimumHeightConstraint, + + collapseSpacerView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor), + collapseSpacerView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor), + collapseSpacerView.topAnchor.constraint(equalTo: host.view.bottomAnchor), + collapseSpacerView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor), + collapseSpacerHeightConstraint + ]) + } + + override func viewDidLayoutSubviews() { + super.viewDidLayoutSubviews() + applyListHeaderFrame() + applyCurrentPageGeometryRules() + resolvePendingTopAlignmentIfPossible() + } + + func update(page: VideoDetailTabPage) { + loadViewIfNeeded() + coordinator.tab = page.tab + coordinator.onOffsetChange = page.onOffsetChange + coordinator.onInteractionBegan = page.onInteractionBegan + coordinator.onTopPullDelta = page.onTopPullDelta + coordinator.onVerticalInteractionBegan = { [weak self] in + self?.resolvePendingTopAlignmentIfPossible(allowDuringInteraction: true) + } + if !page.isSelected { + cancelPendingTopAlignment() + } + if contentUpdateRevision != page.contentUpdateRevision { + contentUpdateRevision = page.contentUpdateRevision + host.rootView = page.content() + needsInitialHeaderOffsetReset = true + } + + let previousVisualTopContentOffsetY = coordinator.visualTopContentOffsetY + let geometry = page.headerGeometry + let visualTopOffset = geometry.resolvedVisualTopOffset + lastAppliedPage = page + coordinator.visualTopContentOffsetY = visualTopOffset + applyTopContentInset(geometry.contentTopInset) + applyListHeaderFrame() + applyBottomContentInset(page.contentBottomPadding) + collapseSpacerHeightConstraint.constant = geometry.collapseSpacerHeight + contentMinimumHeightConstraint.constant = geometry.minimumContentHeight(in: scrollView.bounds.height) + if page.isSelected, needsInitialHeaderOffsetReset { + requestTopAlignment(targetOffsetY: visualTopOffset) + } else if pendingTopAlignmentTargetOffsetY != nil { + pendingTopAlignmentTargetOffsetY = visualTopOffset + resolvePendingTopAlignmentSoon() + } else { + preserveVisualTopIfNeeded( + previousOffsetY: previousVisualTopContentOffsetY, + targetOffsetY: visualTopOffset + ) + } + } + + private func applyCurrentPageGeometryRules() { + guard let page = lastAppliedPage else { return } + let geometry = page.headerGeometry + let minimumContentHeight = geometry.minimumContentHeight(in: scrollView.bounds.height) + if abs(contentMinimumHeightConstraint.constant - minimumContentHeight) > 0.5 { + contentMinimumHeightConstraint.constant = minimumContentHeight + } + guard page.isSelected else { return } + let visualTopOffset = geometry.resolvedVisualTopOffset + if pendingTopAlignmentTargetOffsetY != nil { + pendingTopAlignmentTargetOffsetY = visualTopOffset + return + } + if needsInitialHeaderOffsetReset { + requestTopAlignment(targetOffsetY: visualTopOffset) + return + } + guard scrollView.verticalContentOffsetExcludingBounce <= visualTopOffset + 8 else { return } + requestTopAlignment(targetOffsetY: visualTopOffset) + } + + private func applyTopContentInset(_ topInset: CGFloat) { + let resolvedTopInset = max(topInset, 0) + guard abs(scrollView.contentInset.top - resolvedTopInset) > 0.5 else { return } + scrollView.contentInset.top = resolvedTopInset + scrollView.verticalScrollIndicatorInsets.top = resolvedTopInset + applyListHeaderFrame() + } + + private func applyBottomContentInset(_ bottomInset: CGFloat) { + let resolvedBottomInset = max(bottomInset, 0) + guard abs(scrollView.contentInset.bottom - resolvedBottomInset) > 0.5 else { return } + scrollView.contentInset.bottom = resolvedBottomInset + scrollView.verticalScrollIndicatorInsets.bottom = resolvedBottomInset + } + + private func applyListHeaderFrame() { + let topInset = max(scrollView.contentInset.top, 0) + let nextFrame = CGRect( + x: 0, + y: -topInset, + width: scrollView.bounds.width, + height: topInset + ) + guard !listHeaderView.frame.isApproximatelyEqual(to: nextFrame) else { return } + listHeaderView.frame = nextFrame + } + + private func requestTopAlignment(targetOffsetY: CGFloat) { + topAlignmentGeneration &+= 1 + pendingTopAlignmentTargetOffsetY = targetOffsetY + resolvePendingTopAlignmentSoon() + } + + private func resolvePendingTopAlignmentSoon() { + let generation = topAlignmentGeneration + resolvePendingTopAlignmentIfPossible() + DispatchQueue.main.async { [weak self] in + guard let self, self.topAlignmentGeneration == generation else { return } + self.resolvePendingTopAlignmentIfPossible() + } + } + + private func resolvePendingTopAlignmentIfPossible(allowDuringInteraction: Bool = false) { + guard let targetOffsetY = pendingTopAlignmentTargetOffsetY else { return } + if !allowDuringInteraction { + guard !scrollView.isTracking, !scrollView.isDragging, !scrollView.isDecelerating else { return } + } + let rawTopOffsetY = clampedRawContentOffsetY(forNormalizedOffsetY: targetOffsetY) + guard abs(normalizedContentOffsetY(forRawOffsetY: rawTopOffsetY) - targetOffsetY) <= 0.5 else { return } + setRawContentOffsetYIfNeeded(rawTopOffsetY) + if abs(scrollView.verticalContentOffsetExcludingBounce - targetOffsetY) <= 0.5 { + needsInitialHeaderOffsetReset = false + cancelPendingTopAlignment() + } + } + + private func cancelPendingTopAlignment() { + pendingTopAlignmentTargetOffsetY = nil + } + + func settleAfterHorizontalActivation() { + loadViewIfNeeded() + let targetOffsetY = coordinator.visualTopContentOffsetY + guard needsInitialHeaderOffsetReset + || scrollView.verticalContentOffsetExcludingBounce <= targetOffsetY + 8 else { return } + requestTopAlignment(targetOffsetY: targetOffsetY) + resolvePendingTopAlignmentIfPossible(allowDuringInteraction: true) + } + + var normalizedContentOffsetY: CGFloat { + loadViewIfNeeded() + return scrollView.verticalContentOffsetExcludingBounce + } + + func headerSyncOffset(fromActiveOffset activeOffset: CGFloat) -> CGFloat { + lastAppliedPage?.headerGeometry.syncOffset(fromActiveOffset: activeOffset) ?? 0 + } + + func syncHeaderOffsetFromActivePage(_ offsetY: CGFloat) { + loadViewIfNeeded() + guard let page = lastAppliedPage else { return } + cancelPendingTopAlignment() + guard !needsInitialHeaderOffsetReset else { return } + setNormalizedContentOffsetY(page.headerGeometry.syncOffset(fromActiveOffset: offsetY)) + } + + func setHorizontalPagingActive(_ isActive: Bool) { + coordinator.isHorizontalPagingActive = isActive + if !isActive { + coordinator.resetReportedOffset(scrollView.verticalContentOffsetExcludingBounce) + } + } + + private func preserveVisualTopIfNeeded(previousOffsetY: CGFloat, targetOffsetY: CGFloat) { + guard abs(previousOffsetY - targetOffsetY) > 0.5 else { return } + guard abs(scrollView.verticalContentOffsetExcludingBounce - previousOffsetY) <= 1 else { return } + setNormalizedContentOffsetY(targetOffsetY) + } + + private func setNormalizedContentOffsetY(_ offsetY: CGFloat) { + guard let page = lastAppliedPage else { return } + let rawTopOffsetY = page.headerGeometry.rawContentOffsetY(forNormalizedOffsetY: offsetY, in: scrollView) + setRawContentOffsetYIfNeeded(rawTopOffsetY) + } + + private func setRawContentOffsetYIfNeeded(_ rawTopOffsetY: CGFloat) { + guard abs(scrollView.contentOffset.y - rawTopOffsetY) > 0.5 else { return } + coordinator.isApplyingExternalOffset = true + defer { coordinator.isApplyingExternalOffset = false } + scrollView.setContentOffset(CGPoint(x: scrollView.contentOffset.x, y: rawTopOffsetY), animated: false) + coordinator.resetReportedOffset(scrollView.verticalContentOffsetExcludingBounce) + } + + private func clampedRawContentOffsetY(forNormalizedOffsetY offsetY: CGFloat) -> CGFloat { + guard let page = lastAppliedPage else { return 0 } + return page.headerGeometry.rawContentOffsetY(forNormalizedOffsetY: offsetY, in: scrollView) + } + + private func normalizedContentOffsetY(forRawOffsetY rawOffsetY: CGFloat) -> CGFloat { + guard let page = lastAppliedPage else { return rawOffsetY + scrollView.adjustedContentInset.top } + return page.headerGeometry.normalizedContentOffsetY(forRawOffsetY: rawOffsetY, in: scrollView) + } +} + +private final class VerticalScrollView: UIScrollView { + var shouldBeginVerticalPan: ((UIPanGestureRecognizer, UIView) -> Bool)? + var onGeometryChange: (() -> Void)? + + override var bounds: CGRect { + didSet { + guard abs(bounds.height - oldValue.height) > 0.5 + || abs(bounds.width - oldValue.width) > 0.5 else { return } + onGeometryChange?() + } + } + + override var contentSize: CGSize { + didSet { + guard abs(contentSize.height - oldValue.height) > 0.5 + || abs(contentSize.width - oldValue.width) > 0.5 else { return } + onGeometryChange?() + } + } + + override func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool { + if let panGestureRecognizer = gestureRecognizer as? UIPanGestureRecognizer, + panGestureRecognizer === self.panGestureRecognizer { + return shouldBeginVerticalPan?(panGestureRecognizer, self) ?? true + } + return super.gestureRecognizerShouldBegin(gestureRecognizer) + } +} + +private extension UIScrollView { + var verticalContentOffsetExcludingBounce: CGFloat { + let inset = adjustedContentInset + let minOffsetY = -inset.top + let maxOffsetY = max(minOffsetY, contentSize.height - bounds.height + inset.bottom) + return min(max(contentOffset.y, minOffsetY), maxOffsetY) + inset.top + } +} + +private extension CGRect { + func isApproximatelyEqual(to other: CGRect) -> Bool { + abs(origin.x - other.origin.x) <= 0.5 + && abs(origin.y - other.origin.y) <= 0.5 + && abs(size.width - other.size.width) <= 0.5 + && abs(size.height - other.size.height) <= 0.5 + } +} + +private struct VideoDetailTabPager: UIViewControllerRepresentable { + @Binding var selectedTab: VideoPageTab + let introduction: VideoDetailTabPage + let comments: VideoDetailTabPage + + init( + selectedTab: Binding, + introduction: VideoDetailTabPage, + comments: VideoDetailTabPage + ) { + _selectedTab = selectedTab + self.introduction = introduction + self.comments = comments + } + + func makeCoordinator() -> Coordinator { + Coordinator(selectedTab: $selectedTab) + } + + func makeUIViewController(context: Context) -> PagingViewController { + PagingViewController(coordinator: context.coordinator) + } + + func updateUIViewController(_ uiViewController: PagingViewController, context: Context) { + context.coordinator.selectedTab = $selectedTab + uiViewController.updatePages( + introduction: introduction, + comments: comments, + selectedIndex: selectedTab.pageIndex, + animated: context.coordinator.shouldAnimateProgrammaticSelection(to: selectedTab.pageIndex) + ) + } + + final class Coordinator: NSObject, UIScrollViewDelegate { + var selectedTab: Binding + var onSelectedIndexSettled: ((Int) -> Void)? + var onPagingActivityChanged: ((Bool) -> Void)? + private var lastProgrammaticIndex: Int? + private var pendingSettledIndex: Int? + + init(selectedTab: Binding) { + self.selectedTab = selectedTab + } + + func shouldAnimateProgrammaticSelection(to index: Int) -> Bool { + defer { lastProgrammaticIndex = index } + guard let lastProgrammaticIndex else { return false } + return lastProgrammaticIndex != index + } + + func scrollViewWillBeginDragging(_ scrollView: UIScrollView) { + onPagingActivityChanged?(true) + } + + func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) { + onPagingActivityChanged?(false) + updateSelectedTab(from: scrollView) + } + + func scrollViewDidEndScrollingAnimation(_ scrollView: UIScrollView) { + onPagingActivityChanged?(false) + updateSelectedTab(from: scrollView) + } + + func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) { + if !decelerate { + onPagingActivityChanged?(false) + updateSelectedTab(from: scrollView) + } + } + + func shouldBeginHorizontalPagingPan( + panGestureRecognizer: UIPanGestureRecognizer, + in view: UIView + ) -> Bool { + let startLocation = panGestureRecognizer.location(in: view) + guard startLocation.x > 24 else { return false } + guard !view.hasScrollableHorizontalDescendant(at: startLocation, excluding: view) else { + return false + } + + let translation = panGestureRecognizer.translation(in: view) + let velocity = panGestureRecognizer.velocity(in: view) + let horizontal = max(abs(translation.x), abs(velocity.x) * 0.05) + let vertical = max(abs(translation.y), abs(velocity.y) * 0.05) + return horizontal > 8 && horizontal > vertical * 1.18 + } + + private func updateSelectedTab(from scrollView: UIScrollView) { + let width = scrollView.bounds.width + guard width > 0 else { return } + let index = Int(round(scrollView.contentOffset.x / width)) + let tab = VideoPageTab.page(at: index) + if selectedTab.wrappedValue != tab { + pendingSettledIndex = index + selectedTab.wrappedValue = tab + } else { + onSelectedIndexSettled?(index) + } + } + + func consumePendingSettledIndex(for selectedIndex: Int) -> Bool { + guard pendingSettledIndex == selectedIndex else { return false } + pendingSettledIndex = nil + onSelectedIndexSettled?(selectedIndex) + return true + } + } + + final class PagingViewController: UIViewController { + private let coordinator: Coordinator + private let scrollView = PagingScrollView() + private let contentView = UIView() + private let introductionPage = VideoDetailVerticalScrollPageViewController(tab: .introduction) + private let commentsPage = VideoDetailVerticalScrollPageViewController(tab: .comments) + private var selectedIndex = 0 + private var lastSettledSelectedIndex: Int? + private var pendingSelectedIndex: Int? + private var lastLaidOutWidth: CGFloat = 0 + private var isHorizontalPagingActive = false + + init(coordinator: Coordinator) { + self.coordinator = coordinator + super.init(nibName: nil, bundle: nil) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + view.backgroundColor = .clear + + scrollView.translatesAutoresizingMaskIntoConstraints = false + scrollView.backgroundColor = .clear + scrollView.isPagingEnabled = true + scrollView.bounces = false + scrollView.alwaysBounceHorizontal = false + scrollView.alwaysBounceVertical = false + scrollView.showsHorizontalScrollIndicator = false + scrollView.showsVerticalScrollIndicator = false + scrollView.isDirectionalLockEnabled = true + scrollView.contentInsetAdjustmentBehavior = .never + scrollView.delegate = coordinator + coordinator.onSelectedIndexSettled = { [weak self] index in + self?.settlePageAfterHorizontalSelection(index) + } + scrollView.shouldBeginPagingPan = { [weak self] panGestureRecognizer, view in + self?.coordinator.shouldBeginHorizontalPagingPan( + panGestureRecognizer: panGestureRecognizer, + in: view + ) ?? true + } + coordinator.onPagingActivityChanged = { [weak self] isActive in + self?.setHorizontalPagingActive(isActive) + } + view.addSubview(scrollView) + + contentView.translatesAutoresizingMaskIntoConstraints = false + contentView.backgroundColor = .clear + scrollView.addSubview(contentView) + + addPage(introductionPage) + addPage(commentsPage) + + NSLayoutConstraint.activate([ + scrollView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + scrollView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + scrollView.topAnchor.constraint(equalTo: view.topAnchor), + scrollView.bottomAnchor.constraint(equalTo: view.bottomAnchor), + + contentView.leadingAnchor.constraint(equalTo: scrollView.contentLayoutGuide.leadingAnchor), + contentView.trailingAnchor.constraint(equalTo: scrollView.contentLayoutGuide.trailingAnchor), + contentView.topAnchor.constraint(equalTo: scrollView.contentLayoutGuide.topAnchor), + contentView.bottomAnchor.constraint(equalTo: scrollView.contentLayoutGuide.bottomAnchor), + contentView.heightAnchor.constraint(equalTo: scrollView.frameLayoutGuide.heightAnchor), + + introductionPage.view.leadingAnchor.constraint(equalTo: contentView.leadingAnchor), + introductionPage.view.topAnchor.constraint(equalTo: contentView.topAnchor), + introductionPage.view.bottomAnchor.constraint(equalTo: contentView.bottomAnchor), + introductionPage.view.widthAnchor.constraint(equalTo: scrollView.frameLayoutGuide.widthAnchor), + introductionPage.view.heightAnchor.constraint(equalTo: scrollView.frameLayoutGuide.heightAnchor), + + commentsPage.view.leadingAnchor.constraint(equalTo: introductionPage.view.trailingAnchor), + commentsPage.view.trailingAnchor.constraint(equalTo: contentView.trailingAnchor), + commentsPage.view.topAnchor.constraint(equalTo: contentView.topAnchor), + commentsPage.view.bottomAnchor.constraint(equalTo: contentView.bottomAnchor), + commentsPage.view.widthAnchor.constraint(equalTo: scrollView.frameLayoutGuide.widthAnchor), + commentsPage.view.heightAnchor.constraint(equalTo: scrollView.frameLayoutGuide.heightAnchor) + ]) + } + + override func viewDidLayoutSubviews() { + super.viewDidLayoutSubviews() + let width = scrollView.bounds.width + let widthChanged = abs(width - lastLaidOutWidth) > 0.5 + lastLaidOutWidth = width + if let pendingSelectedIndex { + self.pendingSelectedIndex = nil + setSelectedIndex(pendingSelectedIndex, animated: false) + } else if widthChanged { + setSelectedIndex(selectedIndex, animated: false) + } + } + + func updatePages( + introduction: VideoDetailTabPage, + comments: VideoDetailTabPage, + selectedIndex: Int, + animated: Bool + ) { + loadViewIfNeeded() + self.selectedIndex = min(max(selectedIndex, 0), VideoPageTab.allCases.count - 1) + introductionPage.update(page: introduction) + commentsPage.update(page: comments) + syncInactivePageHeaderOffset() + let consumedSettledIndex = coordinator.consumePendingSettledIndex(for: self.selectedIndex) + guard !scrollView.isTracking, !scrollView.isDragging, !scrollView.isDecelerating else { return } + setSelectedIndex(self.selectedIndex, animated: animated) + if !animated && !consumedSettledIndex { + settleSelectedPageIfNeeded(self.selectedIndex) + } + } + + private func addPage(_ page: UIViewController) { + addChild(page) + page.view.translatesAutoresizingMaskIntoConstraints = false + page.view.backgroundColor = .clear + contentView.addSubview(page.view) + page.didMove(toParent: self) + } + + private func setSelectedIndex(_ index: Int, animated: Bool) { + selectedIndex = min(max(index, 0), VideoPageTab.allCases.count - 1) + let width = scrollView.bounds.width + guard width > 0 else { + pendingSelectedIndex = selectedIndex + return + } + let targetOffset = CGPoint(x: CGFloat(selectedIndex) * width, y: 0) + guard abs(scrollView.contentOffset.x - targetOffset.x) > 0.5 else { return } + if animated { + setHorizontalPagingActive(true) + } + scrollView.setContentOffset(targetOffset, animated: animated) + } + + private func settleSelectedPageIfNeeded(_ index: Int) { + let clampedIndex = min(max(index, 0), VideoPageTab.allCases.count - 1) + guard lastSettledSelectedIndex != clampedIndex else { return } + lastSettledSelectedIndex = clampedIndex + settlePageAfterHorizontalSelection(clampedIndex) + } + + private func settlePageAfterHorizontalSelection(_ index: Int) { + lastSettledSelectedIndex = min(max(index, 0), VideoPageTab.allCases.count - 1) + syncInactivePageHeaderOffset() + switch VideoPageTab.page(at: index) { + case .introduction: + introductionPage.settleAfterHorizontalActivation() + case .comments: + commentsPage.settleAfterHorizontalActivation() + } + } + + private func setHorizontalPagingActive(_ isActive: Bool) { + guard isHorizontalPagingActive != isActive else { return } + isHorizontalPagingActive = isActive + introductionPage.setHorizontalPagingActive(isActive) + commentsPage.setHorizontalPagingActive(isActive) + if !isActive { + syncInactivePageHeaderOffset() + } + } + + private func syncInactivePageHeaderOffset() { + guard let activePage = verticalPageController(for: VideoPageTab.page(at: selectedIndex)) else { + return + } + let activeOffset = activePage.normalizedContentOffsetY + let syncOffset = activePage.headerSyncOffset(fromActiveOffset: activeOffset) + for tab in VideoPageTab.allCases where tab.pageIndex != selectedIndex { + verticalPageController(for: tab)?.syncHeaderOffsetFromActivePage(syncOffset) + } + } + + private func verticalPageController(for tab: VideoPageTab) -> VideoDetailVerticalScrollPageViewController? { + switch tab { + case .introduction: + return introductionPage + case .comments: + return commentsPage + } + } + } + + final class PagingScrollView: UIScrollView { + var shouldBeginPagingPan: ((UIPanGestureRecognizer, UIView) -> Bool)? + + override func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool { + if let panGestureRecognizer = gestureRecognizer as? UIPanGestureRecognizer, + panGestureRecognizer === self.panGestureRecognizer { + return shouldBeginPagingPan?(panGestureRecognizer, self) ?? true + } + return super.gestureRecognizerShouldBegin(gestureRecognizer) + } + } +} + +private extension UIView { + func hasScrollableHorizontalDescendant(at location: CGPoint, excluding excludedView: UIView) -> Bool { + guard let hitView = hitTest(location, with: nil) else { return false } + var current: UIView? = hitView + while let view = current, view !== excludedView { + if let scrollView = view as? UIScrollView, + scrollView.isScrollEnabled, + scrollView.panGestureRecognizer.isEnabled, + scrollView.contentSize.width > scrollView.bounds.width + 1, + scrollView.contentSize.width > scrollView.contentSize.height { + return true + } + current = view.superview + } + return false + } +} diff --git a/iosApp/VideoDetailView.swift b/iosApp/VideoDetailView.swift index 70b47d9c..83ea709a 100644 --- a/iosApp/VideoDetailView.swift +++ b/iosApp/VideoDetailView.swift @@ -710,1100 +710,6 @@ private enum CommentKeyboardTransparency { } } -private enum VideoPlayerCollapseModel { - static func nextCollapseOffset( - currentCollapseOffset: CGFloat, - activeTabOffset: CGFloat, - previousActiveTabOffset: CGFloat?, - collapseDistance: CGFloat - ) -> CGFloat { - let clampedCurrent = clamp(currentCollapseOffset, upperBound: collapseDistance) - - guard let previousActiveTabOffset else { - let nonnegativeActiveOffset = max(0, activeTabOffset) - return min(collapseDistance, max(clampedCurrent, nonnegativeActiveOffset)) - } - - let delta = activeTabOffset - previousActiveTabOffset - if abs(delta) > 0.5 { - return clamp(clampedCurrent + delta, upperBound: collapseDistance) - } - - return clampedCurrent - } - - private static func clamp(_ value: CGFloat, upperBound: CGFloat) -> CGFloat { - min(max(value, 0), max(upperBound, 0)) - } -} - -private struct VideoDetailPagerLayoutMetrics { - let collapseDistance: CGFloat - let playerScrollAway: CGFloat - let continuationProgress: CGFloat -} - -private struct VideoDetailPagerState: Equatable { - var selectedTab: VideoPageTab = .introduction - var collapseOffset: CGFloat = 0 - var tabOffsets: [VideoPageTab: CGFloat] = [:] - var isPlayerPlaying: Bool = false - - func layoutMetrics( - containerHeight _: CGFloat, - rawCollapseDistance: CGFloat - ) -> VideoDetailPagerLayoutMetrics { - let collapseDistance = isPlayerPlaying ? 0 : max(rawCollapseDistance, 0) - let playerScrollAway = isPlayerPlaying ? 0 : Self.clamp(collapseOffset, upperBound: collapseDistance) - let fadeDistance: CGFloat = 72 - let continuationProgress: CGFloat - if rawCollapseDistance > 1 { - continuationProgress = min( - max((playerScrollAway - (rawCollapseDistance - fadeDistance)) / fadeDistance, 0), - 1 - ) - } else { - continuationProgress = 0 - } - - return VideoDetailPagerLayoutMetrics( - collapseDistance: collapseDistance, - playerScrollAway: playerScrollAway, - continuationProgress: continuationProgress - ) - } - - mutating func setPlayerPlaying(_ isPlaying: Bool) { - isPlayerPlaying = isPlaying - if isPlaying { - collapseOffset = 0 - } - } - - mutating func expandPlayer() { - collapseOffset = 0 - } - - mutating func selectTab(_ tab: VideoPageTab, collapseDistance: CGFloat) { - selectedTab = tab - clampCollapse(to: collapseDistance) - } - - mutating func updateTabOffset(_ tab: VideoPageTab, offset: CGFloat, collapseDistance: CGFloat) { - guard tab == selectedTab else { return } - guard !isPlayerPlaying else { - collapseOffset = 0 - return - } - let trackedOffset = Self.clamp(offset, upperBound: collapseDistance) - let previousActiveOffset = tabOffsets[tab] - let previousCollapseOffset = collapseOffset - let nextCollapseOffset = nextCollapseOffset( - activeTabOffset: trackedOffset, - previousActiveTabOffset: previousActiveOffset, - collapseDistance: collapseDistance - ) - guard previousActiveOffset != trackedOffset - || abs(previousCollapseOffset - nextCollapseOffset) > 0.5 else { - return - } - tabOffsets[tab] = trackedOffset - collapseOffset = nextCollapseOffset - } - - private func nextCollapseOffset( - activeTabOffset: CGFloat, - previousActiveTabOffset: CGFloat?, - collapseDistance: CGFloat - ) -> CGFloat { - VideoPlayerCollapseModel.nextCollapseOffset( - currentCollapseOffset: collapseOffset, - activeTabOffset: activeTabOffset, - previousActiveTabOffset: previousActiveTabOffset, - collapseDistance: collapseDistance - ) - } - - mutating func beginInteracting(with tab: VideoPageTab, collapseDistance: CGFloat) { - guard selectedTab != tab else { return } - selectTab(tab, collapseDistance: collapseDistance) - } - - mutating func handleTopPull(tab: VideoPageTab, delta: CGFloat, collapseDistance: CGFloat) { - guard tab == selectedTab, !isPlayerPlaying, delta > 0, collapseOffset > 0 else { return } - collapseOffset = Self.clamp(collapseOffset - delta, upperBound: collapseDistance) - } - - mutating func clampCollapse(to collapseDistance: CGFloat) { - collapseOffset = Self.clamp(collapseOffset, upperBound: collapseDistance) - } - - private static func clamp(_ value: CGFloat, upperBound: CGFloat) -> CGFloat { - min(max(value, 0), max(upperBound, 0)) - } -} - -private enum VideoPageTab: String, CaseIterable, Identifiable { - case introduction - case comments - - var id: String { rawValue } - - var pageIndex: Int { - switch self { - case .introduction: return 0 - case .comments: return 1 - } - } - - static func page(at index: Int) -> VideoPageTab { - index <= 0 ? .introduction : .comments - } - - var scrollCoordinateSpaceName: String { - "bottomScroll-\(rawValue)" - } - - var title: String { - switch self { - case .introduction: - return String(localized: "简介") - case .comments: - return String(localized: "评论") - } - } -} - -private struct VideoDetailTabContentRevision: Equatable { - let introduction: Int - let comments: Int -} - -private struct VideoDetailSmoothHeaderGeometry: Equatable { - let headerHeight: CGFloat - let pinHeaderHeight: CGFloat - let collapseDistance: CGFloat - let visualTopOffset: CGFloat - let pinnedVisibleHeight: CGFloat - - var contentTopInset: CGFloat { - max(headerHeight, 0) + max(pinHeaderHeight, 0) - } - - var resolvedVisualTopOffset: CGFloat { - min(max(visualTopOffset, 0), max(collapseDistance, 0)) - } - - var collapseSpacerHeight: CGFloat { - max(collapseDistance - resolvedVisualTopOffset + 1, 1) - } - - func minimumContentHeight(in scrollBoundsHeight: CGFloat) -> CGFloat { - max( - scrollBoundsHeight - max(pinnedVisibleHeight, 0), - max(collapseDistance, 0) + 1, - 1 - ) - } - - func rawContentOffsetY(forNormalizedOffsetY offsetY: CGFloat, in scrollView: UIScrollView) -> CGFloat { - let inset = scrollView.adjustedContentInset - let minOffsetY = -inset.top - let maxOffsetY = max(minOffsetY, scrollView.contentSize.height - scrollView.bounds.height + inset.bottom) - let rawOffsetY = offsetY - inset.top - return min(max(rawOffsetY, minOffsetY), maxOffsetY) - } - - func normalizedContentOffsetY(forRawOffsetY rawOffsetY: CGFloat, in scrollView: UIScrollView) -> CGFloat { - rawOffsetY + scrollView.adjustedContentInset.top - } - - func syncOffset(fromActiveOffset activeOffset: CGFloat) -> CGFloat { - min(max(activeOffset, 0), resolvedVisualTopOffset) - } -} - -private struct VideoDetailTabPage { - let tab: VideoPageTab - let contentBottomPadding: CGFloat - let isSelected: Bool - let headerGeometry: VideoDetailSmoothHeaderGeometry - let contentUpdateRevision: Int - let onOffsetChange: (VideoPageTab, CGFloat) -> Void - let onInteractionBegan: (VideoPageTab) -> Void - let onTopPullDelta: (VideoPageTab, CGFloat) -> Void - let content: () -> AnyView - - init( - tab: VideoPageTab, - contentBottomPadding: CGFloat, - isSelected: Bool, - headerGeometry: VideoDetailSmoothHeaderGeometry, - contentUpdateRevision: Int, - onOffsetChange: @escaping (VideoPageTab, CGFloat) -> Void, - onInteractionBegan: @escaping (VideoPageTab) -> Void, - onTopPullDelta: @escaping (VideoPageTab, CGFloat) -> Void, - @ViewBuilder content: @escaping () -> Content - ) { - self.tab = tab - self.contentBottomPadding = contentBottomPadding - self.isSelected = isSelected - self.headerGeometry = headerGeometry - self.contentUpdateRevision = contentUpdateRevision - self.onOffsetChange = onOffsetChange - self.onInteractionBegan = onInteractionBegan - self.onTopPullDelta = onTopPullDelta - self.content = { AnyView(content()) } - } -} - -private struct VideoDetailPagerContainer: View { - @Binding var state: VideoDetailPagerState - let collapseDistance: CGFloat - let headerHeight: CGFloat - let pinHeaderHeight: CGFloat - let pinnedVisibleHeight: CGFloat - let playerScrollAway: CGFloat - let introductionContentBottomPadding: CGFloat - let commentsContentBottomPadding: CGFloat - let introductionContentRevision: Int - let commentsContentRevision: Int - let introduction: () -> Introduction - let comments: () -> Comments - - private var selectedTabBinding: Binding { - Binding( - get: { state.selectedTab }, - set: { newTab in - mutateState { $0.selectTab(newTab, collapseDistance: collapseDistance) } - } - ) - } - - var body: some View { - ZStack(alignment: .top) { - VideoDetailTabPager( - selectedTab: selectedTabBinding, - introduction: tabPage( - .introduction, - contentBottomPadding: introductionContentBottomPadding, - contentUpdateRevision: introductionContentRevision, - content: introduction - ), - comments: tabPage( - .comments, - contentBottomPadding: commentsContentBottomPadding, - contentUpdateRevision: commentsContentRevision, - content: comments - ) - ) - .frame(maxHeight: .infinity) - - Picker("Content", selection: selectedTabBinding) { - ForEach(VideoPageTab.allCases) { tab in - Text(tab.title).tag(tab) - } - } - .pickerStyle(.segmented) - .padding(.horizontal, 16) - .frame(height: pinHeaderHeight) - .frame(maxWidth: .infinity) - .background(.background) - .offset(y: max(headerHeight - playerScrollAway, pinnedVisibleHeight - pinHeaderHeight)) - .zIndex(1) - } - .frame(maxHeight: .infinity) - .background(Color(.systemGroupedBackground)) - .onValueChange(of: collapseDistance) { newValue in - mutateState { $0.clampCollapse(to: newValue) } - } - } - - private func tabPage( - _ tab: VideoPageTab, - contentBottomPadding: CGFloat, - contentUpdateRevision: Int, - @ViewBuilder content: @escaping () -> Content - ) -> VideoDetailTabPage { - VideoDetailTabPage( - tab: tab, - contentBottomPadding: contentBottomPadding, - isSelected: state.selectedTab == tab, - headerGeometry: VideoDetailSmoothHeaderGeometry( - headerHeight: headerHeight, - pinHeaderHeight: pinHeaderHeight, - collapseDistance: collapseDistance, - visualTopOffset: playerScrollAway, - pinnedVisibleHeight: pinnedVisibleHeight - ), - contentUpdateRevision: contentUpdateRevision, - onOffsetChange: { tab, offset in - mutateState { - $0.updateTabOffset(tab, offset: offset, collapseDistance: collapseDistance) - } - }, - onInteractionBegan: { tab in - mutateState { - $0.beginInteracting(with: tab, collapseDistance: collapseDistance) - } - }, - onTopPullDelta: { tab, delta in - mutateState { - $0.handleTopPull(tab: tab, delta: delta, collapseDistance: collapseDistance) - } - }, - content: content - ) - } - - private func mutateState(_ mutation: (inout VideoDetailPagerState) -> Void) { - withTransaction(Transaction(animation: nil)) { - var nextState = state - mutation(&nextState) - if nextState != state { - state = nextState - } - } - } -} - -private final class VideoDetailVerticalScrollPageCoordinator: NSObject, UIScrollViewDelegate { - var tab: VideoPageTab - var onOffsetChange: (VideoPageTab, CGFloat) -> Void - var onInteractionBegan: (VideoPageTab) -> Void - var onTopPullDelta: (VideoPageTab, CGFloat) -> Void - var onVerticalInteractionBegan: () -> Void = {} - var visualTopContentOffsetY: CGFloat = 0 - var isApplyingExternalOffset = false - var isHorizontalPagingActive = false - private var lastReportedOffset: CGFloat? - private var lastTopPullTranslationY: CGFloat = 0 - - init( - tab: VideoPageTab, - onOffsetChange: @escaping (VideoPageTab, CGFloat) -> Void, - onInteractionBegan: @escaping (VideoPageTab) -> Void, - onTopPullDelta: @escaping (VideoPageTab, CGFloat) -> Void - ) { - self.tab = tab - self.onOffsetChange = onOffsetChange - self.onInteractionBegan = onInteractionBegan - self.onTopPullDelta = onTopPullDelta - } - - func scrollViewDidScroll(_ scrollView: UIScrollView) { - guard !isApplyingExternalOffset, !isHorizontalPagingActive else { return } - let offset = scrollView.verticalContentOffsetExcludingBounce - guard lastReportedOffset.map({ abs($0 - offset) > 0.5 }) ?? true else { return } - lastReportedOffset = offset - onOffsetChange(tab, offset) - } - - func resetReportedOffset(_ offset: CGFloat) { - lastReportedOffset = offset - } - - @objc func handlePan(_ panGestureRecognizer: UIPanGestureRecognizer) { - guard let scrollView = panGestureRecognizer.view as? UIScrollView else { return } - switch panGestureRecognizer.state { - case .began: - onVerticalInteractionBegan() - onInteractionBegan(tab) - lastTopPullTranslationY = 0 - case .changed: - guard scrollView.verticalContentOffsetExcludingBounce <= visualTopContentOffsetY + 0.5 else { - lastTopPullTranslationY = panGestureRecognizer.translation(in: scrollView).y - return - } - let translationY = panGestureRecognizer.translation(in: scrollView).y - let delta = translationY - lastTopPullTranslationY - lastTopPullTranslationY = translationY - if delta > 0 { - onTopPullDelta(tab, delta) - } - default: - lastTopPullTranslationY = 0 - } - } -} - -private final class VideoDetailVerticalScrollPageViewController: UIViewController { - private let coordinator: VideoDetailVerticalScrollPageCoordinator - private let scrollView = VerticalScrollView() - private let contentView = UIView() - private let listHeaderView = UIView() - private let collapseSpacerView = UIView() - private let host = UIHostingController(rootView: AnyView(EmptyView())) - private var hostMinimumHeightConstraint: NSLayoutConstraint! - private var contentMinimumHeightConstraint: NSLayoutConstraint! - private var collapseSpacerHeightConstraint: NSLayoutConstraint! - private var contentUpdateRevision: Int? - private var lastAppliedPage: VideoDetailTabPage? - private var topAlignmentGeneration = 0 - private var pendingTopAlignmentTargetOffsetY: CGFloat? - private var needsInitialHeaderOffsetReset = true - - init(tab: VideoPageTab) { - coordinator = VideoDetailVerticalScrollPageCoordinator( - tab: tab, - onOffsetChange: { _, _ in }, - onInteractionBegan: { _ in }, - onTopPullDelta: { _, _ in } - ) - super.init(nibName: nil, bundle: nil) - } - - @available(*, unavailable) - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - override func viewDidLoad() { - super.viewDidLoad() - view.backgroundColor = .clear - - scrollView.translatesAutoresizingMaskIntoConstraints = false - scrollView.backgroundColor = .clear - scrollView.bounces = true - scrollView.alwaysBounceVertical = true - scrollView.alwaysBounceHorizontal = false - scrollView.showsHorizontalScrollIndicator = false - scrollView.showsVerticalScrollIndicator = false - scrollView.isDirectionalLockEnabled = true - scrollView.contentInsetAdjustmentBehavior = .never - scrollView.keyboardDismissMode = .interactive - scrollView.delegate = coordinator - scrollView.panGestureRecognizer.addTarget(coordinator, action: #selector(VideoDetailVerticalScrollPageCoordinator.handlePan(_:))) - scrollView.onGeometryChange = { [weak self] in - self?.applyCurrentPageGeometryRules() - self?.resolvePendingTopAlignmentIfPossible() - } - scrollView.shouldBeginVerticalPan = { [weak self] panGestureRecognizer, view in - self?.resolvePendingTopAlignmentIfPossible(allowDuringInteraction: true) - let velocity = panGestureRecognizer.velocity(in: view) - return abs(velocity.x) <= abs(velocity.y) * 1.05 - } - view.addSubview(scrollView) - - contentView.translatesAutoresizingMaskIntoConstraints = false - contentView.backgroundColor = .clear - scrollView.addSubview(contentView) - - listHeaderView.backgroundColor = .clear - listHeaderView.isUserInteractionEnabled = false - scrollView.addSubview(listHeaderView) - - addChild(host) - host.view.translatesAutoresizingMaskIntoConstraints = false - host.view.backgroundColor = .clear - contentView.addSubview(host.view) - host.didMove(toParent: self) - - collapseSpacerView.translatesAutoresizingMaskIntoConstraints = false - collapseSpacerView.backgroundColor = .clear - contentView.addSubview(collapseSpacerView) - - hostMinimumHeightConstraint = host.view.heightAnchor.constraint(greaterThanOrEqualTo: scrollView.frameLayoutGuide.heightAnchor) - contentMinimumHeightConstraint = contentView.heightAnchor.constraint( - greaterThanOrEqualTo: scrollView.frameLayoutGuide.heightAnchor, - constant: 1 - ) - collapseSpacerHeightConstraint = collapseSpacerView.heightAnchor.constraint(equalToConstant: 1) - - NSLayoutConstraint.activate([ - scrollView.leadingAnchor.constraint(equalTo: view.leadingAnchor), - scrollView.trailingAnchor.constraint(equalTo: view.trailingAnchor), - scrollView.topAnchor.constraint(equalTo: view.topAnchor), - scrollView.bottomAnchor.constraint(equalTo: view.bottomAnchor), - - contentView.leadingAnchor.constraint(equalTo: scrollView.contentLayoutGuide.leadingAnchor), - contentView.trailingAnchor.constraint(equalTo: scrollView.contentLayoutGuide.trailingAnchor), - contentView.topAnchor.constraint(equalTo: scrollView.contentLayoutGuide.topAnchor), - contentView.bottomAnchor.constraint(equalTo: scrollView.contentLayoutGuide.bottomAnchor), - contentView.widthAnchor.constraint(equalTo: scrollView.frameLayoutGuide.widthAnchor), - contentMinimumHeightConstraint, - - host.view.leadingAnchor.constraint(equalTo: contentView.leadingAnchor), - host.view.trailingAnchor.constraint(equalTo: contentView.trailingAnchor), - host.view.topAnchor.constraint(equalTo: contentView.topAnchor), - hostMinimumHeightConstraint, - - collapseSpacerView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor), - collapseSpacerView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor), - collapseSpacerView.topAnchor.constraint(equalTo: host.view.bottomAnchor), - collapseSpacerView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor), - collapseSpacerHeightConstraint - ]) - } - - override func viewDidLayoutSubviews() { - super.viewDidLayoutSubviews() - applyListHeaderFrame() - applyCurrentPageGeometryRules() - resolvePendingTopAlignmentIfPossible() - } - - func update(page: VideoDetailTabPage) { - loadViewIfNeeded() - coordinator.tab = page.tab - coordinator.onOffsetChange = page.onOffsetChange - coordinator.onInteractionBegan = page.onInteractionBegan - coordinator.onTopPullDelta = page.onTopPullDelta - coordinator.onVerticalInteractionBegan = { [weak self] in - self?.resolvePendingTopAlignmentIfPossible(allowDuringInteraction: true) - } - if !page.isSelected { - cancelPendingTopAlignment() - } - if contentUpdateRevision != page.contentUpdateRevision { - contentUpdateRevision = page.contentUpdateRevision - host.rootView = page.content() - needsInitialHeaderOffsetReset = true - } - - let previousVisualTopContentOffsetY = coordinator.visualTopContentOffsetY - let geometry = page.headerGeometry - let visualTopOffset = geometry.resolvedVisualTopOffset - lastAppliedPage = page - coordinator.visualTopContentOffsetY = visualTopOffset - applyTopContentInset(geometry.contentTopInset) - applyListHeaderFrame() - applyBottomContentInset(page.contentBottomPadding) - collapseSpacerHeightConstraint.constant = geometry.collapseSpacerHeight - contentMinimumHeightConstraint.constant = geometry.minimumContentHeight(in: scrollView.bounds.height) - if page.isSelected, needsInitialHeaderOffsetReset { - requestTopAlignment(targetOffsetY: visualTopOffset) - } else if pendingTopAlignmentTargetOffsetY != nil { - pendingTopAlignmentTargetOffsetY = visualTopOffset - resolvePendingTopAlignmentSoon() - } else { - preserveVisualTopIfNeeded( - previousOffsetY: previousVisualTopContentOffsetY, - targetOffsetY: visualTopOffset - ) - } - } - - private func applyCurrentPageGeometryRules() { - guard let page = lastAppliedPage else { return } - let geometry = page.headerGeometry - let minimumContentHeight = geometry.minimumContentHeight(in: scrollView.bounds.height) - if abs(contentMinimumHeightConstraint.constant - minimumContentHeight) > 0.5 { - contentMinimumHeightConstraint.constant = minimumContentHeight - } - guard page.isSelected else { return } - let visualTopOffset = geometry.resolvedVisualTopOffset - if pendingTopAlignmentTargetOffsetY != nil { - pendingTopAlignmentTargetOffsetY = visualTopOffset - return - } - if needsInitialHeaderOffsetReset { - requestTopAlignment(targetOffsetY: visualTopOffset) - return - } - guard scrollView.verticalContentOffsetExcludingBounce <= visualTopOffset + 8 else { return } - requestTopAlignment(targetOffsetY: visualTopOffset) - } - - private func applyTopContentInset(_ topInset: CGFloat) { - let resolvedTopInset = max(topInset, 0) - guard abs(scrollView.contentInset.top - resolvedTopInset) > 0.5 else { return } - scrollView.contentInset.top = resolvedTopInset - scrollView.verticalScrollIndicatorInsets.top = resolvedTopInset - applyListHeaderFrame() - } - - private func applyBottomContentInset(_ bottomInset: CGFloat) { - let resolvedBottomInset = max(bottomInset, 0) - guard abs(scrollView.contentInset.bottom - resolvedBottomInset) > 0.5 else { return } - scrollView.contentInset.bottom = resolvedBottomInset - scrollView.verticalScrollIndicatorInsets.bottom = resolvedBottomInset - } - - private func applyListHeaderFrame() { - let topInset = max(scrollView.contentInset.top, 0) - let nextFrame = CGRect( - x: 0, - y: -topInset, - width: scrollView.bounds.width, - height: topInset - ) - guard !listHeaderView.frame.isApproximatelyEqual(to: nextFrame) else { return } - listHeaderView.frame = nextFrame - } - - private func requestTopAlignment(targetOffsetY: CGFloat) { - topAlignmentGeneration &+= 1 - pendingTopAlignmentTargetOffsetY = targetOffsetY - resolvePendingTopAlignmentSoon() - } - - private func resolvePendingTopAlignmentSoon() { - let generation = topAlignmentGeneration - resolvePendingTopAlignmentIfPossible() - DispatchQueue.main.async { [weak self] in - guard let self, self.topAlignmentGeneration == generation else { return } - self.resolvePendingTopAlignmentIfPossible() - } - } - - private func resolvePendingTopAlignmentIfPossible(allowDuringInteraction: Bool = false) { - guard let targetOffsetY = pendingTopAlignmentTargetOffsetY else { return } - if !allowDuringInteraction { - guard !scrollView.isTracking, !scrollView.isDragging, !scrollView.isDecelerating else { return } - } - let rawTopOffsetY = clampedRawContentOffsetY(forNormalizedOffsetY: targetOffsetY) - guard abs(normalizedContentOffsetY(forRawOffsetY: rawTopOffsetY) - targetOffsetY) <= 0.5 else { return } - setRawContentOffsetYIfNeeded(rawTopOffsetY) - if abs(scrollView.verticalContentOffsetExcludingBounce - targetOffsetY) <= 0.5 { - needsInitialHeaderOffsetReset = false - cancelPendingTopAlignment() - } - } - - private func cancelPendingTopAlignment() { - pendingTopAlignmentTargetOffsetY = nil - } - - func settleAfterHorizontalActivation() { - loadViewIfNeeded() - let targetOffsetY = coordinator.visualTopContentOffsetY - guard needsInitialHeaderOffsetReset - || scrollView.verticalContentOffsetExcludingBounce <= targetOffsetY + 8 else { return } - requestTopAlignment(targetOffsetY: targetOffsetY) - resolvePendingTopAlignmentIfPossible(allowDuringInteraction: true) - } - - var normalizedContentOffsetY: CGFloat { - loadViewIfNeeded() - return scrollView.verticalContentOffsetExcludingBounce - } - - func headerSyncOffset(fromActiveOffset activeOffset: CGFloat) -> CGFloat { - lastAppliedPage?.headerGeometry.syncOffset(fromActiveOffset: activeOffset) ?? 0 - } - - func syncHeaderOffsetFromActivePage(_ offsetY: CGFloat) { - loadViewIfNeeded() - guard let page = lastAppliedPage else { return } - cancelPendingTopAlignment() - guard !needsInitialHeaderOffsetReset else { return } - setNormalizedContentOffsetY(page.headerGeometry.syncOffset(fromActiveOffset: offsetY)) - } - - func setHorizontalPagingActive(_ isActive: Bool) { - coordinator.isHorizontalPagingActive = isActive - if !isActive { - coordinator.resetReportedOffset(scrollView.verticalContentOffsetExcludingBounce) - } - } - - private func preserveVisualTopIfNeeded(previousOffsetY: CGFloat, targetOffsetY: CGFloat) { - guard abs(previousOffsetY - targetOffsetY) > 0.5 else { return } - guard abs(scrollView.verticalContentOffsetExcludingBounce - previousOffsetY) <= 1 else { return } - setNormalizedContentOffsetY(targetOffsetY) - } - - private func setNormalizedContentOffsetY(_ offsetY: CGFloat) { - guard let page = lastAppliedPage else { return } - let rawTopOffsetY = page.headerGeometry.rawContentOffsetY(forNormalizedOffsetY: offsetY, in: scrollView) - setRawContentOffsetYIfNeeded(rawTopOffsetY) - } - - private func setRawContentOffsetYIfNeeded(_ rawTopOffsetY: CGFloat) { - guard abs(scrollView.contentOffset.y - rawTopOffsetY) > 0.5 else { return } - coordinator.isApplyingExternalOffset = true - defer { coordinator.isApplyingExternalOffset = false } - scrollView.setContentOffset(CGPoint(x: scrollView.contentOffset.x, y: rawTopOffsetY), animated: false) - coordinator.resetReportedOffset(scrollView.verticalContentOffsetExcludingBounce) - } - - private func clampedRawContentOffsetY(forNormalizedOffsetY offsetY: CGFloat) -> CGFloat { - guard let page = lastAppliedPage else { return 0 } - return page.headerGeometry.rawContentOffsetY(forNormalizedOffsetY: offsetY, in: scrollView) - } - - private func normalizedContentOffsetY(forRawOffsetY rawOffsetY: CGFloat) -> CGFloat { - guard let page = lastAppliedPage else { return rawOffsetY + scrollView.adjustedContentInset.top } - return page.headerGeometry.normalizedContentOffsetY(forRawOffsetY: rawOffsetY, in: scrollView) - } -} - -private final class VerticalScrollView: UIScrollView { - var shouldBeginVerticalPan: ((UIPanGestureRecognizer, UIView) -> Bool)? - var onGeometryChange: (() -> Void)? - - override var bounds: CGRect { - didSet { - guard abs(bounds.height - oldValue.height) > 0.5 - || abs(bounds.width - oldValue.width) > 0.5 else { return } - onGeometryChange?() - } - } - - override var contentSize: CGSize { - didSet { - guard abs(contentSize.height - oldValue.height) > 0.5 - || abs(contentSize.width - oldValue.width) > 0.5 else { return } - onGeometryChange?() - } - } - - override func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool { - if let panGestureRecognizer = gestureRecognizer as? UIPanGestureRecognizer, - panGestureRecognizer === self.panGestureRecognizer { - return shouldBeginVerticalPan?(panGestureRecognizer, self) ?? true - } - return super.gestureRecognizerShouldBegin(gestureRecognizer) - } -} - -private extension UIScrollView { - var verticalContentOffsetExcludingBounce: CGFloat { - let inset = adjustedContentInset - let minOffsetY = -inset.top - let maxOffsetY = max(minOffsetY, contentSize.height - bounds.height + inset.bottom) - return min(max(contentOffset.y, minOffsetY), maxOffsetY) + inset.top - } -} - -private extension CGRect { - func isApproximatelyEqual(to other: CGRect) -> Bool { - abs(origin.x - other.origin.x) <= 0.5 - && abs(origin.y - other.origin.y) <= 0.5 - && abs(size.width - other.size.width) <= 0.5 - && abs(size.height - other.size.height) <= 0.5 - } -} - -private struct VideoDetailTabPager: UIViewControllerRepresentable { - @Binding var selectedTab: VideoPageTab - let introduction: VideoDetailTabPage - let comments: VideoDetailTabPage - - init( - selectedTab: Binding, - introduction: VideoDetailTabPage, - comments: VideoDetailTabPage - ) { - _selectedTab = selectedTab - self.introduction = introduction - self.comments = comments - } - - func makeCoordinator() -> Coordinator { - Coordinator(selectedTab: $selectedTab) - } - - func makeUIViewController(context: Context) -> PagingViewController { - PagingViewController(coordinator: context.coordinator) - } - - func updateUIViewController(_ uiViewController: PagingViewController, context: Context) { - context.coordinator.selectedTab = $selectedTab - uiViewController.updatePages( - introduction: introduction, - comments: comments, - selectedIndex: selectedTab.pageIndex, - animated: context.coordinator.shouldAnimateProgrammaticSelection(to: selectedTab.pageIndex) - ) - } - - final class Coordinator: NSObject, UIScrollViewDelegate { - var selectedTab: Binding - var onSelectedIndexSettled: ((Int) -> Void)? - var onPagingActivityChanged: ((Bool) -> Void)? - private var lastProgrammaticIndex: Int? - private var pendingSettledIndex: Int? - - init(selectedTab: Binding) { - self.selectedTab = selectedTab - } - - func shouldAnimateProgrammaticSelection(to index: Int) -> Bool { - defer { lastProgrammaticIndex = index } - guard let lastProgrammaticIndex else { return false } - return lastProgrammaticIndex != index - } - - func scrollViewWillBeginDragging(_ scrollView: UIScrollView) { - onPagingActivityChanged?(true) - } - - func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) { - onPagingActivityChanged?(false) - updateSelectedTab(from: scrollView) - } - - func scrollViewDidEndScrollingAnimation(_ scrollView: UIScrollView) { - onPagingActivityChanged?(false) - updateSelectedTab(from: scrollView) - } - - func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) { - if !decelerate { - onPagingActivityChanged?(false) - updateSelectedTab(from: scrollView) - } - } - - func shouldBeginHorizontalPagingPan( - panGestureRecognizer: UIPanGestureRecognizer, - in view: UIView - ) -> Bool { - let startLocation = panGestureRecognizer.location(in: view) - guard startLocation.x > 24 else { return false } - guard !view.hasScrollableHorizontalDescendant(at: startLocation, excluding: view) else { - return false - } - - let translation = panGestureRecognizer.translation(in: view) - let velocity = panGestureRecognizer.velocity(in: view) - let horizontal = max(abs(translation.x), abs(velocity.x) * 0.05) - let vertical = max(abs(translation.y), abs(velocity.y) * 0.05) - return horizontal > 8 && horizontal > vertical * 1.18 - } - - private func updateSelectedTab(from scrollView: UIScrollView) { - let width = scrollView.bounds.width - guard width > 0 else { return } - let index = Int(round(scrollView.contentOffset.x / width)) - let tab = VideoPageTab.page(at: index) - if selectedTab.wrappedValue != tab { - pendingSettledIndex = index - selectedTab.wrappedValue = tab - } else { - onSelectedIndexSettled?(index) - } - } - - func consumePendingSettledIndex(for selectedIndex: Int) -> Bool { - guard pendingSettledIndex == selectedIndex else { return false } - pendingSettledIndex = nil - onSelectedIndexSettled?(selectedIndex) - return true - } - } - - final class PagingViewController: UIViewController { - private let coordinator: Coordinator - private let scrollView = PagingScrollView() - private let contentView = UIView() - private let introductionPage = VideoDetailVerticalScrollPageViewController(tab: .introduction) - private let commentsPage = VideoDetailVerticalScrollPageViewController(tab: .comments) - private var selectedIndex = 0 - private var lastSettledSelectedIndex: Int? - private var pendingSelectedIndex: Int? - private var lastLaidOutWidth: CGFloat = 0 - private var isHorizontalPagingActive = false - - init(coordinator: Coordinator) { - self.coordinator = coordinator - super.init(nibName: nil, bundle: nil) - } - - @available(*, unavailable) - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - override func viewDidLoad() { - super.viewDidLoad() - view.backgroundColor = .clear - - scrollView.translatesAutoresizingMaskIntoConstraints = false - scrollView.backgroundColor = .clear - scrollView.isPagingEnabled = true - scrollView.bounces = false - scrollView.alwaysBounceHorizontal = false - scrollView.alwaysBounceVertical = false - scrollView.showsHorizontalScrollIndicator = false - scrollView.showsVerticalScrollIndicator = false - scrollView.isDirectionalLockEnabled = true - scrollView.contentInsetAdjustmentBehavior = .never - scrollView.delegate = coordinator - coordinator.onSelectedIndexSettled = { [weak self] index in - self?.settlePageAfterHorizontalSelection(index) - } - scrollView.shouldBeginPagingPan = { [weak self] panGestureRecognizer, view in - self?.coordinator.shouldBeginHorizontalPagingPan( - panGestureRecognizer: panGestureRecognizer, - in: view - ) ?? true - } - coordinator.onPagingActivityChanged = { [weak self] isActive in - self?.setHorizontalPagingActive(isActive) - } - view.addSubview(scrollView) - - contentView.translatesAutoresizingMaskIntoConstraints = false - contentView.backgroundColor = .clear - scrollView.addSubview(contentView) - - addPage(introductionPage) - addPage(commentsPage) - - NSLayoutConstraint.activate([ - scrollView.leadingAnchor.constraint(equalTo: view.leadingAnchor), - scrollView.trailingAnchor.constraint(equalTo: view.trailingAnchor), - scrollView.topAnchor.constraint(equalTo: view.topAnchor), - scrollView.bottomAnchor.constraint(equalTo: view.bottomAnchor), - - contentView.leadingAnchor.constraint(equalTo: scrollView.contentLayoutGuide.leadingAnchor), - contentView.trailingAnchor.constraint(equalTo: scrollView.contentLayoutGuide.trailingAnchor), - contentView.topAnchor.constraint(equalTo: scrollView.contentLayoutGuide.topAnchor), - contentView.bottomAnchor.constraint(equalTo: scrollView.contentLayoutGuide.bottomAnchor), - contentView.heightAnchor.constraint(equalTo: scrollView.frameLayoutGuide.heightAnchor), - - introductionPage.view.leadingAnchor.constraint(equalTo: contentView.leadingAnchor), - introductionPage.view.topAnchor.constraint(equalTo: contentView.topAnchor), - introductionPage.view.bottomAnchor.constraint(equalTo: contentView.bottomAnchor), - introductionPage.view.widthAnchor.constraint(equalTo: scrollView.frameLayoutGuide.widthAnchor), - introductionPage.view.heightAnchor.constraint(equalTo: scrollView.frameLayoutGuide.heightAnchor), - - commentsPage.view.leadingAnchor.constraint(equalTo: introductionPage.view.trailingAnchor), - commentsPage.view.trailingAnchor.constraint(equalTo: contentView.trailingAnchor), - commentsPage.view.topAnchor.constraint(equalTo: contentView.topAnchor), - commentsPage.view.bottomAnchor.constraint(equalTo: contentView.bottomAnchor), - commentsPage.view.widthAnchor.constraint(equalTo: scrollView.frameLayoutGuide.widthAnchor), - commentsPage.view.heightAnchor.constraint(equalTo: scrollView.frameLayoutGuide.heightAnchor) - ]) - } - - override func viewDidLayoutSubviews() { - super.viewDidLayoutSubviews() - let width = scrollView.bounds.width - let widthChanged = abs(width - lastLaidOutWidth) > 0.5 - lastLaidOutWidth = width - if let pendingSelectedIndex { - self.pendingSelectedIndex = nil - setSelectedIndex(pendingSelectedIndex, animated: false) - } else if widthChanged { - setSelectedIndex(selectedIndex, animated: false) - } - } - - func updatePages( - introduction: VideoDetailTabPage, - comments: VideoDetailTabPage, - selectedIndex: Int, - animated: Bool - ) { - loadViewIfNeeded() - self.selectedIndex = min(max(selectedIndex, 0), VideoPageTab.allCases.count - 1) - introductionPage.update(page: introduction) - commentsPage.update(page: comments) - syncInactivePageHeaderOffset() - let consumedSettledIndex = coordinator.consumePendingSettledIndex(for: self.selectedIndex) - guard !scrollView.isTracking, !scrollView.isDragging, !scrollView.isDecelerating else { return } - setSelectedIndex(self.selectedIndex, animated: animated) - if !animated && !consumedSettledIndex { - settleSelectedPageIfNeeded(self.selectedIndex) - } - } - - private func addPage(_ page: UIViewController) { - addChild(page) - page.view.translatesAutoresizingMaskIntoConstraints = false - page.view.backgroundColor = .clear - contentView.addSubview(page.view) - page.didMove(toParent: self) - } - - private func setSelectedIndex(_ index: Int, animated: Bool) { - selectedIndex = min(max(index, 0), VideoPageTab.allCases.count - 1) - let width = scrollView.bounds.width - guard width > 0 else { - pendingSelectedIndex = selectedIndex - return - } - let targetOffset = CGPoint(x: CGFloat(selectedIndex) * width, y: 0) - guard abs(scrollView.contentOffset.x - targetOffset.x) > 0.5 else { return } - if animated { - setHorizontalPagingActive(true) - } - scrollView.setContentOffset(targetOffset, animated: animated) - } - - private func settleSelectedPageIfNeeded(_ index: Int) { - let clampedIndex = min(max(index, 0), VideoPageTab.allCases.count - 1) - guard lastSettledSelectedIndex != clampedIndex else { return } - lastSettledSelectedIndex = clampedIndex - settlePageAfterHorizontalSelection(clampedIndex) - } - - private func settlePageAfterHorizontalSelection(_ index: Int) { - lastSettledSelectedIndex = min(max(index, 0), VideoPageTab.allCases.count - 1) - syncInactivePageHeaderOffset() - switch VideoPageTab.page(at: index) { - case .introduction: - introductionPage.settleAfterHorizontalActivation() - case .comments: - commentsPage.settleAfterHorizontalActivation() - } - } - - private func setHorizontalPagingActive(_ isActive: Bool) { - guard isHorizontalPagingActive != isActive else { return } - isHorizontalPagingActive = isActive - introductionPage.setHorizontalPagingActive(isActive) - commentsPage.setHorizontalPagingActive(isActive) - if !isActive { - syncInactivePageHeaderOffset() - } - } - - private func syncInactivePageHeaderOffset() { - guard let activePage = verticalPageController(for: VideoPageTab.page(at: selectedIndex)) else { - return - } - let activeOffset = activePage.normalizedContentOffsetY - let syncOffset = activePage.headerSyncOffset(fromActiveOffset: activeOffset) - for tab in VideoPageTab.allCases where tab.pageIndex != selectedIndex { - verticalPageController(for: tab)?.syncHeaderOffsetFromActivePage(syncOffset) - } - } - - private func verticalPageController(for tab: VideoPageTab) -> VideoDetailVerticalScrollPageViewController? { - switch tab { - case .introduction: - return introductionPage - case .comments: - return commentsPage - } - } - } - - final class PagingScrollView: UIScrollView { - var shouldBeginPagingPan: ((UIPanGestureRecognizer, UIView) -> Bool)? - - override func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool { - if let panGestureRecognizer = gestureRecognizer as? UIPanGestureRecognizer, - panGestureRecognizer === self.panGestureRecognizer { - return shouldBeginPagingPan?(panGestureRecognizer, self) ?? true - } - return super.gestureRecognizerShouldBegin(gestureRecognizer) - } - } -} - -private extension UIView { - func hasScrollableHorizontalDescendant(at location: CGPoint, excluding excludedView: UIView) -> Bool { - guard let hitView = hitTest(location, with: nil) else { return false } - var current: UIView? = hitView - while let view = current, view !== excludedView { - if let scrollView = view as? UIScrollView, - scrollView.isScrollEnabled, - scrollView.panGestureRecognizer.isEnabled, - scrollView.contentSize.width > scrollView.bounds.width + 1, - scrollView.contentSize.width > scrollView.contentSize.height { - return true - } - current = view.superview - } - return false - } -} private extension VideoDetailScreenSnapshot { func hash(into hasher: inout Hasher) { From d6eaed21ce6c3ed96c0b52a6741611f3d36458f5 Mon Sep 17 00:00:00 2001 From: pgs666 <74764545+pgs666@users.noreply.github.com> Date: Sun, 7 Jun 2026 09:23:33 +0800 Subject: [PATCH 110/216] Centralize pager list offset context --- iosApp/VideoDetailPager.swift | 55 ++++++++++++++++++++++++++--------- 1 file changed, 42 insertions(+), 13 deletions(-) diff --git a/iosApp/VideoDetailPager.swift b/iosApp/VideoDetailPager.swift index eb59ea35..4fd28caf 100644 --- a/iosApp/VideoDetailPager.swift +++ b/iosApp/VideoDetailPager.swift @@ -189,6 +189,22 @@ private struct VideoDetailSmoothHeaderGeometry: Equatable { max(collapseDistance - resolvedVisualTopOffset + 1, 1) } + func listOffsetContext( + in scrollBoundsHeight: CGFloat, + activeOffset: CGFloat? = nil + ) -> VideoDetailListOffsetContext { + let visualTopOffset = resolvedVisualTopOffset + return VideoDetailListOffsetContext( + contentTopInset: contentTopInset, + initialNormalizedOffsetY: visualTopOffset, + inactiveSyncNormalizedOffsetY: activeOffset.map { + min(max($0, 0), visualTopOffset) + } ?? visualTopOffset, + collapseSpacerHeight: collapseSpacerHeight, + minimumContentHeight: minimumContentHeight(in: scrollBoundsHeight) + ) + } + func minimumContentHeight(in scrollBoundsHeight: CGFloat) -> CGFloat { max( scrollBoundsHeight - max(pinnedVisibleHeight, 0), @@ -209,9 +225,14 @@ private struct VideoDetailSmoothHeaderGeometry: Equatable { rawOffsetY + scrollView.adjustedContentInset.top } - func syncOffset(fromActiveOffset activeOffset: CGFloat) -> CGFloat { - min(max(activeOffset, 0), resolvedVisualTopOffset) - } +} + +private struct VideoDetailListOffsetContext: Equatable { + let contentTopInset: CGFloat + let initialNormalizedOffsetY: CGFloat + let inactiveSyncNormalizedOffsetY: CGFloat + let collapseSpacerHeight: CGFloat + let minimumContentHeight: CGFloat } private struct VideoDetailTabPage { @@ -554,14 +575,15 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle let previousVisualTopContentOffsetY = coordinator.visualTopContentOffsetY let geometry = page.headerGeometry - let visualTopOffset = geometry.resolvedVisualTopOffset + let offsetContext = geometry.listOffsetContext(in: scrollView.bounds.height) + let visualTopOffset = offsetContext.initialNormalizedOffsetY lastAppliedPage = page coordinator.visualTopContentOffsetY = visualTopOffset - applyTopContentInset(geometry.contentTopInset) + applyTopContentInset(offsetContext.contentTopInset) applyListHeaderFrame() applyBottomContentInset(page.contentBottomPadding) - collapseSpacerHeightConstraint.constant = geometry.collapseSpacerHeight - contentMinimumHeightConstraint.constant = geometry.minimumContentHeight(in: scrollView.bounds.height) + collapseSpacerHeightConstraint.constant = offsetContext.collapseSpacerHeight + contentMinimumHeightConstraint.constant = offsetContext.minimumContentHeight if page.isSelected, needsInitialHeaderOffsetReset { requestTopAlignment(targetOffsetY: visualTopOffset) } else if pendingTopAlignmentTargetOffsetY != nil { @@ -578,12 +600,12 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle private func applyCurrentPageGeometryRules() { guard let page = lastAppliedPage else { return } let geometry = page.headerGeometry - let minimumContentHeight = geometry.minimumContentHeight(in: scrollView.bounds.height) - if abs(contentMinimumHeightConstraint.constant - minimumContentHeight) > 0.5 { - contentMinimumHeightConstraint.constant = minimumContentHeight + let offsetContext = geometry.listOffsetContext(in: scrollView.bounds.height) + if abs(contentMinimumHeightConstraint.constant - offsetContext.minimumContentHeight) > 0.5 { + contentMinimumHeightConstraint.constant = offsetContext.minimumContentHeight } guard page.isSelected else { return } - let visualTopOffset = geometry.resolvedVisualTopOffset + let visualTopOffset = offsetContext.initialNormalizedOffsetY if pendingTopAlignmentTargetOffsetY != nil { pendingTopAlignmentTargetOffsetY = visualTopOffset return @@ -671,7 +693,10 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle } func headerSyncOffset(fromActiveOffset activeOffset: CGFloat) -> CGFloat { - lastAppliedPage?.headerGeometry.syncOffset(fromActiveOffset: activeOffset) ?? 0 + lastAppliedPage?.headerGeometry.listOffsetContext( + in: scrollView.bounds.height, + activeOffset: activeOffset + ).inactiveSyncNormalizedOffsetY ?? 0 } func syncHeaderOffsetFromActivePage(_ offsetY: CGFloat) { @@ -679,7 +704,11 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle guard let page = lastAppliedPage else { return } cancelPendingTopAlignment() guard !needsInitialHeaderOffsetReset else { return } - setNormalizedContentOffsetY(page.headerGeometry.syncOffset(fromActiveOffset: offsetY)) + let syncOffsetY = page.headerGeometry.listOffsetContext( + in: scrollView.bounds.height, + activeOffset: offsetY + ).inactiveSyncNormalizedOffsetY + setNormalizedContentOffsetY(syncOffsetY) } func setHorizontalPagingActive(_ isActive: Bool) { From b8105e10c6ea8390eaa57df2cdd0adf9be21887f Mon Sep 17 00:00:00 2001 From: pgs666 <74764545+pgs666@users.noreply.github.com> Date: Sun, 7 Jun 2026 09:31:43 +0800 Subject: [PATCH 111/216] Reset pager list offset during content growth --- iosApp/VideoDetailPager.swift | 29 +++++++++++++++++++++++++---- 1 file changed, 25 insertions(+), 4 deletions(-) diff --git a/iosApp/VideoDetailPager.swift b/iosApp/VideoDetailPager.swift index 4fd28caf..bed7b816 100644 --- a/iosApp/VideoDetailPager.swift +++ b/iosApp/VideoDetailPager.swift @@ -584,8 +584,8 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle applyBottomContentInset(page.contentBottomPadding) collapseSpacerHeightConstraint.constant = offsetContext.collapseSpacerHeight contentMinimumHeightConstraint.constant = offsetContext.minimumContentHeight - if page.isSelected, needsInitialHeaderOffsetReset { - requestTopAlignment(targetOffsetY: visualTopOffset) + if needsInitialHeaderOffsetReset { + applyInitialHeaderOffsetResetIfNeeded() } else if pendingTopAlignmentTargetOffsetY != nil { pendingTopAlignmentTargetOffsetY = visualTopOffset resolvePendingTopAlignmentSoon() @@ -604,16 +604,16 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle if abs(contentMinimumHeightConstraint.constant - offsetContext.minimumContentHeight) > 0.5 { contentMinimumHeightConstraint.constant = offsetContext.minimumContentHeight } - guard page.isSelected else { return } let visualTopOffset = offsetContext.initialNormalizedOffsetY if pendingTopAlignmentTargetOffsetY != nil { pendingTopAlignmentTargetOffsetY = visualTopOffset return } if needsInitialHeaderOffsetReset { - requestTopAlignment(targetOffsetY: visualTopOffset) + applyInitialHeaderOffsetResetIfNeeded() return } + guard page.isSelected else { return } guard scrollView.verticalContentOffsetExcludingBounce <= visualTopOffset + 8 else { return } requestTopAlignment(targetOffsetY: visualTopOffset) } @@ -674,6 +674,23 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle } } + private func applyInitialHeaderOffsetResetIfNeeded(allowDuringInteraction: Bool = false) { + guard needsInitialHeaderOffsetReset, let page = lastAppliedPage else { return } + if !allowDuringInteraction { + guard !scrollView.isTracking, !scrollView.isDragging, !scrollView.isDecelerating else { return } + } + let targetOffsetY = page.headerGeometry.listOffsetContext( + in: scrollView.bounds.height + ).initialNormalizedOffsetY + let rawTopOffsetY = clampedRawContentOffsetY(forNormalizedOffsetY: targetOffsetY) + guard abs(normalizedContentOffsetY(forRawOffsetY: rawTopOffsetY) - targetOffsetY) <= 0.5 else { return } + setRawContentOffsetYIfNeeded(rawTopOffsetY) + if abs(scrollView.verticalContentOffsetExcludingBounce - targetOffsetY) <= 0.5 { + needsInitialHeaderOffsetReset = false + cancelPendingTopAlignment() + } + } + private func cancelPendingTopAlignment() { pendingTopAlignmentTargetOffsetY = nil } @@ -683,6 +700,10 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle let targetOffsetY = coordinator.visualTopContentOffsetY guard needsInitialHeaderOffsetReset || scrollView.verticalContentOffsetExcludingBounce <= targetOffsetY + 8 else { return } + if needsInitialHeaderOffsetReset { + applyInitialHeaderOffsetResetIfNeeded(allowDuringInteraction: true) + guard !needsInitialHeaderOffsetReset else { return } + } requestTopAlignment(targetOffsetY: targetOffsetY) resolvePendingTopAlignmentIfPossible(allowDuringInteraction: true) } From 73f83553b0c8d815cd2dcd90a918e8ae7b79b05a Mon Sep 17 00:00:00 2001 From: pgs666 <74764545+pgs666@users.noreply.github.com> Date: Sun, 7 Jun 2026 09:39:59 +0800 Subject: [PATCH 112/216] Align video pager offset settlement with JX --- iosApp/VideoDetailPager.swift | 32 +++++++++++++++++++++----------- iosApp/VideoDetailView.swift | 2 +- 2 files changed, 22 insertions(+), 12 deletions(-) diff --git a/iosApp/VideoDetailPager.swift b/iosApp/VideoDetailPager.swift index bed7b816..1431bb02 100644 --- a/iosApp/VideoDetailPager.swift +++ b/iosApp/VideoDetailPager.swift @@ -186,7 +186,7 @@ private struct VideoDetailSmoothHeaderGeometry: Equatable { } var collapseSpacerHeight: CGFloat { - max(collapseDistance - resolvedVisualTopOffset + 1, 1) + max(collapseDistance + 1, 1) } func listOffsetContext( @@ -665,9 +665,7 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle if !allowDuringInteraction { guard !scrollView.isTracking, !scrollView.isDragging, !scrollView.isDecelerating else { return } } - let rawTopOffsetY = clampedRawContentOffsetY(forNormalizedOffsetY: targetOffsetY) - guard abs(normalizedContentOffsetY(forRawOffsetY: rawTopOffsetY) - targetOffsetY) <= 0.5 else { return } - setRawContentOffsetYIfNeeded(rawTopOffsetY) + guard setNormalizedContentOffsetYIfReachable(targetOffsetY) else { return } if abs(scrollView.verticalContentOffsetExcludingBounce - targetOffsetY) <= 0.5 { needsInitialHeaderOffsetReset = false cancelPendingTopAlignment() @@ -682,9 +680,10 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle let targetOffsetY = page.headerGeometry.listOffsetContext( in: scrollView.bounds.height ).initialNormalizedOffsetY - let rawTopOffsetY = clampedRawContentOffsetY(forNormalizedOffsetY: targetOffsetY) - guard abs(normalizedContentOffsetY(forRawOffsetY: rawTopOffsetY) - targetOffsetY) <= 0.5 else { return } - setRawContentOffsetYIfNeeded(rawTopOffsetY) + guard setNormalizedContentOffsetYIfReachable(targetOffsetY) else { + pendingTopAlignmentTargetOffsetY = targetOffsetY + return + } if abs(scrollView.verticalContentOffsetExcludingBounce - targetOffsetY) <= 0.5 { needsInitialHeaderOffsetReset = false cancelPendingTopAlignment() @@ -698,8 +697,6 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle func settleAfterHorizontalActivation() { loadViewIfNeeded() let targetOffsetY = coordinator.visualTopContentOffsetY - guard needsInitialHeaderOffsetReset - || scrollView.verticalContentOffsetExcludingBounce <= targetOffsetY + 8 else { return } if needsInitialHeaderOffsetReset { applyInitialHeaderOffsetResetIfNeeded(allowDuringInteraction: true) guard !needsInitialHeaderOffsetReset else { return } @@ -724,12 +721,15 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle loadViewIfNeeded() guard let page = lastAppliedPage else { return } cancelPendingTopAlignment() - guard !needsInitialHeaderOffsetReset else { return } let syncOffsetY = page.headerGeometry.listOffsetContext( in: scrollView.bounds.height, activeOffset: offsetY ).inactiveSyncNormalizedOffsetY - setNormalizedContentOffsetY(syncOffsetY) + guard setNormalizedContentOffsetYIfReachable(syncOffsetY) else { + pendingTopAlignmentTargetOffsetY = syncOffsetY + return + } + needsInitialHeaderOffsetReset = false } func setHorizontalPagingActive(_ isActive: Bool) { @@ -751,6 +751,16 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle setRawContentOffsetYIfNeeded(rawTopOffsetY) } + @discardableResult + private func setNormalizedContentOffsetYIfReachable(_ offsetY: CGFloat) -> Bool { + let rawTopOffsetY = clampedRawContentOffsetY(forNormalizedOffsetY: offsetY) + guard abs(normalizedContentOffsetY(forRawOffsetY: rawTopOffsetY) - offsetY) <= 0.5 else { + return false + } + setRawContentOffsetYIfNeeded(rawTopOffsetY) + return true + } + private func setRawContentOffsetYIfNeeded(_ rawTopOffsetY: CGFloat) { guard abs(scrollView.contentOffset.y - rawTopOffsetY) > 0.5 else { return } coordinator.isApplyingExternalOffset = true diff --git a/iosApp/VideoDetailView.swift b/iosApp/VideoDetailView.swift index 83ea709a..f3e531e5 100644 --- a/iosApp/VideoDetailView.swift +++ b/iosApp/VideoDetailView.swift @@ -10,7 +10,7 @@ struct VideoDetailView: View { private let tabletSidebarMinimumWidth: CGFloat = 360 private let playerContinuationStripHeight: CGFloat = 56 private let pagerPinHeaderHeight: CGFloat = 48 - private let commentComposerBottomSlack: CGFloat = 96 + private let commentComposerBottomSlack: CGFloat = 24 @StateObject private var viewModel: VideoDetailViewModel @StateObject private var commentViewModel: CommentViewModel @State private var pagerState = VideoDetailPagerState() From 32728b69d0352d24e5cb603f8ff95f29e946007e Mon Sep 17 00:00:00 2001 From: pgs666 <74764545+pgs666@users.noreply.github.com> Date: Sun, 7 Jun 2026 09:48:03 +0800 Subject: [PATCH 113/216] Fix pager content size settling --- iosApp/CommentView.swift | 2 +- iosApp/VideoDetailPager.swift | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/iosApp/CommentView.swift b/iosApp/CommentView.swift index eb86101a..6053b388 100644 --- a/iosApp/CommentView.swift +++ b/iosApp/CommentView.swift @@ -198,7 +198,7 @@ struct CommentView: View { .font(.footnote) .foregroundStyle(.secondary) .frame(maxWidth: .infinity) - .padding(.vertical, 12) + .frame(height: 44) } .id(viewModel.sortMode.id) } diff --git a/iosApp/VideoDetailPager.swift b/iosApp/VideoDetailPager.swift index 1431bb02..0cefe6cc 100644 --- a/iosApp/VideoDetailPager.swift +++ b/iosApp/VideoDetailPager.swift @@ -516,10 +516,7 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle contentView.addSubview(collapseSpacerView) hostMinimumHeightConstraint = host.view.heightAnchor.constraint(greaterThanOrEqualTo: scrollView.frameLayoutGuide.heightAnchor) - contentMinimumHeightConstraint = contentView.heightAnchor.constraint( - greaterThanOrEqualTo: scrollView.frameLayoutGuide.heightAnchor, - constant: 1 - ) + contentMinimumHeightConstraint = contentView.heightAnchor.constraint(greaterThanOrEqualToConstant: 1) collapseSpacerHeightConstraint = collapseSpacerView.heightAnchor.constraint(equalToConstant: 1) NSLayoutConstraint.activate([ @@ -571,6 +568,8 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle contentUpdateRevision = page.contentUpdateRevision host.rootView = page.content() needsInitialHeaderOffsetReset = true + view.setNeedsLayout() + view.layoutIfNeeded() } let previousVisualTopContentOffsetY = coordinator.visualTopContentOffsetY @@ -607,6 +606,7 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle let visualTopOffset = offsetContext.initialNormalizedOffsetY if pendingTopAlignmentTargetOffsetY != nil { pendingTopAlignmentTargetOffsetY = visualTopOffset + resolvePendingTopAlignmentIfPossible() return } if needsInitialHeaderOffsetReset { From ffb51161b8ddc6a29e1197c63f4fadb3f1eb9019 Mon Sep 17 00:00:00 2001 From: pgs666 <74764545+pgs666@users.noreply.github.com> Date: Sun, 7 Jun 2026 10:00:18 +0800 Subject: [PATCH 114/216] Preserve pager pending offset intent --- iosApp/VideoDetailPager.swift | 54 +++++++++++++++++++++++++---------- 1 file changed, 39 insertions(+), 15 deletions(-) diff --git a/iosApp/VideoDetailPager.swift b/iosApp/VideoDetailPager.swift index 0cefe6cc..45e6f4d4 100644 --- a/iosApp/VideoDetailPager.swift +++ b/iosApp/VideoDetailPager.swift @@ -235,6 +235,11 @@ private struct VideoDetailListOffsetContext: Equatable { let minimumContentHeight: CGFloat } +private enum VideoDetailPendingTopAlignment { + case initial + case explicit(CGFloat) +} + private struct VideoDetailTabPage { let tab: VideoPageTab let contentBottomPadding: CGFloat @@ -452,7 +457,7 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle private var contentUpdateRevision: Int? private var lastAppliedPage: VideoDetailTabPage? private var topAlignmentGeneration = 0 - private var pendingTopAlignmentTargetOffsetY: CGFloat? + private var pendingTopAlignment: VideoDetailPendingTopAlignment? private var needsInitialHeaderOffsetReset = true init(tab: VideoPageTab) { @@ -487,8 +492,7 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle scrollView.delegate = coordinator scrollView.panGestureRecognizer.addTarget(coordinator, action: #selector(VideoDetailVerticalScrollPageCoordinator.handlePan(_:))) scrollView.onGeometryChange = { [weak self] in - self?.applyCurrentPageGeometryRules() - self?.resolvePendingTopAlignmentIfPossible() + self?.handleScrollGeometryChange() } scrollView.shouldBeginVerticalPan = { [weak self] panGestureRecognizer, view in self?.resolvePendingTopAlignmentIfPossible(allowDuringInteraction: true) @@ -548,8 +552,7 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle override func viewDidLayoutSubviews() { super.viewDidLayoutSubviews() applyListHeaderFrame() - applyCurrentPageGeometryRules() - resolvePendingTopAlignmentIfPossible() + handleScrollGeometryChange() } func update(page: VideoDetailTabPage) { @@ -561,7 +564,7 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle coordinator.onVerticalInteractionBegan = { [weak self] in self?.resolvePendingTopAlignmentIfPossible(allowDuringInteraction: true) } - if !page.isSelected { + if !page.isSelected, !hasExplicitPendingTopAlignment { cancelPendingTopAlignment() } if contentUpdateRevision != page.contentUpdateRevision { @@ -585,8 +588,7 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle contentMinimumHeightConstraint.constant = offsetContext.minimumContentHeight if needsInitialHeaderOffsetReset { applyInitialHeaderOffsetResetIfNeeded() - } else if pendingTopAlignmentTargetOffsetY != nil { - pendingTopAlignmentTargetOffsetY = visualTopOffset + } else if pendingTopAlignment != nil { resolvePendingTopAlignmentSoon() } else { preserveVisualTopIfNeeded( @@ -596,6 +598,11 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle } } + private func handleScrollGeometryChange() { + applyCurrentPageGeometryRules() + resolvePendingTopAlignmentIfPossible() + } + private func applyCurrentPageGeometryRules() { guard let page = lastAppliedPage else { return } let geometry = page.headerGeometry @@ -604,8 +611,7 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle contentMinimumHeightConstraint.constant = offsetContext.minimumContentHeight } let visualTopOffset = offsetContext.initialNormalizedOffsetY - if pendingTopAlignmentTargetOffsetY != nil { - pendingTopAlignmentTargetOffsetY = visualTopOffset + if pendingTopAlignment != nil { resolvePendingTopAlignmentIfPossible() return } @@ -647,7 +653,7 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle private func requestTopAlignment(targetOffsetY: CGFloat) { topAlignmentGeneration &+= 1 - pendingTopAlignmentTargetOffsetY = targetOffsetY + pendingTopAlignment = .explicit(targetOffsetY) resolvePendingTopAlignmentSoon() } @@ -661,7 +667,7 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle } private func resolvePendingTopAlignmentIfPossible(allowDuringInteraction: Bool = false) { - guard let targetOffsetY = pendingTopAlignmentTargetOffsetY else { return } + guard let targetOffsetY = pendingTopAlignmentTargetOffsetY() else { return } if !allowDuringInteraction { guard !scrollView.isTracking, !scrollView.isDragging, !scrollView.isDecelerating else { return } } @@ -681,7 +687,7 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle in: scrollView.bounds.height ).initialNormalizedOffsetY guard setNormalizedContentOffsetYIfReachable(targetOffsetY) else { - pendingTopAlignmentTargetOffsetY = targetOffsetY + pendingTopAlignment = .initial return } if abs(scrollView.verticalContentOffsetExcludingBounce - targetOffsetY) <= 0.5 { @@ -691,7 +697,25 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle } private func cancelPendingTopAlignment() { - pendingTopAlignmentTargetOffsetY = nil + pendingTopAlignment = nil + } + + private func pendingTopAlignmentTargetOffsetY() -> CGFloat? { + guard let pendingTopAlignment, let page = lastAppliedPage else { return nil } + switch pendingTopAlignment { + case .initial: + return page.headerGeometry.listOffsetContext(in: scrollView.bounds.height).initialNormalizedOffsetY + case .explicit(let offsetY): + return offsetY + } + } + + private var hasExplicitPendingTopAlignment: Bool { + guard let pendingTopAlignment else { return false } + if case .explicit = pendingTopAlignment { + return true + } + return false } func settleAfterHorizontalActivation() { @@ -726,7 +750,7 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle activeOffset: offsetY ).inactiveSyncNormalizedOffsetY guard setNormalizedContentOffsetYIfReachable(syncOffsetY) else { - pendingTopAlignmentTargetOffsetY = syncOffsetY + pendingTopAlignment = .explicit(syncOffsetY) return } needsInitialHeaderOffsetReset = false From 63d2440cbea0004d5d4c6b721cce4534ae8e4086 Mon Sep 17 00:00:00 2001 From: pgs666 <74764545+pgs666@users.noreply.github.com> Date: Sun, 7 Jun 2026 10:07:13 +0800 Subject: [PATCH 115/216] Keep inactive pager offset pinned --- iosApp/VideoDetailPager.swift | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/iosApp/VideoDetailPager.swift b/iosApp/VideoDetailPager.swift index 45e6f4d4..7d5fefb2 100644 --- a/iosApp/VideoDetailPager.swift +++ b/iosApp/VideoDetailPager.swift @@ -198,7 +198,7 @@ private struct VideoDetailSmoothHeaderGeometry: Equatable { contentTopInset: contentTopInset, initialNormalizedOffsetY: visualTopOffset, inactiveSyncNormalizedOffsetY: activeOffset.map { - min(max($0, 0), visualTopOffset) + min(max($0, 0), max(collapseDistance, 0)) } ?? visualTopOffset, collapseSpacerHeight: collapseSpacerHeight, minimumContentHeight: minimumContentHeight(in: scrollBoundsHeight) @@ -725,6 +725,10 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle applyInitialHeaderOffsetResetIfNeeded(allowDuringInteraction: true) guard !needsInitialHeaderOffsetReset else { return } } + guard scrollView.verticalContentOffsetExcludingBounce <= targetOffsetY + 0.5 else { + cancelPendingTopAlignment() + return + } requestTopAlignment(targetOffsetY: targetOffsetY) resolvePendingTopAlignmentIfPossible(allowDuringInteraction: true) } From a02b28f0ace310d7400f72f0cecfe96b101dacc5 Mon Sep 17 00:00:00 2001 From: pgs666 <74764545+pgs666@users.noreply.github.com> Date: Sun, 7 Jun 2026 10:15:17 +0800 Subject: [PATCH 116/216] Extract pager offset model --- iosApp/VideoDetailPager.swift | 67 ++++++++++++++++++++++++++++++----- 1 file changed, 58 insertions(+), 9 deletions(-) diff --git a/iosApp/VideoDetailPager.swift b/iosApp/VideoDetailPager.swift index 7d5fefb2..9cb2d252 100644 --- a/iosApp/VideoDetailPager.swift +++ b/iosApp/VideoDetailPager.swift @@ -28,6 +28,47 @@ enum VideoPlayerCollapseModel { } } +enum VideoDetailPagerOffsetModel { + static func initialNormalizedOffsetY( + visualTopOffset: CGFloat, + collapseDistance: CGFloat + ) -> CGFloat { + clamp(visualTopOffset, upperBound: collapseDistance) + } + + static func inactiveSyncNormalizedOffsetY( + activeOffset: CGFloat?, + initialOffset: CGFloat, + collapseDistance: CGFloat + ) -> CGFloat { + guard let activeOffset else { return initialOffset } + return clamp(activeOffset, upperBound: collapseDistance) + } + + static func minimumContentHeight( + scrollBoundsHeight: CGFloat, + pinnedVisibleHeight: CGFloat, + collapseDistance: CGFloat + ) -> CGFloat { + max( + scrollBoundsHeight - max(pinnedVisibleHeight, 0), + max(collapseDistance, 0) + 1, + 1 + ) + } + + static func shouldAlignToVisualTopAfterHorizontalActivation( + currentOffset: CGFloat, + visualTopOffset: CGFloat + ) -> Bool { + currentOffset <= visualTopOffset + 0.5 + } + + private static func clamp(_ value: CGFloat, upperBound: CGFloat) -> CGFloat { + min(max(value, 0), max(upperBound, 0)) + } +} + struct VideoDetailPagerLayoutMetrics { let collapseDistance: CGFloat let playerScrollAway: CGFloat @@ -182,7 +223,10 @@ private struct VideoDetailSmoothHeaderGeometry: Equatable { } var resolvedVisualTopOffset: CGFloat { - min(max(visualTopOffset, 0), max(collapseDistance, 0)) + VideoDetailPagerOffsetModel.initialNormalizedOffsetY( + visualTopOffset: visualTopOffset, + collapseDistance: collapseDistance + ) } var collapseSpacerHeight: CGFloat { @@ -197,19 +241,21 @@ private struct VideoDetailSmoothHeaderGeometry: Equatable { return VideoDetailListOffsetContext( contentTopInset: contentTopInset, initialNormalizedOffsetY: visualTopOffset, - inactiveSyncNormalizedOffsetY: activeOffset.map { - min(max($0, 0), max(collapseDistance, 0)) - } ?? visualTopOffset, + inactiveSyncNormalizedOffsetY: VideoDetailPagerOffsetModel.inactiveSyncNormalizedOffsetY( + activeOffset: activeOffset, + initialOffset: visualTopOffset, + collapseDistance: collapseDistance + ), collapseSpacerHeight: collapseSpacerHeight, minimumContentHeight: minimumContentHeight(in: scrollBoundsHeight) ) } func minimumContentHeight(in scrollBoundsHeight: CGFloat) -> CGFloat { - max( - scrollBoundsHeight - max(pinnedVisibleHeight, 0), - max(collapseDistance, 0) + 1, - 1 + VideoDetailPagerOffsetModel.minimumContentHeight( + scrollBoundsHeight: scrollBoundsHeight, + pinnedVisibleHeight: pinnedVisibleHeight, + collapseDistance: collapseDistance ) } @@ -725,7 +771,10 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle applyInitialHeaderOffsetResetIfNeeded(allowDuringInteraction: true) guard !needsInitialHeaderOffsetReset else { return } } - guard scrollView.verticalContentOffsetExcludingBounce <= targetOffsetY + 0.5 else { + guard VideoDetailPagerOffsetModel.shouldAlignToVisualTopAfterHorizontalActivation( + currentOffset: scrollView.verticalContentOffsetExcludingBounce, + visualTopOffset: targetOffsetY + ) else { cancelPendingTopAlignment() return } From cb36a477fec3c53eff5c43e5616023faea3c83a4 Mon Sep 17 00:00:00 2001 From: pgs666 <74764545+pgs666@users.noreply.github.com> Date: Sun, 7 Jun 2026 10:24:38 +0800 Subject: [PATCH 117/216] Report pager offset after horizontal settle --- iosApp/VideoDetailPager.swift | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/iosApp/VideoDetailPager.swift b/iosApp/VideoDetailPager.swift index 9cb2d252..62ed8892 100644 --- a/iosApp/VideoDetailPager.swift +++ b/iosApp/VideoDetailPager.swift @@ -816,6 +816,13 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle } } + func reportCurrentOffset() { + loadViewIfNeeded() + let offset = scrollView.verticalContentOffsetExcludingBounce + coordinator.resetReportedOffset(offset) + coordinator.onOffsetChange(coordinator.tab, offset) + } + private func preserveVisualTopIfNeeded(previousOffsetY: CGFloat, targetOffsetY: CGFloat) { guard abs(previousOffsetY - targetOffsetY) > 0.5 else { return } guard abs(scrollView.verticalContentOffsetExcludingBounce - previousOffsetY) <= 1 else { return } @@ -1166,8 +1173,10 @@ private struct VideoDetailTabPager: UIViewControllerRepresentable { switch VideoPageTab.page(at: index) { case .introduction: introductionPage.settleAfterHorizontalActivation() + introductionPage.reportCurrentOffset() case .comments: commentsPage.settleAfterHorizontalActivation() + commentsPage.reportCurrentOffset() } } From 4ce838d0575277f200b71713f5584235c9807ab2 Mon Sep 17 00:00:00 2001 From: pgs666 <74764545+pgs666@users.noreply.github.com> Date: Sun, 7 Jun 2026 10:36:58 +0800 Subject: [PATCH 118/216] Move pager pin header into list header lifecycle --- iosApp/VideoDetailPager.swift | 186 ++++++++++++++++++++++++++++------ 1 file changed, 154 insertions(+), 32 deletions(-) diff --git a/iosApp/VideoDetailPager.swift b/iosApp/VideoDetailPager.swift index 62ed8892..e259bf41 100644 --- a/iosApp/VideoDetailPager.swift +++ b/iosApp/VideoDetailPager.swift @@ -344,37 +344,21 @@ struct VideoDetailPagerContainer: View { } var body: some View { - ZStack(alignment: .top) { - VideoDetailTabPager( - selectedTab: selectedTabBinding, - introduction: tabPage( - .introduction, - contentBottomPadding: introductionContentBottomPadding, - contentUpdateRevision: introductionContentRevision, - content: introduction - ), - comments: tabPage( - .comments, - contentBottomPadding: commentsContentBottomPadding, - contentUpdateRevision: commentsContentRevision, - content: comments - ) + VideoDetailTabPager( + selectedTab: selectedTabBinding, + introduction: tabPage( + .introduction, + contentBottomPadding: introductionContentBottomPadding, + contentUpdateRevision: introductionContentRevision, + content: introduction + ), + comments: tabPage( + .comments, + contentBottomPadding: commentsContentBottomPadding, + contentUpdateRevision: commentsContentRevision, + content: comments ) - .frame(maxHeight: .infinity) - - Picker("Content", selection: selectedTabBinding) { - ForEach(VideoPageTab.allCases) { tab in - Text(tab.title).tag(tab) - } - } - .pickerStyle(.segmented) - .padding(.horizontal, 16) - .frame(height: pinHeaderHeight) - .frame(maxWidth: .infinity) - .background(.background) - .offset(y: max(headerHeight - playerScrollAway, pinnedVisibleHeight - pinHeaderHeight)) - .zIndex(1) - } + ) .frame(maxHeight: .infinity) .background(Color(.systemGroupedBackground)) .onValueChange(of: collapseDistance) { newValue in @@ -436,6 +420,7 @@ private final class VideoDetailVerticalScrollPageCoordinator: NSObject, UIScroll var onInteractionBegan: (VideoPageTab) -> Void var onTopPullDelta: (VideoPageTab, CGFloat) -> Void var onVerticalInteractionBegan: () -> Void = {} + var onVisibleOffsetChange: (VideoPageTab, CGFloat) -> Void = { _, _ in } var visualTopContentOffsetY: CGFloat = 0 var isApplyingExternalOffset = false var isHorizontalPagingActive = false @@ -457,6 +442,7 @@ private final class VideoDetailVerticalScrollPageCoordinator: NSObject, UIScroll func scrollViewDidScroll(_ scrollView: UIScrollView) { guard !isApplyingExternalOffset, !isHorizontalPagingActive else { return } let offset = scrollView.verticalContentOffsetExcludingBounce + onVisibleOffsetChange(tab, offset) guard lastReportedOffset.map({ abs($0 - offset) > 0.5 }) ?? true else { return } lastReportedOffset = offset onOffsetChange(tab, offset) @@ -505,6 +491,7 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle private var topAlignmentGeneration = 0 private var pendingTopAlignment: VideoDetailPendingTopAlignment? private var needsInitialHeaderOffsetReset = true + var onHeaderOffsetChanged: (VideoPageTab, CGFloat) -> Void = { _, _ in } init(tab: VideoPageTab) { coordinator = VideoDetailVerticalScrollPageCoordinator( @@ -552,7 +539,7 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle scrollView.addSubview(contentView) listHeaderView.backgroundColor = .clear - listHeaderView.isUserInteractionEnabled = false + listHeaderView.isUserInteractionEnabled = true scrollView.addSubview(listHeaderView) addChild(host) @@ -610,6 +597,9 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle coordinator.onVerticalInteractionBegan = { [weak self] in self?.resolvePendingTopAlignmentIfPossible(allowDuringInteraction: true) } + coordinator.onVisibleOffsetChange = { [weak self] tab, offset in + self?.onHeaderOffsetChanged(tab, offset) + } if !page.isSelected, !hasExplicitPendingTopAlignment { cancelPendingTopAlignment() } @@ -851,6 +841,12 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle defer { coordinator.isApplyingExternalOffset = false } scrollView.setContentOffset(CGPoint(x: scrollView.contentOffset.x, y: rawTopOffsetY), animated: false) coordinator.resetReportedOffset(scrollView.verticalContentOffsetExcludingBounce) + onHeaderOffsetChanged(coordinator.tab, scrollView.verticalContentOffsetExcludingBounce) + } + + func headerAttachmentView() -> UIView { + loadViewIfNeeded() + return listHeaderView } private func clampedRawContentOffsetY(forNormalizedOffsetY offsetY: CGFloat) -> CGFloat { @@ -1024,6 +1020,8 @@ private struct VideoDetailTabPager: UIViewControllerRepresentable { private let coordinator: Coordinator private let scrollView = PagingScrollView() private let contentView = UIView() + private let headerContainerView = PagingHeaderContainerView() + private let pinHeaderHost = UIHostingController(rootView: AnyView(EmptyView())) private let introductionPage = VideoDetailVerticalScrollPageViewController(tab: .introduction) private let commentsPage = VideoDetailVerticalScrollPageViewController(tab: .comments) private var selectedIndex = 0 @@ -1031,6 +1029,8 @@ private struct VideoDetailTabPager: UIViewControllerRepresentable { private var pendingSelectedIndex: Int? private var lastLaidOutWidth: CGFloat = 0 private var isHorizontalPagingActive = false + private var latestPages: [VideoPageTab: VideoDetailTabPage] = [:] + private var selectedTabBinding: Binding? init(coordinator: Coordinator) { self.coordinator = coordinator @@ -1075,8 +1075,21 @@ private struct VideoDetailTabPager: UIViewControllerRepresentable { contentView.backgroundColor = .clear scrollView.addSubview(contentView) + headerContainerView.backgroundColor = .clear + headerContainerView.pinHeaderHeight = 48 + addChild(pinHeaderHost) + pinHeaderHost.view.backgroundColor = .clear + headerContainerView.addSubview(pinHeaderHost.view) + pinHeaderHost.didMove(toParent: self) + addPage(introductionPage) addPage(commentsPage) + introductionPage.onHeaderOffsetChanged = { [weak self] tab, offset in + self?.updateHeaderContainerPosition(for: tab, offsetY: offset) + } + commentsPage.onHeaderOffsetChanged = { [weak self] tab, offset in + self?.updateHeaderContainerPosition(for: tab, offsetY: offset) + } NSLayoutConstraint.activate([ scrollView.leadingAnchor.constraint(equalTo: view.leadingAnchor), @@ -1110,6 +1123,8 @@ private struct VideoDetailTabPager: UIViewControllerRepresentable { let width = scrollView.bounds.width let widthChanged = abs(width - lastLaidOutWidth) > 0.5 lastLaidOutWidth = width + layoutPinHeaderHost() + updateHeaderAttachmentForCurrentState() if let pendingSelectedIndex { self.pendingSelectedIndex = nil setSelectedIndex(pendingSelectedIndex, animated: false) @@ -1126,8 +1141,13 @@ private struct VideoDetailTabPager: UIViewControllerRepresentable { ) { loadViewIfNeeded() self.selectedIndex = min(max(selectedIndex, 0), VideoPageTab.allCases.count - 1) + selectedTabBinding = coordinator.selectedTab + latestPages[.introduction] = introduction + latestPages[.comments] = comments + updatePinHeaderHost(page: VideoPageTab.page(at: self.selectedIndex)) introductionPage.update(page: introduction) commentsPage.update(page: comments) + updateHeaderAttachmentForCurrentState() syncInactivePageHeaderOffset() let consumedSettledIndex = coordinator.consumePendingSettledIndex(for: self.selectedIndex) guard !scrollView.isTracking, !scrollView.isDragging, !scrollView.isDecelerating else { return } @@ -1169,7 +1189,7 @@ private struct VideoDetailTabPager: UIViewControllerRepresentable { private func settlePageAfterHorizontalSelection(_ index: Int) { lastSettledSelectedIndex = min(max(index, 0), VideoPageTab.allCases.count - 1) - syncInactivePageHeaderOffset() + updatePinHeaderHost(page: VideoPageTab.page(at: index)) switch VideoPageTab.page(at: index) { case .introduction: introductionPage.settleAfterHorizontalActivation() @@ -1178,6 +1198,8 @@ private struct VideoDetailTabPager: UIViewControllerRepresentable { commentsPage.settleAfterHorizontalActivation() commentsPage.reportCurrentOffset() } + updateHeaderAttachmentForCurrentState() + syncInactivePageHeaderOffset() } private func setHorizontalPagingActive(_ isActive: Bool) { @@ -1185,6 +1207,7 @@ private struct VideoDetailTabPager: UIViewControllerRepresentable { isHorizontalPagingActive = isActive introductionPage.setHorizontalPagingActive(isActive) commentsPage.setHorizontalPagingActive(isActive) + updateHeaderAttachmentForCurrentState() if !isActive { syncInactivePageHeaderOffset() } @@ -1209,6 +1232,90 @@ private struct VideoDetailTabPager: UIViewControllerRepresentable { return commentsPage } } + + private func updatePinHeaderHost(page tab: VideoPageTab) { + guard let selectedTabBinding else { return } + guard let page = latestPages[tab] else { return } + headerContainerView.pinHeaderHeight = page.headerGeometry.pinHeaderHeight + pinHeaderHost.rootView = AnyView( + Picker("Content", selection: selectedTabBinding) { + ForEach(VideoPageTab.allCases) { tab in + Text(tab.title).tag(tab) + } + } + .pickerStyle(.segmented) + .padding(.horizontal, 16) + .frame(height: page.headerGeometry.pinHeaderHeight) + .frame(maxWidth: .infinity) + .background(.background) + ) + layoutPinHeaderHost() + } + + private func layoutPinHeaderHost() { + guard let page = latestPages[VideoPageTab.page(at: selectedIndex)] else { return } + let width = view.bounds.width + let contentTopInset = page.headerGeometry.contentTopInset + headerContainerView.frame = CGRect( + x: 0, + y: headerContainerView.frame.origin.y, + width: width, + height: contentTopInset + ) + pinHeaderHost.view.frame = CGRect( + x: 0, + y: page.headerGeometry.headerHeight, + width: width, + height: page.headerGeometry.pinHeaderHeight + ) + } + + private func updateHeaderAttachmentForCurrentState() { + guard let page = latestPages[VideoPageTab.page(at: selectedIndex)] else { return } + layoutPinHeaderHost() + if isHorizontalPagingActive { + attachHeaderToPagerContainer(page: page) + return + } + let selectedPageController = verticalPageController(for: VideoPageTab.page(at: selectedIndex)) + guard let selectedPageController else { return } + let selectedOffset = selectedPageController.normalizedContentOffsetY + let shouldAttachToListHeader = selectedOffset <= page.headerGeometry.collapseDistance + 0.5 + if shouldAttachToListHeader { + attachHeader(to: selectedPageController.headerAttachmentView(), originY: 0) + } else { + attachHeaderToPagerContainer(page: page) + } + } + + private func attachHeaderToPagerContainer(page: VideoDetailTabPage) { + let offset = verticalPageController(for: VideoPageTab.page(at: selectedIndex))?.normalizedContentOffsetY ?? 0 + let headerOffset = min(max(offset, 0), page.headerGeometry.collapseDistance) + attachHeader(to: view, originY: -headerOffset) + } + + private func attachHeader(to parentView: UIView, originY: CGFloat) { + if headerContainerView.superview !== parentView { + headerContainerView.removeFromSuperview() + parentView.addSubview(headerContainerView) + } + var frame = headerContainerView.frame + frame.origin = CGPoint(x: 0, y: originY) + frame.size.width = parentView.bounds.width + headerContainerView.frame = frame + parentView.bringSubviewToFront(headerContainerView) + } + + private func updateHeaderContainerPosition(for tab: VideoPageTab, offsetY: CGFloat) { + guard tab.pageIndex == selectedIndex else { return } + guard let page = latestPages[tab] else { return } + if isHorizontalPagingActive { + let headerOffset = min(max(offsetY, 0), page.headerGeometry.collapseDistance) + attachHeader(to: view, originY: -headerOffset) + } else { + updateHeaderAttachmentForCurrentState() + } + } } final class PagingScrollView: UIScrollView { @@ -1222,6 +1329,21 @@ private struct VideoDetailTabPager: UIViewControllerRepresentable { return super.gestureRecognizerShouldBegin(gestureRecognizer) } } + + final class PagingHeaderContainerView: UIView { + var pinHeaderHeight: CGFloat = 0 + + override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { + let pinHeaderFrame = CGRect( + x: 0, + y: bounds.height - pinHeaderHeight, + width: bounds.width, + height: pinHeaderHeight + ) + guard pinHeaderFrame.contains(point) else { return nil } + return super.hitTest(point, with: event) + } + } } private extension UIView { From 12a559ab9d56c33b7b5c28fe3206661e9690ab47 Mon Sep 17 00:00:00 2001 From: pgs666 <74764545+pgs666@users.noreply.github.com> Date: Sun, 7 Jun 2026 10:45:17 +0800 Subject: [PATCH 119/216] Stop resetting pager offset on content updates --- iosApp/VideoDetailPager.swift | 42 ++++++++++------------------------- 1 file changed, 12 insertions(+), 30 deletions(-) diff --git a/iosApp/VideoDetailPager.swift b/iosApp/VideoDetailPager.swift index e259bf41..dde93e59 100644 --- a/iosApp/VideoDetailPager.swift +++ b/iosApp/VideoDetailPager.swift @@ -488,9 +488,9 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle private var collapseSpacerHeightConstraint: NSLayoutConstraint! private var contentUpdateRevision: Int? private var lastAppliedPage: VideoDetailTabPage? - private var topAlignmentGeneration = 0 private var pendingTopAlignment: VideoDetailPendingTopAlignment? private var needsInitialHeaderOffsetReset = true + private var hasAppliedInitialListOffset = false var onHeaderOffsetChanged: (VideoPageTab, CGFloat) -> Void = { _, _ in } init(tab: VideoPageTab) { @@ -606,12 +606,13 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle if contentUpdateRevision != page.contentUpdateRevision { contentUpdateRevision = page.contentUpdateRevision host.rootView = page.content() - needsInitialHeaderOffsetReset = true + if !hasAppliedInitialListOffset && !hasExplicitPendingTopAlignment { + needsInitialHeaderOffsetReset = true + } view.setNeedsLayout() view.layoutIfNeeded() } - let previousVisualTopContentOffsetY = coordinator.visualTopContentOffsetY let geometry = page.headerGeometry let offsetContext = geometry.listOffsetContext(in: scrollView.bounds.height) let visualTopOffset = offsetContext.initialNormalizedOffsetY @@ -626,11 +627,6 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle applyInitialHeaderOffsetResetIfNeeded() } else if pendingTopAlignment != nil { resolvePendingTopAlignmentSoon() - } else { - preserveVisualTopIfNeeded( - previousOffsetY: previousVisualTopContentOffsetY, - targetOffsetY: visualTopOffset - ) } } @@ -646,18 +642,13 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle if abs(contentMinimumHeightConstraint.constant - offsetContext.minimumContentHeight) > 0.5 { contentMinimumHeightConstraint.constant = offsetContext.minimumContentHeight } - let visualTopOffset = offsetContext.initialNormalizedOffsetY if pendingTopAlignment != nil { resolvePendingTopAlignmentIfPossible() return } if needsInitialHeaderOffsetReset { applyInitialHeaderOffsetResetIfNeeded() - return } - guard page.isSelected else { return } - guard scrollView.verticalContentOffsetExcludingBounce <= visualTopOffset + 8 else { return } - requestTopAlignment(targetOffsetY: visualTopOffset) } private func applyTopContentInset(_ topInset: CGFloat) { @@ -687,18 +678,10 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle listHeaderView.frame = nextFrame } - private func requestTopAlignment(targetOffsetY: CGFloat) { - topAlignmentGeneration &+= 1 - pendingTopAlignment = .explicit(targetOffsetY) - resolvePendingTopAlignmentSoon() - } - private func resolvePendingTopAlignmentSoon() { - let generation = topAlignmentGeneration resolvePendingTopAlignmentIfPossible() DispatchQueue.main.async { [weak self] in - guard let self, self.topAlignmentGeneration == generation else { return } - self.resolvePendingTopAlignmentIfPossible() + self?.resolvePendingTopAlignmentIfPossible() } } @@ -710,6 +693,7 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle guard setNormalizedContentOffsetYIfReachable(targetOffsetY) else { return } if abs(scrollView.verticalContentOffsetExcludingBounce - targetOffsetY) <= 0.5 { needsInitialHeaderOffsetReset = false + hasAppliedInitialListOffset = true cancelPendingTopAlignment() } } @@ -724,10 +708,12 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle ).initialNormalizedOffsetY guard setNormalizedContentOffsetYIfReachable(targetOffsetY) else { pendingTopAlignment = .initial + resolvePendingTopAlignmentSoon() return } if abs(scrollView.verticalContentOffsetExcludingBounce - targetOffsetY) <= 0.5 { needsInitialHeaderOffsetReset = false + hasAppliedInitialListOffset = true cancelPendingTopAlignment() } } @@ -768,8 +754,8 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle cancelPendingTopAlignment() return } - requestTopAlignment(targetOffsetY: targetOffsetY) - resolvePendingTopAlignmentIfPossible(allowDuringInteraction: true) + setNormalizedContentOffsetY(targetOffsetY) + hasAppliedInitialListOffset = true } var normalizedContentOffsetY: CGFloat { @@ -794,9 +780,11 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle ).inactiveSyncNormalizedOffsetY guard setNormalizedContentOffsetYIfReachable(syncOffsetY) else { pendingTopAlignment = .explicit(syncOffsetY) + resolvePendingTopAlignmentSoon() return } needsInitialHeaderOffsetReset = false + hasAppliedInitialListOffset = true } func setHorizontalPagingActive(_ isActive: Bool) { @@ -813,12 +801,6 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle coordinator.onOffsetChange(coordinator.tab, offset) } - private func preserveVisualTopIfNeeded(previousOffsetY: CGFloat, targetOffsetY: CGFloat) { - guard abs(previousOffsetY - targetOffsetY) > 0.5 else { return } - guard abs(scrollView.verticalContentOffsetExcludingBounce - previousOffsetY) <= 1 else { return } - setNormalizedContentOffsetY(targetOffsetY) - } - private func setNormalizedContentOffsetY(_ offsetY: CGFloat) { guard let page = lastAppliedPage else { return } let rawTopOffsetY = page.headerGeometry.rawContentOffsetY(forNormalizedOffsetY: offsetY, in: scrollView) From 008a7478a4029551e88d691e8aeabe6cc3264715 Mon Sep 17 00:00:00 2001 From: pgs666 <74764545+pgs666@users.noreply.github.com> Date: Sun, 7 Jun 2026 10:54:45 +0800 Subject: [PATCH 120/216] Move continuation strip into pager header --- iosApp/VideoDetailPager.swift | 109 ++++++++++++++++++++++++++-------- iosApp/VideoDetailView.swift | 14 +++-- 2 files changed, 93 insertions(+), 30 deletions(-) diff --git a/iosApp/VideoDetailPager.swift b/iosApp/VideoDetailPager.swift index dde93e59..3493f6af 100644 --- a/iosApp/VideoDetailPager.swift +++ b/iosApp/VideoDetailPager.swift @@ -320,7 +320,7 @@ private struct VideoDetailTabPage { } } -struct VideoDetailPagerContainer: View { +struct VideoDetailPagerContainer: View { @Binding var state: VideoDetailPagerState let collapseDistance: CGFloat let headerHeight: CGFloat @@ -331,6 +331,8 @@ struct VideoDetailPagerContainer: View { let commentsContentBottomPadding: CGFloat let introductionContentRevision: Int let commentsContentRevision: Int + let continuationHeader: () -> ContinuationHeader + let isContinuationHeaderInteractive: Bool let introduction: () -> Introduction let comments: () -> Comments @@ -346,6 +348,9 @@ struct VideoDetailPagerContainer: View { var body: some View { VideoDetailTabPager( selectedTab: selectedTabBinding, + continuationHeader: AnyView(continuationHeader()), + isContinuationHeaderInteractive: isContinuationHeaderInteractive, + pinHeader: AnyView(pinHeader), introduction: tabPage( .introduction, contentBottomPadding: introductionContentBottomPadding, @@ -366,6 +371,19 @@ struct VideoDetailPagerContainer: View { } } + private var pinHeader: some View { + Picker("Content", selection: selectedTabBinding) { + ForEach(VideoPageTab.allCases) { tab in + Text(tab.title).tag(tab) + } + } + .pickerStyle(.segmented) + .padding(.horizontal, 16) + .frame(height: pinHeaderHeight) + .frame(maxWidth: .infinity) + .background(.background) + } + private func tabPage( _ tab: VideoPageTab, contentBottomPadding: CGFloat, @@ -891,15 +909,24 @@ private extension CGRect { private struct VideoDetailTabPager: UIViewControllerRepresentable { @Binding var selectedTab: VideoPageTab + let continuationHeader: AnyView + let isContinuationHeaderInteractive: Bool + let pinHeader: AnyView let introduction: VideoDetailTabPage let comments: VideoDetailTabPage init( selectedTab: Binding, + continuationHeader: AnyView, + isContinuationHeaderInteractive: Bool, + pinHeader: AnyView, introduction: VideoDetailTabPage, comments: VideoDetailTabPage ) { _selectedTab = selectedTab + self.continuationHeader = continuationHeader + self.isContinuationHeaderInteractive = isContinuationHeaderInteractive + self.pinHeader = pinHeader self.introduction = introduction self.comments = comments } @@ -915,6 +942,9 @@ private struct VideoDetailTabPager: UIViewControllerRepresentable { func updateUIViewController(_ uiViewController: PagingViewController, context: Context) { context.coordinator.selectedTab = $selectedTab uiViewController.updatePages( + continuationHeader: continuationHeader, + isContinuationHeaderInteractive: isContinuationHeaderInteractive, + pinHeader: pinHeader, introduction: introduction, comments: comments, selectedIndex: selectedTab.pageIndex, @@ -1003,6 +1033,7 @@ private struct VideoDetailTabPager: UIViewControllerRepresentable { private let scrollView = PagingScrollView() private let contentView = UIView() private let headerContainerView = PagingHeaderContainerView() + private let continuationHeaderHost = UIHostingController(rootView: AnyView(EmptyView())) private let pinHeaderHost = UIHostingController(rootView: AnyView(EmptyView())) private let introductionPage = VideoDetailVerticalScrollPageViewController(tab: .introduction) private let commentsPage = VideoDetailVerticalScrollPageViewController(tab: .comments) @@ -1012,7 +1043,6 @@ private struct VideoDetailTabPager: UIViewControllerRepresentable { private var lastLaidOutWidth: CGFloat = 0 private var isHorizontalPagingActive = false private var latestPages: [VideoPageTab: VideoDetailTabPage] = [:] - private var selectedTabBinding: Binding? init(coordinator: Coordinator) { self.coordinator = coordinator @@ -1059,6 +1089,10 @@ private struct VideoDetailTabPager: UIViewControllerRepresentable { headerContainerView.backgroundColor = .clear headerContainerView.pinHeaderHeight = 48 + addChild(continuationHeaderHost) + continuationHeaderHost.view.backgroundColor = .clear + headerContainerView.addSubview(continuationHeaderHost.view) + continuationHeaderHost.didMove(toParent: self) addChild(pinHeaderHost) pinHeaderHost.view.backgroundColor = .clear headerContainerView.addSubview(pinHeaderHost.view) @@ -1105,7 +1139,7 @@ private struct VideoDetailTabPager: UIViewControllerRepresentable { let width = scrollView.bounds.width let widthChanged = abs(width - lastLaidOutWidth) > 0.5 lastLaidOutWidth = width - layoutPinHeaderHost() + layoutHeaderHosts() updateHeaderAttachmentForCurrentState() if let pendingSelectedIndex { self.pendingSelectedIndex = nil @@ -1116,6 +1150,9 @@ private struct VideoDetailTabPager: UIViewControllerRepresentable { } func updatePages( + continuationHeader: AnyView, + isContinuationHeaderInteractive: Bool, + pinHeader: AnyView, introduction: VideoDetailTabPage, comments: VideoDetailTabPage, selectedIndex: Int, @@ -1123,10 +1160,14 @@ private struct VideoDetailTabPager: UIViewControllerRepresentable { ) { loadViewIfNeeded() self.selectedIndex = min(max(selectedIndex, 0), VideoPageTab.allCases.count - 1) - selectedTabBinding = coordinator.selectedTab latestPages[.introduction] = introduction latestPages[.comments] = comments - updatePinHeaderHost(page: VideoPageTab.page(at: self.selectedIndex)) + updateHeaderHosts( + continuationHeader: continuationHeader, + isContinuationHeaderInteractive: isContinuationHeaderInteractive, + pinHeader: pinHeader, + page: VideoPageTab.page(at: self.selectedIndex) + ) introductionPage.update(page: introduction) commentsPage.update(page: comments) updateHeaderAttachmentForCurrentState() @@ -1171,7 +1212,7 @@ private struct VideoDetailTabPager: UIViewControllerRepresentable { private func settlePageAfterHorizontalSelection(_ index: Int) { lastSettledSelectedIndex = min(max(index, 0), VideoPageTab.allCases.count - 1) - updatePinHeaderHost(page: VideoPageTab.page(at: index)) + layoutHeaderHosts() switch VideoPageTab.page(at: index) { case .introduction: introductionPage.settleAfterHorizontalActivation() @@ -1215,35 +1256,40 @@ private struct VideoDetailTabPager: UIViewControllerRepresentable { } } - private func updatePinHeaderHost(page tab: VideoPageTab) { - guard let selectedTabBinding else { return } + private func updateHeaderHosts( + continuationHeader: AnyView, + isContinuationHeaderInteractive: Bool, + pinHeader: AnyView, + page tab: VideoPageTab + ) { guard let page = latestPages[tab] else { return } headerContainerView.pinHeaderHeight = page.headerGeometry.pinHeaderHeight - pinHeaderHost.rootView = AnyView( - Picker("Content", selection: selectedTabBinding) { - ForEach(VideoPageTab.allCases) { tab in - Text(tab.title).tag(tab) - } - } - .pickerStyle(.segmented) - .padding(.horizontal, 16) - .frame(height: page.headerGeometry.pinHeaderHeight) - .frame(maxWidth: .infinity) - .background(.background) - ) - layoutPinHeaderHost() + headerContainerView.continuationHeaderHeight = continuationHeaderHeight(for: page) + headerContainerView.isContinuationHeaderInteractive = isContinuationHeaderInteractive + continuationHeaderHost.rootView = continuationHeader + pinHeaderHost.rootView = pinHeader + layoutHeaderHosts() } - private func layoutPinHeaderHost() { + private func layoutHeaderHosts() { guard let page = latestPages[VideoPageTab.page(at: selectedIndex)] else { return } let width = view.bounds.width let contentTopInset = page.headerGeometry.contentTopInset + let continuationHeight = continuationHeaderHeight(for: page) + headerContainerView.continuationHeaderHeight = continuationHeight + headerContainerView.pinHeaderHeight = page.headerGeometry.pinHeaderHeight headerContainerView.frame = CGRect( x: 0, y: headerContainerView.frame.origin.y, width: width, height: contentTopInset ) + continuationHeaderHost.view.frame = CGRect( + x: 0, + y: max(page.headerGeometry.headerHeight - continuationHeight, 0), + width: width, + height: continuationHeight + ) pinHeaderHost.view.frame = CGRect( x: 0, y: page.headerGeometry.headerHeight, @@ -1252,9 +1298,13 @@ private struct VideoDetailTabPager: UIViewControllerRepresentable { ) } + private func continuationHeaderHeight(for page: VideoDetailTabPage) -> CGFloat { + max(page.headerGeometry.pinnedVisibleHeight - page.headerGeometry.pinHeaderHeight, 0) + } + private func updateHeaderAttachmentForCurrentState() { guard let page = latestPages[VideoPageTab.page(at: selectedIndex)] else { return } - layoutPinHeaderHost() + layoutHeaderHosts() if isHorizontalPagingActive { attachHeaderToPagerContainer(page: page) return @@ -1314,15 +1364,26 @@ private struct VideoDetailTabPager: UIViewControllerRepresentable { final class PagingHeaderContainerView: UIView { var pinHeaderHeight: CGFloat = 0 + var continuationHeaderHeight: CGFloat = 0 + var isContinuationHeaderInteractive = false override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { + let continuationHeaderFrame = CGRect( + x: 0, + y: max(bounds.height - pinHeaderHeight - continuationHeaderHeight, 0), + width: bounds.width, + height: continuationHeaderHeight + ) let pinHeaderFrame = CGRect( x: 0, y: bounds.height - pinHeaderHeight, width: bounds.width, height: pinHeaderHeight ) - guard pinHeaderFrame.contains(point) else { return nil } + guard pinHeaderFrame.contains(point) + || (isContinuationHeaderInteractive && continuationHeaderFrame.contains(point)) else { + return nil + } return super.hitTest(point, with: event) } } diff --git a/iosApp/VideoDetailView.swift b/iosApp/VideoDetailView.swift index f3e531e5..60b998f9 100644 --- a/iosApp/VideoDetailView.swift +++ b/iosApp/VideoDetailView.swift @@ -271,6 +271,7 @@ struct VideoDetailView: View { pinHeaderHeight: pagerPinHeaderHeight, pinnedVisibleHeight: playerContinuationStripHeight + pagerPinHeaderHeight, playerScrollAway: pagerMetrics.playerScrollAway, + continuationProgress: pagerMetrics.continuationProgress, introductionContentClearance: introductionContentClearance(), composerContentClearance: commentComposerContentClearance( safeAreaBottom: proxy.safeAreaInsets.bottom @@ -286,12 +287,6 @@ struct VideoDetailView: View { height: currentPlayerHeight ) .offset(y: isPlayerFullscreen ? 0 : -pagerMetrics.playerScrollAway) - - if !isPlayerFullscreen, - pagerMetrics.continuationProgress > 0 { - continuePlayingStrip(snapshot: snapshot, progress: pagerMetrics.continuationProgress) - .frame(width: leftWidth, height: playerContinuationStripHeight) - } } .frame(width: leftWidth, height: proxy.size.height, alignment: .top) .clipped() @@ -406,6 +401,7 @@ struct VideoDetailView: View { pinHeaderHeight: CGFloat, pinnedVisibleHeight: CGFloat, playerScrollAway: CGFloat, + continuationProgress: CGFloat, introductionContentClearance: CGFloat, composerContentClearance: CGFloat ) -> some View { @@ -422,6 +418,12 @@ struct VideoDetailView: View { commentsContentBottomPadding: composerContentClearance, introductionContentRevision: contentRevision.introduction, commentsContentRevision: contentRevision.comments, + continuationHeader: { + continuePlayingStrip(snapshot: snapshot, progress: continuationProgress) + .frame(height: playerContinuationStripHeight) + .opacity(isPlayerFullscreen ? 0 : 1) + }, + isContinuationHeaderInteractive: !isPlayerFullscreen && continuationProgress > 0.05, introduction: { AndroidStyleIntroduction( snapshot: snapshot, From 889780e563fb2f24f44ab92d8a1defef4e9ddfb3 Mon Sep 17 00:00:00 2001 From: pgs666 <74764545+pgs666@users.noreply.github.com> Date: Sun, 7 Jun 2026 11:04:23 +0800 Subject: [PATCH 121/216] Stabilize pager header host updates --- iosApp/VideoDetailPager.swift | 36 +++++++++++++++++++++++++++++++++-- iosApp/VideoDetailView.swift | 12 ++++++++++-- 2 files changed, 44 insertions(+), 4 deletions(-) diff --git a/iosApp/VideoDetailPager.swift b/iosApp/VideoDetailPager.swift index 3493f6af..7ce02581 100644 --- a/iosApp/VideoDetailPager.swift +++ b/iosApp/VideoDetailPager.swift @@ -327,10 +327,12 @@ struct VideoDetailPagerContainer ContinuationHeader let isContinuationHeaderInteractive: Bool let introduction: () -> Introduction @@ -348,7 +350,9 @@ struct VideoDetailPagerContainer, + headerContentRevision: Int, continuationHeader: AnyView, + continuationProgress: CGFloat, isContinuationHeaderInteractive: Bool, pinHeader: AnyView, introduction: VideoDetailTabPage, comments: VideoDetailTabPage ) { _selectedTab = selectedTab + self.headerContentRevision = headerContentRevision self.continuationHeader = continuationHeader + self.continuationProgress = continuationProgress self.isContinuationHeaderInteractive = isContinuationHeaderInteractive self.pinHeader = pinHeader self.introduction = introduction @@ -942,7 +952,9 @@ private struct VideoDetailTabPager: UIViewControllerRepresentable { func updateUIViewController(_ uiViewController: PagingViewController, context: Context) { context.coordinator.selectedTab = $selectedTab uiViewController.updatePages( + headerContentRevision: headerContentRevision, continuationHeader: continuationHeader, + continuationProgress: continuationProgress, isContinuationHeaderInteractive: isContinuationHeaderInteractive, pinHeader: pinHeader, introduction: introduction, @@ -1042,6 +1054,7 @@ private struct VideoDetailTabPager: UIViewControllerRepresentable { private var pendingSelectedIndex: Int? private var lastLaidOutWidth: CGFloat = 0 private var isHorizontalPagingActive = false + private var headerContentRevision: Int? private var latestPages: [VideoPageTab: VideoDetailTabPage] = [:] init(coordinator: Coordinator) { @@ -1150,7 +1163,9 @@ private struct VideoDetailTabPager: UIViewControllerRepresentable { } func updatePages( + headerContentRevision: Int, continuationHeader: AnyView, + continuationProgress: CGFloat, isContinuationHeaderInteractive: Bool, pinHeader: AnyView, introduction: VideoDetailTabPage, @@ -1163,7 +1178,9 @@ private struct VideoDetailTabPager: UIViewControllerRepresentable { latestPages[.introduction] = introduction latestPages[.comments] = comments updateHeaderHosts( + headerContentRevision: headerContentRevision, continuationHeader: continuationHeader, + continuationProgress: continuationProgress, isContinuationHeaderInteractive: isContinuationHeaderInteractive, pinHeader: pinHeader, page: VideoPageTab.page(at: self.selectedIndex) @@ -1257,7 +1274,9 @@ private struct VideoDetailTabPager: UIViewControllerRepresentable { } private func updateHeaderHosts( + headerContentRevision: Int, continuationHeader: AnyView, + continuationProgress: CGFloat, isContinuationHeaderInteractive: Bool, pinHeader: AnyView, page tab: VideoPageTab @@ -1266,9 +1285,13 @@ private struct VideoDetailTabPager: UIViewControllerRepresentable { headerContainerView.pinHeaderHeight = page.headerGeometry.pinHeaderHeight headerContainerView.continuationHeaderHeight = continuationHeaderHeight(for: page) headerContainerView.isContinuationHeaderInteractive = isContinuationHeaderInteractive - continuationHeaderHost.rootView = continuationHeader - pinHeaderHost.rootView = pinHeader + if self.headerContentRevision != headerContentRevision { + self.headerContentRevision = headerContentRevision + continuationHeaderHost.rootView = continuationHeader + pinHeaderHost.rootView = pinHeader + } layoutHeaderHosts() + applyContinuationHeaderPresentation(progress: continuationProgress) } private func layoutHeaderHosts() { @@ -1302,6 +1325,15 @@ private struct VideoDetailTabPager: UIViewControllerRepresentable { max(page.headerGeometry.pinnedVisibleHeight - page.headerGeometry.pinHeaderHeight, 0) } + private func applyContinuationHeaderPresentation(progress: CGFloat) { + let clampedProgress = min(max(progress, 0), 1) + continuationHeaderHost.view.alpha = clampedProgress + continuationHeaderHost.view.transform = CGAffineTransform( + translationX: 0, + y: -8 * (1 - clampedProgress) + ) + } + private func updateHeaderAttachmentForCurrentState() { guard let page = latestPages[VideoPageTab.page(at: selectedIndex)] else { return } layoutHeaderHosts() diff --git a/iosApp/VideoDetailView.swift b/iosApp/VideoDetailView.swift index 60b998f9..deed9b9f 100644 --- a/iosApp/VideoDetailView.swift +++ b/iosApp/VideoDetailView.swift @@ -406,6 +406,7 @@ struct VideoDetailView: View { composerContentClearance: CGFloat ) -> some View { let contentRevision = tabContentRevision(snapshot: snapshot, showsRelated: showsRelated) + let headerContentRevision = pagerHeaderContentRevision(snapshot: snapshot) return VideoDetailPagerContainer( state: $pagerState, @@ -414,14 +415,15 @@ struct VideoDetailView: View { pinHeaderHeight: pinHeaderHeight, pinnedVisibleHeight: pinnedVisibleHeight, playerScrollAway: playerScrollAway, + continuationProgress: continuationProgress, introductionContentBottomPadding: introductionContentClearance, commentsContentBottomPadding: composerContentClearance, introductionContentRevision: contentRevision.introduction, commentsContentRevision: contentRevision.comments, + headerContentRevision: headerContentRevision, continuationHeader: { - continuePlayingStrip(snapshot: snapshot, progress: continuationProgress) + continuePlayingStrip(snapshot: snapshot, progress: 1) .frame(height: playerContinuationStripHeight) - .opacity(isPlayerFullscreen ? 0 : 1) }, isContinuationHeaderInteractive: !isPlayerFullscreen && continuationProgress > 0.05, introduction: { @@ -538,6 +540,12 @@ struct VideoDetailView: View { comments: commentsHasher.finalize() ) } + + private func pagerHeaderContentRevision(snapshot: VideoDetailScreenSnapshot) -> Int { + var hasher = Hasher() + snapshot.hash(into: &hasher) + return hasher.finalize() + } } private struct CommentComposerBar: View { From fe174ffc8d4b3e79848edcef0f92175719172166 Mon Sep 17 00:00:00 2001 From: pgs666 <74764545+pgs666@users.noreply.github.com> Date: Sun, 7 Jun 2026 11:16:30 +0800 Subject: [PATCH 122/216] Track pager header index during horizontal scroll --- iosApp/VideoDetailPager.swift | 61 +++++++++++++++++++++++++++++------ 1 file changed, 52 insertions(+), 9 deletions(-) diff --git a/iosApp/VideoDetailPager.swift b/iosApp/VideoDetailPager.swift index 7ce02581..7ba31714 100644 --- a/iosApp/VideoDetailPager.swift +++ b/iosApp/VideoDetailPager.swift @@ -968,6 +968,7 @@ private struct VideoDetailTabPager: UIViewControllerRepresentable { var selectedTab: Binding var onSelectedIndexSettled: ((Int) -> Void)? var onPagingActivityChanged: ((Bool) -> Void)? + var onHorizontalVisibleIndexChanged: ((Int) -> Void)? private var lastProgrammaticIndex: Int? private var pendingSettledIndex: Int? @@ -985,20 +986,27 @@ private struct VideoDetailTabPager: UIViewControllerRepresentable { onPagingActivityChanged?(true) } + func scrollViewDidScroll(_ scrollView: UIScrollView) { + let width = scrollView.bounds.width + guard width > 0 else { return } + let index = Int(scrollView.contentOffset.x / width) + onHorizontalVisibleIndexChanged?(min(max(index, 0), VideoPageTab.allCases.count - 1)) + } + func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) { - onPagingActivityChanged?(false) updateSelectedTab(from: scrollView) + onPagingActivityChanged?(false) } func scrollViewDidEndScrollingAnimation(_ scrollView: UIScrollView) { - onPagingActivityChanged?(false) updateSelectedTab(from: scrollView) + onPagingActivityChanged?(false) } func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) { if !decelerate { - onPagingActivityChanged?(false) updateSelectedTab(from: scrollView) + onPagingActivityChanged?(false) } } @@ -1050,6 +1058,7 @@ private struct VideoDetailTabPager: UIViewControllerRepresentable { private let introductionPage = VideoDetailVerticalScrollPageViewController(tab: .introduction) private let commentsPage = VideoDetailVerticalScrollPageViewController(tab: .comments) private var selectedIndex = 0 + private var headerVisibleIndex = 0 private var lastSettledSelectedIndex: Int? private var pendingSelectedIndex: Int? private var lastLaidOutWidth: CGFloat = 0 @@ -1094,6 +1103,9 @@ private struct VideoDetailTabPager: UIViewControllerRepresentable { coordinator.onPagingActivityChanged = { [weak self] isActive in self?.setHorizontalPagingActive(isActive) } + coordinator.onHorizontalVisibleIndexChanged = { [weak self] index in + self?.setHeaderVisibleIndex(index) + } view.addSubview(scrollView) contentView.translatesAutoresizingMaskIntoConstraints = false @@ -1175,6 +1187,9 @@ private struct VideoDetailTabPager: UIViewControllerRepresentable { ) { loadViewIfNeeded() self.selectedIndex = min(max(selectedIndex, 0), VideoPageTab.allCases.count - 1) + if !isHorizontalPagingActive { + headerVisibleIndex = self.selectedIndex + } latestPages[.introduction] = introduction latestPages[.comments] = comments updateHeaderHosts( @@ -1188,7 +1203,9 @@ private struct VideoDetailTabPager: UIViewControllerRepresentable { introductionPage.update(page: introduction) commentsPage.update(page: comments) updateHeaderAttachmentForCurrentState() - syncInactivePageHeaderOffset() + if !isHorizontalPagingActive { + syncInactivePageHeaderOffset() + } let consumedSettledIndex = coordinator.consumePendingSettledIndex(for: self.selectedIndex) guard !scrollView.isTracking, !scrollView.isDragging, !scrollView.isDecelerating else { return } setSelectedIndex(self.selectedIndex, animated: animated) @@ -1207,6 +1224,9 @@ private struct VideoDetailTabPager: UIViewControllerRepresentable { private func setSelectedIndex(_ index: Int, animated: Bool) { selectedIndex = min(max(index, 0), VideoPageTab.allCases.count - 1) + if !isHorizontalPagingActive { + headerVisibleIndex = selectedIndex + } let width = scrollView.bounds.width guard width > 0 else { pendingSelectedIndex = selectedIndex @@ -1229,6 +1249,7 @@ private struct VideoDetailTabPager: UIViewControllerRepresentable { private func settlePageAfterHorizontalSelection(_ index: Int) { lastSettledSelectedIndex = min(max(index, 0), VideoPageTab.allCases.count - 1) + headerVisibleIndex = lastSettledSelectedIndex ?? selectedIndex layoutHeaderHosts() switch VideoPageTab.page(at: index) { case .introduction: @@ -1247,12 +1268,29 @@ private struct VideoDetailTabPager: UIViewControllerRepresentable { isHorizontalPagingActive = isActive introductionPage.setHorizontalPagingActive(isActive) commentsPage.setHorizontalPagingActive(isActive) + if !isActive { + headerVisibleIndex = settledIndexFromHorizontalOffset() + } updateHeaderAttachmentForCurrentState() if !isActive { syncInactivePageHeaderOffset() } } + private func setHeaderVisibleIndex(_ index: Int) { + let clampedIndex = min(max(index, 0), VideoPageTab.allCases.count - 1) + guard headerVisibleIndex != clampedIndex else { return } + headerVisibleIndex = clampedIndex + updateHeaderAttachmentForCurrentState() + } + + private func settledIndexFromHorizontalOffset() -> Int { + let width = scrollView.bounds.width + guard width > 0 else { return selectedIndex } + let index = Int(round(scrollView.contentOffset.x / width)) + return min(max(index, 0), VideoPageTab.allCases.count - 1) + } + private func syncInactivePageHeaderOffset() { guard let activePage = verticalPageController(for: VideoPageTab.page(at: selectedIndex)) else { return @@ -1295,7 +1333,7 @@ private struct VideoDetailTabPager: UIViewControllerRepresentable { } private func layoutHeaderHosts() { - guard let page = latestPages[VideoPageTab.page(at: selectedIndex)] else { return } + guard let page = latestPages[activeHeaderTab] else { return } let width = view.bounds.width let contentTopInset = page.headerGeometry.contentTopInset let continuationHeight = continuationHeaderHeight(for: page) @@ -1335,13 +1373,14 @@ private struct VideoDetailTabPager: UIViewControllerRepresentable { } private func updateHeaderAttachmentForCurrentState() { - guard let page = latestPages[VideoPageTab.page(at: selectedIndex)] else { return } + let headerTab = activeHeaderTab + guard let page = latestPages[headerTab] else { return } layoutHeaderHosts() if isHorizontalPagingActive { attachHeaderToPagerContainer(page: page) return } - let selectedPageController = verticalPageController(for: VideoPageTab.page(at: selectedIndex)) + let selectedPageController = verticalPageController(for: headerTab) guard let selectedPageController else { return } let selectedOffset = selectedPageController.normalizedContentOffsetY let shouldAttachToListHeader = selectedOffset <= page.headerGeometry.collapseDistance + 0.5 @@ -1353,11 +1392,15 @@ private struct VideoDetailTabPager: UIViewControllerRepresentable { } private func attachHeaderToPagerContainer(page: VideoDetailTabPage) { - let offset = verticalPageController(for: VideoPageTab.page(at: selectedIndex))?.normalizedContentOffsetY ?? 0 + let offset = verticalPageController(for: activeHeaderTab)?.normalizedContentOffsetY ?? 0 let headerOffset = min(max(offset, 0), page.headerGeometry.collapseDistance) attachHeader(to: view, originY: -headerOffset) } + private var activeHeaderTab: VideoPageTab { + VideoPageTab.page(at: isHorizontalPagingActive ? headerVisibleIndex : selectedIndex) + } + private func attachHeader(to parentView: UIView, originY: CGFloat) { if headerContainerView.superview !== parentView { headerContainerView.removeFromSuperview() @@ -1371,7 +1414,7 @@ private struct VideoDetailTabPager: UIViewControllerRepresentable { } private func updateHeaderContainerPosition(for tab: VideoPageTab, offsetY: CGFloat) { - guard tab.pageIndex == selectedIndex else { return } + guard tab == activeHeaderTab else { return } guard let page = latestPages[tab] else { return } if isHorizontalPagingActive { let headerOffset = min(max(offsetY, 0), page.headerGeometry.collapseDistance) From 8853012b3017efcb38104eca39a9a95fc1af6fbf Mon Sep 17 00:00:00 2001 From: pgs666 <74764545+pgs666@users.noreply.github.com> Date: Sun, 7 Jun 2026 11:24:34 +0800 Subject: [PATCH 123/216] Model pager inactive sync states --- iosApp/VideoDetailPager.swift | 46 +++++++++++++++++++++++------------ 1 file changed, 30 insertions(+), 16 deletions(-) diff --git a/iosApp/VideoDetailPager.swift b/iosApp/VideoDetailPager.swift index 7ba31714..6c040120 100644 --- a/iosApp/VideoDetailPager.swift +++ b/iosApp/VideoDetailPager.swift @@ -29,6 +29,12 @@ enum VideoPlayerCollapseModel { } enum VideoDetailPagerOffsetModel { + enum InactiveSyncMode: Equatable { + case visualTop(CGFloat) + case scrollingHeader(CGFloat) + case pinned(CGFloat) + } + static func initialNormalizedOffsetY( visualTopOffset: CGFloat, collapseDistance: CGFloat @@ -36,13 +42,16 @@ enum VideoDetailPagerOffsetModel { clamp(visualTopOffset, upperBound: collapseDistance) } - static func inactiveSyncNormalizedOffsetY( + static func inactiveSyncMode( activeOffset: CGFloat?, initialOffset: CGFloat, collapseDistance: CGFloat - ) -> CGFloat { - guard let activeOffset else { return initialOffset } - return clamp(activeOffset, upperBound: collapseDistance) + ) -> InactiveSyncMode { + guard let activeOffset else { return .visualTop(initialOffset) } + if activeOffset < collapseDistance { + return .scrollingHeader(max(activeOffset, 0)) + } + return .pinned(max(collapseDistance, 0)) } static func minimumContentHeight( @@ -69,6 +78,15 @@ enum VideoDetailPagerOffsetModel { } } +private extension VideoDetailPagerOffsetModel.InactiveSyncMode { + var normalizedOffsetY: CGFloat { + switch self { + case .visualTop(let offsetY), .scrollingHeader(let offsetY), .pinned(let offsetY): + return offsetY + } + } +} + struct VideoDetailPagerLayoutMetrics { let collapseDistance: CGFloat let playerScrollAway: CGFloat @@ -241,7 +259,7 @@ private struct VideoDetailSmoothHeaderGeometry: Equatable { return VideoDetailListOffsetContext( contentTopInset: contentTopInset, initialNormalizedOffsetY: visualTopOffset, - inactiveSyncNormalizedOffsetY: VideoDetailPagerOffsetModel.inactiveSyncNormalizedOffsetY( + inactiveSyncMode: VideoDetailPagerOffsetModel.inactiveSyncMode( activeOffset: activeOffset, initialOffset: visualTopOffset, collapseDistance: collapseDistance @@ -276,7 +294,7 @@ private struct VideoDetailSmoothHeaderGeometry: Equatable { private struct VideoDetailListOffsetContext: Equatable { let contentTopInset: CGFloat let initialNormalizedOffsetY: CGFloat - let inactiveSyncNormalizedOffsetY: CGFloat + let inactiveSyncMode: VideoDetailPagerOffsetModel.InactiveSyncMode let collapseSpacerHeight: CGFloat let minimumContentHeight: CGFloat } @@ -785,21 +803,17 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle return scrollView.verticalContentOffsetExcludingBounce } - func headerSyncOffset(fromActiveOffset activeOffset: CGFloat) -> CGFloat { + func headerSyncMode(fromActiveOffset activeOffset: CGFloat) -> VideoDetailPagerOffsetModel.InactiveSyncMode { lastAppliedPage?.headerGeometry.listOffsetContext( in: scrollView.bounds.height, activeOffset: activeOffset - ).inactiveSyncNormalizedOffsetY ?? 0 + ).inactiveSyncMode ?? .visualTop(0) } - func syncHeaderOffsetFromActivePage(_ offsetY: CGFloat) { + func syncHeaderOffsetFromActivePage(_ syncMode: VideoDetailPagerOffsetModel.InactiveSyncMode) { loadViewIfNeeded() - guard let page = lastAppliedPage else { return } cancelPendingTopAlignment() - let syncOffsetY = page.headerGeometry.listOffsetContext( - in: scrollView.bounds.height, - activeOffset: offsetY - ).inactiveSyncNormalizedOffsetY + let syncOffsetY = syncMode.normalizedOffsetY guard setNormalizedContentOffsetYIfReachable(syncOffsetY) else { pendingTopAlignment = .explicit(syncOffsetY) resolvePendingTopAlignmentSoon() @@ -1296,9 +1310,9 @@ private struct VideoDetailTabPager: UIViewControllerRepresentable { return } let activeOffset = activePage.normalizedContentOffsetY - let syncOffset = activePage.headerSyncOffset(fromActiveOffset: activeOffset) + let syncMode = activePage.headerSyncMode(fromActiveOffset: activeOffset) for tab in VideoPageTab.allCases where tab.pageIndex != selectedIndex { - verticalPageController(for: tab)?.syncHeaderOffsetFromActivePage(syncOffset) + verticalPageController(for: tab)?.syncHeaderOffsetFromActivePage(syncMode) } } From 4dbc47f4fef43c549e40de4ed053cb9d6cf793a2 Mon Sep 17 00:00:00 2001 From: pgs666 <74764545+pgs666@users.noreply.github.com> Date: Sun, 7 Jun 2026 11:38:20 +0800 Subject: [PATCH 124/216] Align pager activation with JX semantics --- iosApp/VideoDetailPager.swift | 49 +++++++++++++++++++++++++++++------ 1 file changed, 41 insertions(+), 8 deletions(-) diff --git a/iosApp/VideoDetailPager.swift b/iosApp/VideoDetailPager.swift index 6c040120..4220813c 100644 --- a/iosApp/VideoDetailPager.swift +++ b/iosApp/VideoDetailPager.swift @@ -531,6 +531,7 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle private var pendingTopAlignment: VideoDetailPendingTopAlignment? private var needsInitialHeaderOffsetReset = true private var hasAppliedInitialListOffset = false + private var hasCompletedFirstActiveAlignment = false var onHeaderOffsetChanged: (VideoPageTab, CGFloat) -> Void = { _, _ in } init(tab: VideoPageTab) { @@ -646,6 +647,7 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle if contentUpdateRevision != page.contentUpdateRevision { contentUpdateRevision = page.contentUpdateRevision host.rootView = page.content() + hasCompletedFirstActiveAlignment = false if !hasAppliedInitialListOffset && !hasExplicitPendingTopAlignment { needsInitialHeaderOffsetReset = true } @@ -686,6 +688,10 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle resolvePendingTopAlignmentIfPossible() return } + if page.isSelected, !hasCompletedFirstActiveAlignment { + applyFirstActiveAlignmentIfNeeded() + return + } if needsInitialHeaderOffsetReset { applyInitialHeaderOffsetResetIfNeeded() } @@ -782,11 +788,33 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle func settleAfterHorizontalActivation() { loadViewIfNeeded() - let targetOffsetY = coordinator.visualTopContentOffsetY + applyFirstActiveAlignmentIfNeeded(allowDuringInteraction: true) + } + + private func applyFirstActiveAlignmentIfNeeded(allowDuringInteraction: Bool = false) { + guard let page = lastAppliedPage else { return } + let targetOffsetY = page.headerGeometry.listOffsetContext( + in: scrollView.bounds.height + ).initialNormalizedOffsetY + coordinator.visualTopContentOffsetY = targetOffsetY if needsInitialHeaderOffsetReset { - applyInitialHeaderOffsetResetIfNeeded(allowDuringInteraction: true) + applyInitialHeaderOffsetResetIfNeeded(allowDuringInteraction: allowDuringInteraction) guard !needsInitialHeaderOffsetReset else { return } } + if !allowDuringInteraction { + guard !scrollView.isTracking, !scrollView.isDragging, !scrollView.isDecelerating else { return } + } + if !hasCompletedFirstActiveAlignment { + guard setNormalizedContentOffsetYIfReachable(targetOffsetY) else { + pendingTopAlignment = .initial + resolvePendingTopAlignmentSoon() + return + } + hasAppliedInitialListOffset = true + hasCompletedFirstActiveAlignment = true + cancelPendingTopAlignment() + return + } guard VideoDetailPagerOffsetModel.shouldAlignToVisualTopAfterHorizontalActivation( currentOffset: scrollView.verticalContentOffsetExcludingBounce, visualTopOffset: targetOffsetY @@ -796,6 +824,7 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle } setNormalizedContentOffsetY(targetOffsetY) hasAppliedInitialListOffset = true + hasCompletedFirstActiveAlignment = true } var normalizedContentOffsetY: CGFloat { @@ -1046,18 +1075,16 @@ private struct VideoDetailTabPager: UIViewControllerRepresentable { guard width > 0 else { return } let index = Int(round(scrollView.contentOffset.x / width)) let tab = VideoPageTab.page(at: index) + onSelectedIndexSettled?(index) if selectedTab.wrappedValue != tab { pendingSettledIndex = index selectedTab.wrappedValue = tab - } else { - onSelectedIndexSettled?(index) } } func consumePendingSettledIndex(for selectedIndex: Int) -> Bool { guard pendingSettledIndex == selectedIndex else { return false } pendingSettledIndex = nil - onSelectedIndexSettled?(selectedIndex) return true } } @@ -1257,13 +1284,19 @@ private struct VideoDetailTabPager: UIViewControllerRepresentable { private func settleSelectedPageIfNeeded(_ index: Int) { let clampedIndex = min(max(index, 0), VideoPageTab.allCases.count - 1) guard lastSettledSelectedIndex != clampedIndex else { return } - lastSettledSelectedIndex = clampedIndex settlePageAfterHorizontalSelection(clampedIndex) } private func settlePageAfterHorizontalSelection(_ index: Int) { - lastSettledSelectedIndex = min(max(index, 0), VideoPageTab.allCases.count - 1) - headerVisibleIndex = lastSettledSelectedIndex ?? selectedIndex + let clampedIndex = min(max(index, 0), VideoPageTab.allCases.count - 1) + selectedIndex = clampedIndex + guard lastSettledSelectedIndex != clampedIndex else { + headerVisibleIndex = clampedIndex + updateHeaderAttachmentForCurrentState() + return + } + lastSettledSelectedIndex = clampedIndex + headerVisibleIndex = clampedIndex layoutHeaderHosts() switch VideoPageTab.page(at: index) { case .introduction: From f562c39ca99aec9a80803768c29083a873f00e0d Mon Sep 17 00:00:00 2001 From: pgs666 <74764545+pgs666@users.noreply.github.com> Date: Sun, 7 Jun 2026 11:46:31 +0800 Subject: [PATCH 125/216] Reapply pager activation after content resize --- iosApp/VideoDetailPager.swift | 30 +++++++++++++++++++++++++++++- 1 file changed, 29 insertions(+), 1 deletion(-) diff --git a/iosApp/VideoDetailPager.swift b/iosApp/VideoDetailPager.swift index 4220813c..f247cc93 100644 --- a/iosApp/VideoDetailPager.swift +++ b/iosApp/VideoDetailPager.swift @@ -532,6 +532,8 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle private var needsInitialHeaderOffsetReset = true private var hasAppliedInitialListOffset = false private var hasCompletedFirstActiveAlignment = false + private var firstActiveAlignedContentHeight: CGFloat? + private var hasUserInteractedSinceFirstActiveAlignment = false var onHeaderOffsetChanged: (VideoPageTab, CGFloat) -> Void = { _, _ in } init(tab: VideoPageTab) { @@ -636,6 +638,7 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle coordinator.onInteractionBegan = page.onInteractionBegan coordinator.onTopPullDelta = page.onTopPullDelta coordinator.onVerticalInteractionBegan = { [weak self] in + self?.hasUserInteractedSinceFirstActiveAlignment = true self?.resolvePendingTopAlignmentIfPossible(allowDuringInteraction: true) } coordinator.onVisibleOffsetChange = { [weak self] tab, offset in @@ -648,6 +651,8 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle contentUpdateRevision = page.contentUpdateRevision host.rootView = page.content() hasCompletedFirstActiveAlignment = false + firstActiveAlignedContentHeight = nil + hasUserInteractedSinceFirstActiveAlignment = false if !hasAppliedInitialListOffset && !hasExplicitPendingTopAlignment { needsInitialHeaderOffsetReset = true } @@ -684,6 +689,10 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle if abs(contentMinimumHeightConstraint.constant - offsetContext.minimumContentHeight) > 0.5 { contentMinimumHeightConstraint.constant = offsetContext.minimumContentHeight } + if shouldReopenFirstActiveAlignmentAfterContentSizeChange(for: page) { + hasCompletedFirstActiveAlignment = false + firstActiveAlignedContentHeight = nil + } if pendingTopAlignment != nil { resolvePendingTopAlignmentIfPossible() return @@ -741,6 +750,9 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle needsInitialHeaderOffsetReset = false hasAppliedInitialListOffset = true cancelPendingTopAlignment() + if lastAppliedPage?.isSelected == true { + markFirstActiveAlignmentCompleted() + } } } @@ -811,7 +823,7 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle return } hasAppliedInitialListOffset = true - hasCompletedFirstActiveAlignment = true + markFirstActiveAlignmentCompleted() cancelPendingTopAlignment() return } @@ -824,7 +836,23 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle } setNormalizedContentOffsetY(targetOffsetY) hasAppliedInitialListOffset = true + markFirstActiveAlignmentCompleted() + } + + private func shouldReopenFirstActiveAlignmentAfterContentSizeChange(for page: VideoDetailTabPage) -> Bool { + guard page.isSelected, + hasCompletedFirstActiveAlignment, + !hasUserInteractedSinceFirstActiveAlignment, + let firstActiveAlignedContentHeight else { + return false + } + return abs(scrollView.contentSize.height - firstActiveAlignedContentHeight) > 0.5 + } + + private func markFirstActiveAlignmentCompleted() { hasCompletedFirstActiveAlignment = true + firstActiveAlignedContentHeight = scrollView.contentSize.height + hasUserInteractedSinceFirstActiveAlignment = false } var normalizedContentOffsetY: CGFloat { From 3910c81ac6059d2f838faa24fd50bbb54081d4d8 Mon Sep 17 00:00:00 2001 From: pgs666 <74764545+pgs666@users.noreply.github.com> Date: Sun, 7 Jun 2026 11:56:14 +0800 Subject: [PATCH 126/216] Model pager header sync state --- iosApp/VideoDetailPager.swift | 115 +++++++++++++++++++++++++--------- 1 file changed, 86 insertions(+), 29 deletions(-) diff --git a/iosApp/VideoDetailPager.swift b/iosApp/VideoDetailPager.swift index f247cc93..895d4b08 100644 --- a/iosApp/VideoDetailPager.swift +++ b/iosApp/VideoDetailPager.swift @@ -42,18 +42,6 @@ enum VideoDetailPagerOffsetModel { clamp(visualTopOffset, upperBound: collapseDistance) } - static func inactiveSyncMode( - activeOffset: CGFloat?, - initialOffset: CGFloat, - collapseDistance: CGFloat - ) -> InactiveSyncMode { - guard let activeOffset else { return .visualTop(initialOffset) } - if activeOffset < collapseDistance { - return .scrollingHeader(max(activeOffset, 0)) - } - return .pinned(max(collapseDistance, 0)) - } - static func minimumContentHeight( scrollBoundsHeight: CGFloat, pinnedVisibleHeight: CGFloat, @@ -78,6 +66,32 @@ enum VideoDetailPagerOffsetModel { } } +private struct VideoDetailSmoothHeaderSyncState: Equatable { + let isSyncingListOffsets: Bool + let headerContainerY: CGFloat + let inactiveSyncMode: VideoDetailPagerOffsetModel.InactiveSyncMode + + static func state( + activeOffset: CGFloat, + collapseDistance: CGFloat + ) -> VideoDetailSmoothHeaderSyncState { + let resolvedCollapseDistance = max(collapseDistance, 0) + if activeOffset < resolvedCollapseDistance { + let scrollingOffset = max(activeOffset, 0) + return VideoDetailSmoothHeaderSyncState( + isSyncingListOffsets: true, + headerContainerY: -scrollingOffset, + inactiveSyncMode: .scrollingHeader(scrollingOffset) + ) + } + return VideoDetailSmoothHeaderSyncState( + isSyncingListOffsets: false, + headerContainerY: -resolvedCollapseDistance, + inactiveSyncMode: .pinned(resolvedCollapseDistance) + ) + } +} + private extension VideoDetailPagerOffsetModel.InactiveSyncMode { var normalizedOffsetY: CGFloat { switch self { @@ -256,19 +270,25 @@ private struct VideoDetailSmoothHeaderGeometry: Equatable { activeOffset: CGFloat? = nil ) -> VideoDetailListOffsetContext { let visualTopOffset = resolvedVisualTopOffset + let inactiveSyncMode = activeOffset.map { + smoothHeaderSyncState(activeOffset: $0).inactiveSyncMode + } ?? .visualTop(visualTopOffset) return VideoDetailListOffsetContext( contentTopInset: contentTopInset, initialNormalizedOffsetY: visualTopOffset, - inactiveSyncMode: VideoDetailPagerOffsetModel.inactiveSyncMode( - activeOffset: activeOffset, - initialOffset: visualTopOffset, - collapseDistance: collapseDistance - ), + inactiveSyncMode: inactiveSyncMode, collapseSpacerHeight: collapseSpacerHeight, minimumContentHeight: minimumContentHeight(in: scrollBoundsHeight) ) } + func smoothHeaderSyncState(activeOffset: CGFloat) -> VideoDetailSmoothHeaderSyncState { + VideoDetailSmoothHeaderSyncState.state( + activeOffset: activeOffset, + collapseDistance: collapseDistance + ) + } + func minimumContentHeight(in scrollBoundsHeight: CGFloat) -> CGFloat { VideoDetailPagerOffsetModel.minimumContentHeight( scrollBoundsHeight: scrollBoundsHeight, @@ -860,13 +880,6 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle return scrollView.verticalContentOffsetExcludingBounce } - func headerSyncMode(fromActiveOffset activeOffset: CGFloat) -> VideoDetailPagerOffsetModel.InactiveSyncMode { - lastAppliedPage?.headerGeometry.listOffsetContext( - in: scrollView.bounds.height, - activeOffset: activeOffset - ).inactiveSyncMode ?? .visualTop(0) - } - func syncHeaderOffsetFromActivePage(_ syncMode: VideoDetailPagerOffsetModel.InactiveSyncMode) { loadViewIfNeeded() cancelPendingTopAlignment() @@ -1133,6 +1146,11 @@ private struct VideoDetailTabPager: UIViewControllerRepresentable { private var lastLaidOutWidth: CGFloat = 0 private var isHorizontalPagingActive = false private var headerContentRevision: Int? + private var headerSyncState = VideoDetailSmoothHeaderSyncState.state( + activeOffset: 0, + collapseDistance: 0 + ) + private var hasSyncedInactivePages = false private var latestPages: [VideoPageTab: VideoDetailTabPage] = [:] init(coordinator: Coordinator) { @@ -1259,6 +1277,10 @@ private struct VideoDetailTabPager: UIViewControllerRepresentable { if !isHorizontalPagingActive { headerVisibleIndex = self.selectedIndex } + if introduction.contentUpdateRevision != latestPages[.introduction]?.contentUpdateRevision + || comments.contentUpdateRevision != latestPages[.comments]?.contentUpdateRevision { + hasSyncedInactivePages = false + } latestPages[.introduction] = introduction latestPages[.comments] = comments updateHeaderHosts( @@ -1371,12 +1393,43 @@ private struct VideoDetailTabPager: UIViewControllerRepresentable { return } let activeOffset = activePage.normalizedContentOffsetY - let syncMode = activePage.headerSyncMode(fromActiveOffset: activeOffset) + let previousSyncState = headerSyncState + let nextSyncState = updateHeaderSyncState(activeOffset: activeOffset) + guard let syncMode = inactiveSyncMode( + previousState: previousSyncState, + nextState: nextSyncState + ) else { return } + hasSyncedInactivePages = true for tab in VideoPageTab.allCases where tab.pageIndex != selectedIndex { verticalPageController(for: tab)?.syncHeaderOffsetFromActivePage(syncMode) } } + private func inactiveSyncMode( + previousState: VideoDetailSmoothHeaderSyncState, + nextState: VideoDetailSmoothHeaderSyncState + ) -> VideoDetailPagerOffsetModel.InactiveSyncMode? { + if !hasSyncedInactivePages { + return nextState.inactiveSyncMode + } + if nextState.isSyncingListOffsets { + return nextState.inactiveSyncMode + } + if previousState.isSyncingListOffsets { + return nextState.inactiveSyncMode + } + return nil + } + + @discardableResult + private func updateHeaderSyncState(activeOffset: CGFloat) -> VideoDetailSmoothHeaderSyncState { + guard let page = latestPages[VideoPageTab.page(at: selectedIndex)] else { + return headerSyncState + } + headerSyncState = page.headerGeometry.smoothHeaderSyncState(activeOffset: activeOffset) + return headerSyncState + } + private func verticalPageController(for tab: VideoPageTab) -> VideoDetailVerticalScrollPageViewController? { switch tab { case .introduction: @@ -1468,8 +1521,8 @@ private struct VideoDetailTabPager: UIViewControllerRepresentable { private func attachHeaderToPagerContainer(page: VideoDetailTabPage) { let offset = verticalPageController(for: activeHeaderTab)?.normalizedContentOffsetY ?? 0 - let headerOffset = min(max(offset, 0), page.headerGeometry.collapseDistance) - attachHeader(to: view, originY: -headerOffset) + let syncState = page.headerGeometry.smoothHeaderSyncState(activeOffset: offset) + attachHeader(to: view, originY: syncState.headerContainerY) } private var activeHeaderTab: VideoPageTab { @@ -1492,9 +1545,13 @@ private struct VideoDetailTabPager: UIViewControllerRepresentable { guard tab == activeHeaderTab else { return } guard let page = latestPages[tab] else { return } if isHorizontalPagingActive { - let headerOffset = min(max(offsetY, 0), page.headerGeometry.collapseDistance) - attachHeader(to: view, originY: -headerOffset) + let syncState = page.headerGeometry.smoothHeaderSyncState(activeOffset: offsetY) + headerSyncState = syncState + attachHeader(to: view, originY: syncState.headerContainerY) } else { + if tab == VideoPageTab.page(at: selectedIndex) { + updateHeaderSyncState(activeOffset: offsetY) + } updateHeaderAttachmentForCurrentState() } } From b555d7271e38d60f8ef72efdad2eaa13595a8907 Mon Sep 17 00:00:00 2001 From: pgs666 <74764545+pgs666@users.noreply.github.com> Date: Sun, 7 Jun 2026 12:05:33 +0800 Subject: [PATCH 127/216] Consolidate pager horizontal position --- iosApp/VideoDetailPager.swift | 115 +++++++++++++++++++++------------- 1 file changed, 73 insertions(+), 42 deletions(-) diff --git a/iosApp/VideoDetailPager.swift b/iosApp/VideoDetailPager.swift index 895d4b08..3c877469 100644 --- a/iosApp/VideoDetailPager.swift +++ b/iosApp/VideoDetailPager.swift @@ -324,6 +324,53 @@ private enum VideoDetailPendingTopAlignment { case explicit(CGFloat) } +private struct VideoDetailHorizontalPagerPosition: Equatable { + private(set) var selectedIndex = 0 + private(set) var visibleIndex = 0 + private(set) var settledIndex: Int? + private(set) var isPagingActive = false + + var activeHeaderIndex: Int { + isPagingActive ? visibleIndex : selectedIndex + } + + mutating func setSelectedIndex(_ index: Int) { + selectedIndex = clamped(index) + if !isPagingActive { + visibleIndex = selectedIndex + } + } + + mutating func setVisibleIndex(_ index: Int) -> Bool { + let nextIndex = clamped(index) + guard visibleIndex != nextIndex else { return false } + visibleIndex = nextIndex + return true + } + + mutating func setPagingActive(_ isActive: Bool, settledIndex: Int?) -> Bool { + guard isPagingActive != isActive else { return false } + isPagingActive = isActive + if !isActive, let settledIndex { + visibleIndex = clamped(settledIndex) + } + return true + } + + mutating func markSettled(_ index: Int) -> Bool { + let nextIndex = clamped(index) + selectedIndex = nextIndex + visibleIndex = nextIndex + guard settledIndex != nextIndex else { return false } + settledIndex = nextIndex + return true + } + + private func clamped(_ index: Int) -> Int { + min(max(index, 0), VideoPageTab.allCases.count - 1) + } +} + private struct VideoDetailTabPage { let tab: VideoPageTab let contentBottomPadding: CGFloat @@ -1139,12 +1186,9 @@ private struct VideoDetailTabPager: UIViewControllerRepresentable { private let pinHeaderHost = UIHostingController(rootView: AnyView(EmptyView())) private let introductionPage = VideoDetailVerticalScrollPageViewController(tab: .introduction) private let commentsPage = VideoDetailVerticalScrollPageViewController(tab: .comments) - private var selectedIndex = 0 - private var headerVisibleIndex = 0 - private var lastSettledSelectedIndex: Int? + private var pagerPosition = VideoDetailHorizontalPagerPosition() private var pendingSelectedIndex: Int? private var lastLaidOutWidth: CGFloat = 0 - private var isHorizontalPagingActive = false private var headerContentRevision: Int? private var headerSyncState = VideoDetailSmoothHeaderSyncState.state( activeOffset: 0, @@ -1257,7 +1301,7 @@ private struct VideoDetailTabPager: UIViewControllerRepresentable { self.pendingSelectedIndex = nil setSelectedIndex(pendingSelectedIndex, animated: false) } else if widthChanged { - setSelectedIndex(selectedIndex, animated: false) + setSelectedIndex(pagerPosition.selectedIndex, animated: false) } } @@ -1273,10 +1317,7 @@ private struct VideoDetailTabPager: UIViewControllerRepresentable { animated: Bool ) { loadViewIfNeeded() - self.selectedIndex = min(max(selectedIndex, 0), VideoPageTab.allCases.count - 1) - if !isHorizontalPagingActive { - headerVisibleIndex = self.selectedIndex - } + pagerPosition.setSelectedIndex(selectedIndex) if introduction.contentUpdateRevision != latestPages[.introduction]?.contentUpdateRevision || comments.contentUpdateRevision != latestPages[.comments]?.contentUpdateRevision { hasSyncedInactivePages = false @@ -1289,19 +1330,19 @@ private struct VideoDetailTabPager: UIViewControllerRepresentable { continuationProgress: continuationProgress, isContinuationHeaderInteractive: isContinuationHeaderInteractive, pinHeader: pinHeader, - page: VideoPageTab.page(at: self.selectedIndex) + page: VideoPageTab.page(at: pagerPosition.selectedIndex) ) introductionPage.update(page: introduction) commentsPage.update(page: comments) updateHeaderAttachmentForCurrentState() - if !isHorizontalPagingActive { + if !pagerPosition.isPagingActive { syncInactivePageHeaderOffset() } - let consumedSettledIndex = coordinator.consumePendingSettledIndex(for: self.selectedIndex) + let consumedSettledIndex = coordinator.consumePendingSettledIndex(for: pagerPosition.selectedIndex) guard !scrollView.isTracking, !scrollView.isDragging, !scrollView.isDecelerating else { return } - setSelectedIndex(self.selectedIndex, animated: animated) + setSelectedIndex(pagerPosition.selectedIndex, animated: animated) if !animated && !consumedSettledIndex { - settleSelectedPageIfNeeded(self.selectedIndex) + settleSelectedPageIfNeeded(pagerPosition.selectedIndex) } } @@ -1314,16 +1355,13 @@ private struct VideoDetailTabPager: UIViewControllerRepresentable { } private func setSelectedIndex(_ index: Int, animated: Bool) { - selectedIndex = min(max(index, 0), VideoPageTab.allCases.count - 1) - if !isHorizontalPagingActive { - headerVisibleIndex = selectedIndex - } + pagerPosition.setSelectedIndex(index) let width = scrollView.bounds.width guard width > 0 else { - pendingSelectedIndex = selectedIndex + pendingSelectedIndex = pagerPosition.selectedIndex return } - let targetOffset = CGPoint(x: CGFloat(selectedIndex) * width, y: 0) + let targetOffset = CGPoint(x: CGFloat(pagerPosition.selectedIndex) * width, y: 0) guard abs(scrollView.contentOffset.x - targetOffset.x) > 0.5 else { return } if animated { setHorizontalPagingActive(true) @@ -1333,20 +1371,16 @@ private struct VideoDetailTabPager: UIViewControllerRepresentable { private func settleSelectedPageIfNeeded(_ index: Int) { let clampedIndex = min(max(index, 0), VideoPageTab.allCases.count - 1) - guard lastSettledSelectedIndex != clampedIndex else { return } + guard pagerPosition.settledIndex != clampedIndex else { return } settlePageAfterHorizontalSelection(clampedIndex) } private func settlePageAfterHorizontalSelection(_ index: Int) { let clampedIndex = min(max(index, 0), VideoPageTab.allCases.count - 1) - selectedIndex = clampedIndex - guard lastSettledSelectedIndex != clampedIndex else { - headerVisibleIndex = clampedIndex + guard pagerPosition.markSettled(clampedIndex) else { updateHeaderAttachmentForCurrentState() return } - lastSettledSelectedIndex = clampedIndex - headerVisibleIndex = clampedIndex layoutHeaderHosts() switch VideoPageTab.page(at: index) { case .introduction: @@ -1361,13 +1395,12 @@ private struct VideoDetailTabPager: UIViewControllerRepresentable { } private func setHorizontalPagingActive(_ isActive: Bool) { - guard isHorizontalPagingActive != isActive else { return } - isHorizontalPagingActive = isActive + guard pagerPosition.setPagingActive( + isActive, + settledIndex: isActive ? nil : settledIndexFromHorizontalOffset() + ) else { return } introductionPage.setHorizontalPagingActive(isActive) commentsPage.setHorizontalPagingActive(isActive) - if !isActive { - headerVisibleIndex = settledIndexFromHorizontalOffset() - } updateHeaderAttachmentForCurrentState() if !isActive { syncInactivePageHeaderOffset() @@ -1375,21 +1408,19 @@ private struct VideoDetailTabPager: UIViewControllerRepresentable { } private func setHeaderVisibleIndex(_ index: Int) { - let clampedIndex = min(max(index, 0), VideoPageTab.allCases.count - 1) - guard headerVisibleIndex != clampedIndex else { return } - headerVisibleIndex = clampedIndex + guard pagerPosition.setVisibleIndex(index) else { return } updateHeaderAttachmentForCurrentState() } private func settledIndexFromHorizontalOffset() -> Int { let width = scrollView.bounds.width - guard width > 0 else { return selectedIndex } + guard width > 0 else { return pagerPosition.selectedIndex } let index = Int(round(scrollView.contentOffset.x / width)) return min(max(index, 0), VideoPageTab.allCases.count - 1) } private func syncInactivePageHeaderOffset() { - guard let activePage = verticalPageController(for: VideoPageTab.page(at: selectedIndex)) else { + guard let activePage = verticalPageController(for: VideoPageTab.page(at: pagerPosition.selectedIndex)) else { return } let activeOffset = activePage.normalizedContentOffsetY @@ -1400,7 +1431,7 @@ private struct VideoDetailTabPager: UIViewControllerRepresentable { nextState: nextSyncState ) else { return } hasSyncedInactivePages = true - for tab in VideoPageTab.allCases where tab.pageIndex != selectedIndex { + for tab in VideoPageTab.allCases where tab.pageIndex != pagerPosition.selectedIndex { verticalPageController(for: tab)?.syncHeaderOffsetFromActivePage(syncMode) } } @@ -1423,7 +1454,7 @@ private struct VideoDetailTabPager: UIViewControllerRepresentable { @discardableResult private func updateHeaderSyncState(activeOffset: CGFloat) -> VideoDetailSmoothHeaderSyncState { - guard let page = latestPages[VideoPageTab.page(at: selectedIndex)] else { + guard let page = latestPages[VideoPageTab.page(at: pagerPosition.selectedIndex)] else { return headerSyncState } headerSyncState = page.headerGeometry.smoothHeaderSyncState(activeOffset: activeOffset) @@ -1504,7 +1535,7 @@ private struct VideoDetailTabPager: UIViewControllerRepresentable { let headerTab = activeHeaderTab guard let page = latestPages[headerTab] else { return } layoutHeaderHosts() - if isHorizontalPagingActive { + if pagerPosition.isPagingActive { attachHeaderToPagerContainer(page: page) return } @@ -1526,7 +1557,7 @@ private struct VideoDetailTabPager: UIViewControllerRepresentable { } private var activeHeaderTab: VideoPageTab { - VideoPageTab.page(at: isHorizontalPagingActive ? headerVisibleIndex : selectedIndex) + VideoPageTab.page(at: pagerPosition.activeHeaderIndex) } private func attachHeader(to parentView: UIView, originY: CGFloat) { @@ -1544,12 +1575,12 @@ private struct VideoDetailTabPager: UIViewControllerRepresentable { private func updateHeaderContainerPosition(for tab: VideoPageTab, offsetY: CGFloat) { guard tab == activeHeaderTab else { return } guard let page = latestPages[tab] else { return } - if isHorizontalPagingActive { + if pagerPosition.isPagingActive { let syncState = page.headerGeometry.smoothHeaderSyncState(activeOffset: offsetY) headerSyncState = syncState attachHeader(to: view, originY: syncState.headerContainerY) } else { - if tab == VideoPageTab.page(at: selectedIndex) { + if tab == VideoPageTab.page(at: pagerPosition.selectedIndex) { updateHeaderSyncState(activeOffset: offsetY) } updateHeaderAttachmentForCurrentState() From a108454a727cfe0302fbd504e7ed0cf308680a72 Mon Sep 17 00:00:00 2001 From: pgs666 <74764545+pgs666@users.noreply.github.com> Date: Sun, 7 Jun 2026 12:13:13 +0800 Subject: [PATCH 128/216] Normalize pager horizontal finish handling --- iosApp/VideoDetailPager.swift | 39 +++++++++++++++++++++++++++-------- 1 file changed, 30 insertions(+), 9 deletions(-) diff --git a/iosApp/VideoDetailPager.swift b/iosApp/VideoDetailPager.swift index 3c877469..cb6198d2 100644 --- a/iosApp/VideoDetailPager.swift +++ b/iosApp/VideoDetailPager.swift @@ -947,6 +947,11 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle } } + func setScrollsToTop(_ scrollsToTop: Bool) { + loadViewIfNeeded() + scrollView.scrollsToTop = scrollsToTop + } + func reportCurrentOffset() { loadViewIfNeeded() let offset = scrollView.verticalContentOffsetExcludingBounce @@ -1256,6 +1261,7 @@ private struct VideoDetailTabPager: UIViewControllerRepresentable { addPage(introductionPage) addPage(commentsPage) + updateScrollsToTop(for: VideoPageTab.page(at: pagerPosition.selectedIndex)) introductionPage.onHeaderOffsetChanged = { [weak self] tab, offset in self?.updateHeaderContainerPosition(for: tab, offsetY: offset) } @@ -1334,6 +1340,7 @@ private struct VideoDetailTabPager: UIViewControllerRepresentable { ) introductionPage.update(page: introduction) commentsPage.update(page: comments) + updateScrollsToTop(for: VideoPageTab.page(at: pagerPosition.selectedIndex)) updateHeaderAttachmentForCurrentState() if !pagerPosition.isPagingActive { syncInactivePageHeaderOffset() @@ -1371,29 +1378,43 @@ private struct VideoDetailTabPager: UIViewControllerRepresentable { private func settleSelectedPageIfNeeded(_ index: Int) { let clampedIndex = min(max(index, 0), VideoPageTab.allCases.count - 1) - guard pagerPosition.settledIndex != clampedIndex else { return } - settlePageAfterHorizontalSelection(clampedIndex) + guard pagerPosition.settledIndex != clampedIndex else { + finishHorizontalPaging(at: clampedIndex) + return + } + finishHorizontalPaging(at: clampedIndex) } private func settlePageAfterHorizontalSelection(_ index: Int) { + finishHorizontalPaging(at: index) + } + + private func finishHorizontalPaging(at index: Int) { let clampedIndex = min(max(index, 0), VideoPageTab.allCases.count - 1) - guard pagerPosition.markSettled(clampedIndex) else { - updateHeaderAttachmentForCurrentState() - return - } + let didChangeSettledIndex = pagerPosition.markSettled(clampedIndex) + updateScrollsToTop(for: VideoPageTab.page(at: clampedIndex)) layoutHeaderHosts() - switch VideoPageTab.page(at: index) { + switch VideoPageTab.page(at: clampedIndex) { case .introduction: - introductionPage.settleAfterHorizontalActivation() + if didChangeSettledIndex { + introductionPage.settleAfterHorizontalActivation() + } introductionPage.reportCurrentOffset() case .comments: - commentsPage.settleAfterHorizontalActivation() + if didChangeSettledIndex { + commentsPage.settleAfterHorizontalActivation() + } commentsPage.reportCurrentOffset() } updateHeaderAttachmentForCurrentState() syncInactivePageHeaderOffset() } + private func updateScrollsToTop(for activeTab: VideoPageTab) { + introductionPage.setScrollsToTop(activeTab == .introduction) + commentsPage.setScrollsToTop(activeTab == .comments) + } + private func setHorizontalPagingActive(_ isActive: Bool) { guard pagerPosition.setPagingActive( isActive, From 9b414fe6d2747e720eea2f319b34ef4c08b8e95b Mon Sep 17 00:00:00 2001 From: pgs666 <74764545+pgs666@users.noreply.github.com> Date: Sun, 7 Jun 2026 12:22:37 +0800 Subject: [PATCH 129/216] Model pager header attachment state --- iosApp/VideoDetailPager.swift | 65 +++++++++++++++++++++++++---------- 1 file changed, 47 insertions(+), 18 deletions(-) diff --git a/iosApp/VideoDetailPager.swift b/iosApp/VideoDetailPager.swift index cb6198d2..a080c433 100644 --- a/iosApp/VideoDetailPager.swift +++ b/iosApp/VideoDetailPager.swift @@ -92,6 +92,26 @@ private struct VideoDetailSmoothHeaderSyncState: Equatable { } } +private enum VideoDetailHeaderAttachmentState: Equatable { + case listHeader + case pagerContainer(CGFloat) + + static func state( + isHorizontalPagingActive: Bool, + selectedOffset: CGFloat, + syncState: VideoDetailSmoothHeaderSyncState, + collapseDistance: CGFloat + ) -> VideoDetailHeaderAttachmentState { + if isHorizontalPagingActive { + return .pagerContainer(syncState.headerContainerY) + } + if selectedOffset <= collapseDistance + 0.5 { + return .listHeader + } + return .pagerContainer(syncState.headerContainerY) + } +} + private extension VideoDetailPagerOffsetModel.InactiveSyncMode { var normalizedOffsetY: CGFloat { switch self { @@ -1556,25 +1576,27 @@ private struct VideoDetailTabPager: UIViewControllerRepresentable { let headerTab = activeHeaderTab guard let page = latestPages[headerTab] else { return } layoutHeaderHosts() - if pagerPosition.isPagingActive { - attachHeaderToPagerContainer(page: page) - return - } - let selectedPageController = verticalPageController(for: headerTab) - guard let selectedPageController else { return } - let selectedOffset = selectedPageController.normalizedContentOffsetY - let shouldAttachToListHeader = selectedOffset <= page.headerGeometry.collapseDistance + 0.5 - if shouldAttachToListHeader { - attachHeader(to: selectedPageController.headerAttachmentView(), originY: 0) - } else { - attachHeaderToPagerContainer(page: page) - } + guard let pageController = verticalPageController(for: headerTab) else { return } + let offset = pageController.normalizedContentOffsetY + let attachmentState = VideoDetailHeaderAttachmentState.state( + isHorizontalPagingActive: pagerPosition.isPagingActive, + selectedOffset: offset, + syncState: page.headerGeometry.smoothHeaderSyncState(activeOffset: offset), + collapseDistance: page.headerGeometry.collapseDistance + ) + applyHeaderAttachment(attachmentState, pageController: pageController) } - private func attachHeaderToPagerContainer(page: VideoDetailTabPage) { - let offset = verticalPageController(for: activeHeaderTab)?.normalizedContentOffsetY ?? 0 - let syncState = page.headerGeometry.smoothHeaderSyncState(activeOffset: offset) - attachHeader(to: view, originY: syncState.headerContainerY) + private func applyHeaderAttachment( + _ attachmentState: VideoDetailHeaderAttachmentState, + pageController: VideoDetailVerticalScrollPageViewController + ) { + switch attachmentState { + case .listHeader: + attachHeader(to: pageController.headerAttachmentView(), originY: 0) + case .pagerContainer(let originY): + attachHeader(to: view, originY: originY) + } } private var activeHeaderTab: VideoPageTab { @@ -1596,10 +1618,17 @@ private struct VideoDetailTabPager: UIViewControllerRepresentable { private func updateHeaderContainerPosition(for tab: VideoPageTab, offsetY: CGFloat) { guard tab == activeHeaderTab else { return } guard let page = latestPages[tab] else { return } + guard let pageController = verticalPageController(for: tab) else { return } if pagerPosition.isPagingActive { let syncState = page.headerGeometry.smoothHeaderSyncState(activeOffset: offsetY) headerSyncState = syncState - attachHeader(to: view, originY: syncState.headerContainerY) + let attachmentState = VideoDetailHeaderAttachmentState.state( + isHorizontalPagingActive: true, + selectedOffset: offsetY, + syncState: syncState, + collapseDistance: page.headerGeometry.collapseDistance + ) + applyHeaderAttachment(attachmentState, pageController: pageController) } else { if tab == VideoPageTab.page(at: pagerPosition.selectedIndex) { updateHeaderSyncState(activeOffset: offsetY) From 3d0e3e79aafaf547dd0af330c37da10793610a03 Mon Sep 17 00:00:00 2001 From: pgs666 <74764545+pgs666@users.noreply.github.com> Date: Sun, 7 Jun 2026 12:34:35 +0800 Subject: [PATCH 130/216] Consolidate list alignment state --- iosApp/VideoDetailPager.swift | 151 +++++++++++++++++++++------------- 1 file changed, 94 insertions(+), 57 deletions(-) diff --git a/iosApp/VideoDetailPager.swift b/iosApp/VideoDetailPager.swift index a080c433..42ca2fc0 100644 --- a/iosApp/VideoDetailPager.swift +++ b/iosApp/VideoDetailPager.swift @@ -344,6 +344,66 @@ private enum VideoDetailPendingTopAlignment { case explicit(CGFloat) } +private struct VideoDetailListAlignmentState { + var pendingTopAlignment: VideoDetailPendingTopAlignment? + var needsInitialHeaderOffsetReset = true + var hasAppliedInitialListOffset = false + var hasCompletedFirstActiveAlignment = false + var firstActiveAlignedContentHeight: CGFloat? + var hasUserInteractedSinceFirstActiveAlignment = false + + var hasExplicitPendingTopAlignment: Bool { + guard let pendingTopAlignment else { return false } + if case .explicit = pendingTopAlignment { + return true + } + return false + } + + mutating func cancelPendingTopAlignment() { + pendingTopAlignment = nil + } + + mutating func resetForContentUpdate() { + hasCompletedFirstActiveAlignment = false + firstActiveAlignedContentHeight = nil + hasUserInteractedSinceFirstActiveAlignment = false + } + + mutating func reopenFirstActiveAlignmentAfterContentSizeChange() { + hasCompletedFirstActiveAlignment = false + firstActiveAlignedContentHeight = nil + } + + mutating func markInitialOffsetApplied() { + needsInitialHeaderOffsetReset = false + hasAppliedInitialListOffset = true + } + + mutating func markFirstActiveAlignmentCompleted(contentHeight: CGFloat) { + hasCompletedFirstActiveAlignment = true + firstActiveAlignedContentHeight = contentHeight + hasUserInteractedSinceFirstActiveAlignment = false + } + + mutating func markUserInteractionAfterFirstActiveAlignment() { + hasUserInteractedSinceFirstActiveAlignment = true + } + + func shouldReopenFirstActiveAlignment( + isSelected: Bool, + contentHeight: CGFloat + ) -> Bool { + guard isSelected, + hasCompletedFirstActiveAlignment, + !hasUserInteractedSinceFirstActiveAlignment, + let firstActiveAlignedContentHeight else { + return false + } + return abs(contentHeight - firstActiveAlignedContentHeight) > 0.5 + } +} + private struct VideoDetailHorizontalPagerPosition: Equatable { private(set) var selectedIndex = 0 private(set) var visibleIndex = 0 @@ -615,12 +675,7 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle private var collapseSpacerHeightConstraint: NSLayoutConstraint! private var contentUpdateRevision: Int? private var lastAppliedPage: VideoDetailTabPage? - private var pendingTopAlignment: VideoDetailPendingTopAlignment? - private var needsInitialHeaderOffsetReset = true - private var hasAppliedInitialListOffset = false - private var hasCompletedFirstActiveAlignment = false - private var firstActiveAlignedContentHeight: CGFloat? - private var hasUserInteractedSinceFirstActiveAlignment = false + private var alignmentState = VideoDetailListAlignmentState() var onHeaderOffsetChanged: (VideoPageTab, CGFloat) -> Void = { _, _ in } init(tab: VideoPageTab) { @@ -725,7 +780,7 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle coordinator.onInteractionBegan = page.onInteractionBegan coordinator.onTopPullDelta = page.onTopPullDelta coordinator.onVerticalInteractionBegan = { [weak self] in - self?.hasUserInteractedSinceFirstActiveAlignment = true + self?.alignmentState.markUserInteractionAfterFirstActiveAlignment() self?.resolvePendingTopAlignmentIfPossible(allowDuringInteraction: true) } coordinator.onVisibleOffsetChange = { [weak self] tab, offset in @@ -737,11 +792,9 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle if contentUpdateRevision != page.contentUpdateRevision { contentUpdateRevision = page.contentUpdateRevision host.rootView = page.content() - hasCompletedFirstActiveAlignment = false - firstActiveAlignedContentHeight = nil - hasUserInteractedSinceFirstActiveAlignment = false - if !hasAppliedInitialListOffset && !hasExplicitPendingTopAlignment { - needsInitialHeaderOffsetReset = true + alignmentState.resetForContentUpdate() + if !alignmentState.hasAppliedInitialListOffset && !alignmentState.hasExplicitPendingTopAlignment { + alignmentState.needsInitialHeaderOffsetReset = true } view.setNeedsLayout() view.layoutIfNeeded() @@ -757,9 +810,9 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle applyBottomContentInset(page.contentBottomPadding) collapseSpacerHeightConstraint.constant = offsetContext.collapseSpacerHeight contentMinimumHeightConstraint.constant = offsetContext.minimumContentHeight - if needsInitialHeaderOffsetReset { + if alignmentState.needsInitialHeaderOffsetReset { applyInitialHeaderOffsetResetIfNeeded() - } else if pendingTopAlignment != nil { + } else if alignmentState.pendingTopAlignment != nil { resolvePendingTopAlignmentSoon() } } @@ -776,19 +829,21 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle if abs(contentMinimumHeightConstraint.constant - offsetContext.minimumContentHeight) > 0.5 { contentMinimumHeightConstraint.constant = offsetContext.minimumContentHeight } - if shouldReopenFirstActiveAlignmentAfterContentSizeChange(for: page) { - hasCompletedFirstActiveAlignment = false - firstActiveAlignedContentHeight = nil + if alignmentState.shouldReopenFirstActiveAlignment( + isSelected: page.isSelected, + contentHeight: scrollView.contentSize.height + ) { + alignmentState.reopenFirstActiveAlignmentAfterContentSizeChange() } - if pendingTopAlignment != nil { + if alignmentState.pendingTopAlignment != nil { resolvePendingTopAlignmentIfPossible() return } - if page.isSelected, !hasCompletedFirstActiveAlignment { + if page.isSelected, !alignmentState.hasCompletedFirstActiveAlignment { applyFirstActiveAlignmentIfNeeded() return } - if needsInitialHeaderOffsetReset { + if alignmentState.needsInitialHeaderOffsetReset { applyInitialHeaderOffsetResetIfNeeded() } } @@ -834,9 +889,8 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle } guard setNormalizedContentOffsetYIfReachable(targetOffsetY) else { return } if abs(scrollView.verticalContentOffsetExcludingBounce - targetOffsetY) <= 0.5 { - needsInitialHeaderOffsetReset = false - hasAppliedInitialListOffset = true - cancelPendingTopAlignment() + alignmentState.markInitialOffsetApplied() + alignmentState.cancelPendingTopAlignment() if lastAppliedPage?.isSelected == true { markFirstActiveAlignmentCompleted() } @@ -844,7 +898,7 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle } private func applyInitialHeaderOffsetResetIfNeeded(allowDuringInteraction: Bool = false) { - guard needsInitialHeaderOffsetReset, let page = lastAppliedPage else { return } + guard alignmentState.needsInitialHeaderOffsetReset, let page = lastAppliedPage else { return } if !allowDuringInteraction { guard !scrollView.isTracking, !scrollView.isDragging, !scrollView.isDecelerating else { return } } @@ -852,23 +906,23 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle in: scrollView.bounds.height ).initialNormalizedOffsetY guard setNormalizedContentOffsetYIfReachable(targetOffsetY) else { - pendingTopAlignment = .initial + alignmentState.pendingTopAlignment = .initial resolvePendingTopAlignmentSoon() return } if abs(scrollView.verticalContentOffsetExcludingBounce - targetOffsetY) <= 0.5 { - needsInitialHeaderOffsetReset = false - hasAppliedInitialListOffset = true - cancelPendingTopAlignment() + alignmentState.markInitialOffsetApplied() + alignmentState.cancelPendingTopAlignment() } } private func cancelPendingTopAlignment() { - pendingTopAlignment = nil + alignmentState.cancelPendingTopAlignment() } private func pendingTopAlignmentTargetOffsetY() -> CGFloat? { - guard let pendingTopAlignment, let page = lastAppliedPage else { return nil } + guard let pendingTopAlignment = alignmentState.pendingTopAlignment, + let page = lastAppliedPage else { return nil } switch pendingTopAlignment { case .initial: return page.headerGeometry.listOffsetContext(in: scrollView.bounds.height).initialNormalizedOffsetY @@ -878,11 +932,7 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle } private var hasExplicitPendingTopAlignment: Bool { - guard let pendingTopAlignment else { return false } - if case .explicit = pendingTopAlignment { - return true - } - return false + alignmentState.hasExplicitPendingTopAlignment } func settleAfterHorizontalActivation() { @@ -896,20 +946,20 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle in: scrollView.bounds.height ).initialNormalizedOffsetY coordinator.visualTopContentOffsetY = targetOffsetY - if needsInitialHeaderOffsetReset { + if alignmentState.needsInitialHeaderOffsetReset { applyInitialHeaderOffsetResetIfNeeded(allowDuringInteraction: allowDuringInteraction) - guard !needsInitialHeaderOffsetReset else { return } + guard !alignmentState.needsInitialHeaderOffsetReset else { return } } if !allowDuringInteraction { guard !scrollView.isTracking, !scrollView.isDragging, !scrollView.isDecelerating else { return } } - if !hasCompletedFirstActiveAlignment { + if !alignmentState.hasCompletedFirstActiveAlignment { guard setNormalizedContentOffsetYIfReachable(targetOffsetY) else { - pendingTopAlignment = .initial + alignmentState.pendingTopAlignment = .initial resolvePendingTopAlignmentSoon() return } - hasAppliedInitialListOffset = true + alignmentState.markInitialOffsetApplied() markFirstActiveAlignmentCompleted() cancelPendingTopAlignment() return @@ -922,24 +972,12 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle return } setNormalizedContentOffsetY(targetOffsetY) - hasAppliedInitialListOffset = true + alignmentState.markInitialOffsetApplied() markFirstActiveAlignmentCompleted() } - private func shouldReopenFirstActiveAlignmentAfterContentSizeChange(for page: VideoDetailTabPage) -> Bool { - guard page.isSelected, - hasCompletedFirstActiveAlignment, - !hasUserInteractedSinceFirstActiveAlignment, - let firstActiveAlignedContentHeight else { - return false - } - return abs(scrollView.contentSize.height - firstActiveAlignedContentHeight) > 0.5 - } - private func markFirstActiveAlignmentCompleted() { - hasCompletedFirstActiveAlignment = true - firstActiveAlignedContentHeight = scrollView.contentSize.height - hasUserInteractedSinceFirstActiveAlignment = false + alignmentState.markFirstActiveAlignmentCompleted(contentHeight: scrollView.contentSize.height) } var normalizedContentOffsetY: CGFloat { @@ -952,12 +990,11 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle cancelPendingTopAlignment() let syncOffsetY = syncMode.normalizedOffsetY guard setNormalizedContentOffsetYIfReachable(syncOffsetY) else { - pendingTopAlignment = .explicit(syncOffsetY) + alignmentState.pendingTopAlignment = .explicit(syncOffsetY) resolvePendingTopAlignmentSoon() return } - needsInitialHeaderOffsetReset = false - hasAppliedInitialListOffset = true + alignmentState.markInitialOffsetApplied() } func setHorizontalPagingActive(_ isActive: Bool) { From 676ef18e9232a3b9b9887715699edd9d381faa57 Mon Sep 17 00:00:00 2001 From: pgs666 <74764545+pgs666@users.noreply.github.com> Date: Sun, 7 Jun 2026 12:41:27 +0800 Subject: [PATCH 131/216] Settle pager after selected state updates --- iosApp/VideoDetailPager.swift | 22 +++++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/iosApp/VideoDetailPager.swift b/iosApp/VideoDetailPager.swift index 42ca2fc0..14961c49 100644 --- a/iosApp/VideoDetailPager.swift +++ b/iosApp/VideoDetailPager.swift @@ -676,6 +676,7 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle private var contentUpdateRevision: Int? private var lastAppliedPage: VideoDetailTabPage? private var alignmentState = VideoDetailListAlignmentState() + private var isFirstActiveAlignmentVerificationScheduled = false var onHeaderOffsetChanged: (VideoPageTab, CGFloat) -> Void = { _, _ in } init(tab: VideoPageTab) { @@ -978,6 +979,18 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle private func markFirstActiveAlignmentCompleted() { alignmentState.markFirstActiveAlignmentCompleted(contentHeight: scrollView.contentSize.height) + scheduleFirstActiveAlignmentVerification() + } + + private func scheduleFirstActiveAlignmentVerification() { + guard !isFirstActiveAlignmentVerificationScheduled else { return } + isFirstActiveAlignmentVerificationScheduled = true + DispatchQueue.main.async { [weak self] in + guard let self else { return } + self.isFirstActiveAlignmentVerificationScheduled = false + self.view.layoutIfNeeded() + self.applyCurrentPageGeometryRules() + } } var normalizedContentOffsetY: CGFloat { @@ -1225,11 +1238,12 @@ private struct VideoDetailTabPager: UIViewControllerRepresentable { guard width > 0 else { return } let index = Int(round(scrollView.contentOffset.x / width)) let tab = VideoPageTab.page(at: index) - onSelectedIndexSettled?(index) if selectedTab.wrappedValue != tab { pendingSettledIndex = index selectedTab.wrappedValue = tab + return } + onSelectedIndexSettled?(index) } func consumePendingSettledIndex(for selectedIndex: Int) -> Bool { @@ -1402,10 +1416,12 @@ private struct VideoDetailTabPager: UIViewControllerRepresentable { if !pagerPosition.isPagingActive { syncInactivePageHeaderOffset() } - let consumedSettledIndex = coordinator.consumePendingSettledIndex(for: pagerPosition.selectedIndex) guard !scrollView.isTracking, !scrollView.isDragging, !scrollView.isDecelerating else { return } + let consumedSettledIndex = coordinator.consumePendingSettledIndex(for: pagerPosition.selectedIndex) setSelectedIndex(pagerPosition.selectedIndex, animated: animated) - if !animated && !consumedSettledIndex { + if consumedSettledIndex { + finishHorizontalPaging(at: pagerPosition.selectedIndex) + } else if !animated { settleSelectedPageIfNeeded(pagerPosition.selectedIndex) } } From b599cd2d029ea12487d8749c6ca5f5a5ab1bf966 Mon Sep 17 00:00:00 2001 From: pgs666 <74764545+pgs666@users.noreply.github.com> Date: Sun, 7 Jun 2026 12:49:05 +0800 Subject: [PATCH 132/216] Sync inactive pager lists during vertical scroll --- iosApp/VideoDetailPager.swift | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/iosApp/VideoDetailPager.swift b/iosApp/VideoDetailPager.swift index 14961c49..d90618bd 100644 --- a/iosApp/VideoDetailPager.swift +++ b/iosApp/VideoDetailPager.swift @@ -1513,11 +1513,11 @@ private struct VideoDetailTabPager: UIViewControllerRepresentable { return min(max(index, 0), VideoPageTab.allCases.count - 1) } - private func syncInactivePageHeaderOffset() { + private func syncInactivePageHeaderOffset(activeOffset providedActiveOffset: CGFloat? = nil) { guard let activePage = verticalPageController(for: VideoPageTab.page(at: pagerPosition.selectedIndex)) else { return } - let activeOffset = activePage.normalizedContentOffsetY + let activeOffset = providedActiveOffset ?? activePage.normalizedContentOffsetY let previousSyncState = headerSyncState let nextSyncState = updateHeaderSyncState(activeOffset: activeOffset) guard let syncMode = inactiveSyncMode( @@ -1684,7 +1684,7 @@ private struct VideoDetailTabPager: UIViewControllerRepresentable { applyHeaderAttachment(attachmentState, pageController: pageController) } else { if tab == VideoPageTab.page(at: pagerPosition.selectedIndex) { - updateHeaderSyncState(activeOffset: offsetY) + syncInactivePageHeaderOffset(activeOffset: offsetY) } updateHeaderAttachmentForCurrentState() } From e1655eeed9c7c22a3939e711d113c71cc52b794f Mon Sep 17 00:00:00 2001 From: pgs666 <74764545+pgs666@users.noreply.github.com> Date: Sun, 7 Jun 2026 12:57:51 +0800 Subject: [PATCH 133/216] Use content spacer for pager bottom clearance --- iosApp/VideoDetailPager.swift | 34 ++++++++++++++++++++++++++-------- 1 file changed, 26 insertions(+), 8 deletions(-) diff --git a/iosApp/VideoDetailPager.swift b/iosApp/VideoDetailPager.swift index d90618bd..b29c5537 100644 --- a/iosApp/VideoDetailPager.swift +++ b/iosApp/VideoDetailPager.swift @@ -669,10 +669,12 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle private let contentView = UIView() private let listHeaderView = UIView() private let collapseSpacerView = UIView() + private let contentBottomSpacerView = UIView() private let host = UIHostingController(rootView: AnyView(EmptyView())) private var hostMinimumHeightConstraint: NSLayoutConstraint! private var contentMinimumHeightConstraint: NSLayoutConstraint! private var collapseSpacerHeightConstraint: NSLayoutConstraint! + private var contentBottomSpacerHeightConstraint: NSLayoutConstraint! private var contentUpdateRevision: Int? private var lastAppliedPage: VideoDetailTabPage? private var alignmentState = VideoDetailListAlignmentState() @@ -738,9 +740,14 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle collapseSpacerView.backgroundColor = .clear contentView.addSubview(collapseSpacerView) + contentBottomSpacerView.translatesAutoresizingMaskIntoConstraints = false + contentBottomSpacerView.backgroundColor = .clear + contentView.addSubview(contentBottomSpacerView) + hostMinimumHeightConstraint = host.view.heightAnchor.constraint(greaterThanOrEqualTo: scrollView.frameLayoutGuide.heightAnchor) contentMinimumHeightConstraint = contentView.heightAnchor.constraint(greaterThanOrEqualToConstant: 1) collapseSpacerHeightConstraint = collapseSpacerView.heightAnchor.constraint(equalToConstant: 1) + contentBottomSpacerHeightConstraint = contentBottomSpacerView.heightAnchor.constraint(equalToConstant: 0) NSLayoutConstraint.activate([ scrollView.leadingAnchor.constraint(equalTo: view.leadingAnchor), @@ -763,8 +770,13 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle collapseSpacerView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor), collapseSpacerView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor), collapseSpacerView.topAnchor.constraint(equalTo: host.view.bottomAnchor), - collapseSpacerView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor), - collapseSpacerHeightConstraint + collapseSpacerHeightConstraint, + + contentBottomSpacerView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor), + contentBottomSpacerView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor), + contentBottomSpacerView.topAnchor.constraint(equalTo: collapseSpacerView.bottomAnchor), + contentBottomSpacerView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor), + contentBottomSpacerHeightConstraint ]) } @@ -808,7 +820,7 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle coordinator.visualTopContentOffsetY = visualTopOffset applyTopContentInset(offsetContext.contentTopInset) applyListHeaderFrame() - applyBottomContentInset(page.contentBottomPadding) + applyBottomContentSpacing(page.contentBottomPadding) collapseSpacerHeightConstraint.constant = offsetContext.collapseSpacerHeight contentMinimumHeightConstraint.constant = offsetContext.minimumContentHeight if alignmentState.needsInitialHeaderOffsetReset { @@ -857,11 +869,17 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle applyListHeaderFrame() } - private func applyBottomContentInset(_ bottomInset: CGFloat) { - let resolvedBottomInset = max(bottomInset, 0) - guard abs(scrollView.contentInset.bottom - resolvedBottomInset) > 0.5 else { return } - scrollView.contentInset.bottom = resolvedBottomInset - scrollView.verticalScrollIndicatorInsets.bottom = resolvedBottomInset + private func applyBottomContentSpacing(_ bottomSpacing: CGFloat) { + let resolvedBottomSpacing = max(bottomSpacing, 0) + if abs(contentBottomSpacerHeightConstraint.constant - resolvedBottomSpacing) > 0.5 { + contentBottomSpacerHeightConstraint.constant = resolvedBottomSpacing + } + if abs(scrollView.contentInset.bottom) > 0.5 { + scrollView.contentInset.bottom = 0 + } + if abs(scrollView.verticalScrollIndicatorInsets.bottom - resolvedBottomSpacing) > 0.5 { + scrollView.verticalScrollIndicatorInsets.bottom = resolvedBottomSpacing + } } private func applyListHeaderFrame() { From 9a0bd17c147b3d23bc208f3a970f32fc99491843 Mon Sep 17 00:00:00 2001 From: pgs666 <74764545+pgs666@users.noreply.github.com> Date: Sun, 7 Jun 2026 13:06:08 +0800 Subject: [PATCH 134/216] Avoid treating pending alignment as user scroll --- iosApp/VideoDetailPager.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/iosApp/VideoDetailPager.swift b/iosApp/VideoDetailPager.swift index b29c5537..ea547f63 100644 --- a/iosApp/VideoDetailPager.swift +++ b/iosApp/VideoDetailPager.swift @@ -387,6 +387,7 @@ private struct VideoDetailListAlignmentState { } mutating func markUserInteractionAfterFirstActiveAlignment() { + guard hasCompletedFirstActiveAlignment else { return } hasUserInteractedSinceFirstActiveAlignment = true } From a49c1fe305186272b8e5115191d92dff134075f3 Mon Sep 17 00:00:00 2001 From: pgs666 <74764545+pgs666@users.noreply.github.com> Date: Sun, 7 Jun 2026 13:18:35 +0800 Subject: [PATCH 135/216] Use table-backed comment list --- iosApp/CommentListTableView.swift | 175 ++++++++++++++++++++++++++++++ iosApp/CommentView.swift | 50 ++++----- iosApp/VideoDetailPager.swift | 63 +++++++++-- 3 files changed, 249 insertions(+), 39 deletions(-) create mode 100644 iosApp/CommentListTableView.swift diff --git a/iosApp/CommentListTableView.swift b/iosApp/CommentListTableView.swift new file mode 100644 index 00000000..a3d0bfa7 --- /dev/null +++ b/iosApp/CommentListTableView.swift @@ -0,0 +1,175 @@ +import SwiftUI +import UIKit + +struct CommentListTableView: UIViewRepresentable { + let comments: [CommentRow] + let runningActionIDs: Set + let onReply: (CommentRow) -> Void + let onShowReplies: (CommentRow) -> Void + let onLike: (CommentRow) -> Void + let onDislike: (CommentRow) -> Void + let onReport: (CommentRow) -> Void + + func makeCoordinator() -> Coordinator { + Coordinator(parent: self) + } + + func makeUIView(context: Context) -> IntrinsicCommentTableView { + let tableView = IntrinsicCommentTableView(frame: .zero, style: .plain) + tableView.backgroundColor = .clear + tableView.separatorStyle = .none + tableView.showsVerticalScrollIndicator = false + tableView.isScrollEnabled = false + tableView.alwaysBounceVertical = false + tableView.estimatedRowHeight = 0 + tableView.estimatedSectionHeaderHeight = 0 + tableView.estimatedSectionFooterHeight = 0 + tableView.rowHeight = UITableView.automaticDimension + tableView.sectionHeaderHeight = 0 + tableView.sectionFooterHeight = 0 + tableView.contentInsetAdjustmentBehavior = .never + tableView.dataSource = context.coordinator + tableView.delegate = context.coordinator + tableView.register(HostingCommentTableViewCell.self, forCellReuseIdentifier: HostingCommentTableViewCell.reuseIdentifier) + tableView.register(CommentListFooterCell.self, forCellReuseIdentifier: CommentListFooterCell.reuseIdentifier) + return tableView + } + + func updateUIView(_ tableView: IntrinsicCommentTableView, context: Context) { + context.coordinator.parent = self + tableView.reloadData() + tableView.invalidateIntrinsicContentSize() + } + + final class Coordinator: NSObject, UITableViewDataSource, UITableViewDelegate { + var parent: CommentListTableView + + init(parent: CommentListTableView) { + self.parent = parent + } + + func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + parent.comments.count + 1 + } + + func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + if indexPath.row == parent.comments.count { + let cell = tableView.dequeueReusableCell( + withIdentifier: CommentListFooterCell.reuseIdentifier, + for: indexPath + ) as? CommentListFooterCell ?? CommentListFooterCell(style: .default, reuseIdentifier: CommentListFooterCell.reuseIdentifier) + cell.configure() + return cell + } + + let comment = parent.comments[indexPath.row] + let cell = tableView.dequeueReusableCell( + withIdentifier: HostingCommentTableViewCell.reuseIdentifier, + for: indexPath + ) as? HostingCommentTableViewCell ?? HostingCommentTableViewCell(style: .default, reuseIdentifier: HostingCommentTableViewCell.reuseIdentifier) + cell.configure( + comment: comment, + isRunningLike: parent.runningActionIDs.contains("like-\(comment.id)"), + onReply: { [weak self] in self?.parent.onReply(comment) }, + onShowReplies: { [weak self] in self?.parent.onShowReplies(comment) }, + onLike: { [weak self] in self?.parent.onLike(comment) }, + onDislike: { [weak self] in self?.parent.onDislike(comment) }, + onReport: { [weak self] in self?.parent.onReport(comment) } + ) + return cell + } + } +} + +final class IntrinsicCommentTableView: UITableView { + override var contentSize: CGSize { + didSet { + guard abs(contentSize.height - oldValue.height) > 0.5 else { return } + invalidateIntrinsicContentSize() + } + } + + override var intrinsicContentSize: CGSize { + layoutIfNeeded() + return CGSize(width: UIView.noIntrinsicMetric, height: contentSize.height) + } +} + +private final class HostingCommentTableViewCell: UITableViewCell { + static let reuseIdentifier = "HostingCommentTableViewCell" + + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + super.init(style: style, reuseIdentifier: reuseIdentifier) + backgroundColor = .clear + contentView.backgroundColor = .clear + selectionStyle = .none + preservesSuperviewLayoutMargins = false + contentView.preservesSuperviewLayoutMargins = false + layoutMargins = .zero + contentView.layoutMargins = .zero + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func prepareForReuse() { + super.prepareForReuse() + contentConfiguration = nil + } + + func configure( + comment: CommentRow, + isRunningLike: Bool, + onReply: @escaping () -> Void, + onShowReplies: @escaping () -> Void, + onLike: @escaping () -> Void, + onDislike: @escaping () -> Void, + onReport: @escaping () -> Void + ) { + let row = CommentRowView( + comment: comment, + isRunningLike: isRunningLike, + onReply: onReply, + onShowReplies: onShowReplies, + onLike: onLike, + onDislike: onDislike, + onReport: onReport + ) + .padding(.vertical, 6) + .padding(.horizontal, 16) + + contentConfiguration = UIHostingConfiguration { + row + } + .margins(.all, 0) + } +} + +private final class CommentListFooterCell: UITableViewCell { + static let reuseIdentifier = "CommentListFooterCell" + + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + super.init(style: style, reuseIdentifier: reuseIdentifier) + backgroundColor = .clear + contentView.backgroundColor = .clear + selectionStyle = .none + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func configure() { + contentConfiguration = UIHostingConfiguration { + Text("comment.no_more") + .font(.footnote) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity) + .frame(height: 44) + } + .margins(.all, 0) + } +} diff --git a/iosApp/CommentView.swift b/iosApp/CommentView.swift index 6053b388..22a4a501 100644 --- a/iosApp/CommentView.swift +++ b/iosApp/CommentView.swift @@ -170,36 +170,26 @@ struct CommentView: View { .frame(maxWidth: .infinity) .padding(.vertical, 60) } else { - VStack(alignment: .leading, spacing: 12) { - ForEach(comments) { comment in - CommentRowView( - comment: comment, - isRunningLike: viewModel.runningActionIDs.contains("like-\(comment.id)"), - onReply: { - replyText = "@\(comment.username) " - replyTarget = comment - }, - onShowReplies: { - repliesTarget = comment - }, - onLike: { - viewModel.like(comment: comment, isPositive: true) - }, - onDislike: { - viewModel.like(comment: comment, isPositive: false) - }, - onReport: { - reportTarget = comment - } - ) + CommentListTableView( + comments: comments, + runningActionIDs: viewModel.runningActionIDs, + onReply: { comment in + replyText = "@\(comment.username) " + replyTarget = comment + }, + onShowReplies: { comment in + repliesTarget = comment + }, + onLike: { comment in + viewModel.like(comment: comment, isPositive: true) + }, + onDislike: { comment in + viewModel.like(comment: comment, isPositive: false) + }, + onReport: { comment in + reportTarget = comment } - - Text("comment.no_more") - .font(.footnote) - .foregroundStyle(.secondary) - .frame(maxWidth: .infinity) - .frame(height: 44) - } + ) .id(viewModel.sortMode.id) } } @@ -234,7 +224,7 @@ struct CommentView: View { } -private struct CommentRowView: View { +struct CommentRowView: View { let comment: CommentRow let isRunningLike: Bool let onReply: () -> Void diff --git a/iosApp/VideoDetailPager.swift b/iosApp/VideoDetailPager.swift index ea547f63..90569c21 100644 --- a/iosApp/VideoDetailPager.swift +++ b/iosApp/VideoDetailPager.swift @@ -349,6 +349,7 @@ private struct VideoDetailListAlignmentState { var needsInitialHeaderOffsetReset = true var hasAppliedInitialListOffset = false var hasCompletedFirstActiveAlignment = false + var isFirstActiveAlignmentStabilizing = false var firstActiveAlignedContentHeight: CGFloat? var hasUserInteractedSinceFirstActiveAlignment = false @@ -366,12 +367,14 @@ private struct VideoDetailListAlignmentState { mutating func resetForContentUpdate() { hasCompletedFirstActiveAlignment = false + isFirstActiveAlignmentStabilizing = false firstActiveAlignedContentHeight = nil hasUserInteractedSinceFirstActiveAlignment = false } mutating func reopenFirstActiveAlignmentAfterContentSizeChange() { hasCompletedFirstActiveAlignment = false + isFirstActiveAlignmentStabilizing = false firstActiveAlignedContentHeight = nil } @@ -382,12 +385,20 @@ private struct VideoDetailListAlignmentState { mutating func markFirstActiveAlignmentCompleted(contentHeight: CGFloat) { hasCompletedFirstActiveAlignment = true + isFirstActiveAlignmentStabilizing = true + firstActiveAlignedContentHeight = contentHeight + hasUserInteractedSinceFirstActiveAlignment = false + } + + mutating func markFirstActiveAlignmentStabilized(contentHeight: CGFloat) { + hasCompletedFirstActiveAlignment = true + isFirstActiveAlignmentStabilizing = false firstActiveAlignedContentHeight = contentHeight hasUserInteractedSinceFirstActiveAlignment = false } mutating func markUserInteractionAfterFirstActiveAlignment() { - guard hasCompletedFirstActiveAlignment else { return } + guard hasCompletedFirstActiveAlignment, !isFirstActiveAlignmentStabilizing else { return } hasUserInteractedSinceFirstActiveAlignment = true } @@ -679,7 +690,8 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle private var contentUpdateRevision: Int? private var lastAppliedPage: VideoDetailTabPage? private var alignmentState = VideoDetailListAlignmentState() - private var isFirstActiveAlignmentVerificationScheduled = false + private var pendingTopAlignmentRetryCount = 0 + private var firstActiveAlignmentVerificationPass = 0 var onHeaderOffsetChanged: (VideoPageTab, CGFloat) -> Void = { _, _ in } init(tab: VideoPageTab) { @@ -897,8 +909,23 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle private func resolvePendingTopAlignmentSoon() { resolvePendingTopAlignmentIfPossible() + schedulePendingTopAlignmentRetry() + } + + private func schedulePendingTopAlignmentRetry() { + guard alignmentState.pendingTopAlignment != nil else { + pendingTopAlignmentRetryCount = 0 + return + } + guard pendingTopAlignmentRetryCount < 8 else { return } + pendingTopAlignmentRetryCount += 1 DispatchQueue.main.async { [weak self] in - self?.resolvePendingTopAlignmentIfPossible() + guard let self else { return } + self.view.layoutIfNeeded() + self.resolvePendingTopAlignmentIfPossible() + if self.alignmentState.pendingTopAlignment != nil { + self.schedulePendingTopAlignmentRetry() + } } } @@ -911,6 +938,7 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle if abs(scrollView.verticalContentOffsetExcludingBounce - targetOffsetY) <= 0.5 { alignmentState.markInitialOffsetApplied() alignmentState.cancelPendingTopAlignment() + pendingTopAlignmentRetryCount = 0 if lastAppliedPage?.isSelected == true { markFirstActiveAlignmentCompleted() } @@ -1002,14 +1030,31 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle } private func scheduleFirstActiveAlignmentVerification() { - guard !isFirstActiveAlignmentVerificationScheduled else { return } - isFirstActiveAlignmentVerificationScheduled = true + guard firstActiveAlignmentVerificationPass == 0 else { return } + firstActiveAlignmentVerificationPass = 1 DispatchQueue.main.async { [weak self] in - guard let self else { return } - self.isFirstActiveAlignmentVerificationScheduled = false - self.view.layoutIfNeeded() - self.applyCurrentPageGeometryRules() + self?.runFirstActiveAlignmentVerification() + } + } + + private func runFirstActiveAlignmentVerification() { + view.layoutIfNeeded() + applyCurrentPageGeometryRules() + guard alignmentState.hasCompletedFirstActiveAlignment, + alignmentState.isFirstActiveAlignmentStabilizing, + lastAppliedPage?.isSelected == true else { + firstActiveAlignmentVerificationPass = 0 + return + } + if firstActiveAlignmentVerificationPass < 3 { + firstActiveAlignmentVerificationPass += 1 + DispatchQueue.main.async { [weak self] in + self?.runFirstActiveAlignmentVerification() + } + return } + alignmentState.markFirstActiveAlignmentStabilized(contentHeight: scrollView.contentSize.height) + firstActiveAlignmentVerificationPass = 0 } var normalizedContentOffsetY: CGFloat { From c31c75e3614797e579a74a314740cb6868d78b57 Mon Sep 17 00:00:00 2001 From: pgs666 <74764545+pgs666@users.noreply.github.com> Date: Sun, 7 Jun 2026 13:26:14 +0800 Subject: [PATCH 136/216] Prepare comment table controller for pager --- iosApp/CommentListTableView.swift | 136 +++++++++++++++++++----------- iosApp/VideoDetailPager.swift | 13 ++- 2 files changed, 99 insertions(+), 50 deletions(-) diff --git a/iosApp/CommentListTableView.swift b/iosApp/CommentListTableView.swift index a3d0bfa7..a0cec3b5 100644 --- a/iosApp/CommentListTableView.swift +++ b/iosApp/CommentListTableView.swift @@ -11,73 +11,115 @@ struct CommentListTableView: UIViewRepresentable { let onReport: (CommentRow) -> Void func makeCoordinator() -> Coordinator { - Coordinator(parent: self) + Coordinator() } func makeUIView(context: Context) -> IntrinsicCommentTableView { let tableView = IntrinsicCommentTableView(frame: .zero, style: .plain) - tableView.backgroundColor = .clear - tableView.separatorStyle = .none - tableView.showsVerticalScrollIndicator = false tableView.isScrollEnabled = false tableView.alwaysBounceVertical = false - tableView.estimatedRowHeight = 0 - tableView.estimatedSectionHeaderHeight = 0 - tableView.estimatedSectionFooterHeight = 0 - tableView.rowHeight = UITableView.automaticDimension - tableView.sectionHeaderHeight = 0 - tableView.sectionFooterHeight = 0 - tableView.contentInsetAdjustmentBehavior = .never - tableView.dataSource = context.coordinator - tableView.delegate = context.coordinator - tableView.register(HostingCommentTableViewCell.self, forCellReuseIdentifier: HostingCommentTableViewCell.reuseIdentifier) - tableView.register(CommentListFooterCell.self, forCellReuseIdentifier: CommentListFooterCell.reuseIdentifier) + context.coordinator.controller.attach(tableView) return tableView } func updateUIView(_ tableView: IntrinsicCommentTableView, context: Context) { - context.coordinator.parent = self - tableView.reloadData() + context.coordinator.controller.update( + comments: comments, + runningActionIDs: runningActionIDs, + onReply: onReply, + onShowReplies: onShowReplies, + onLike: onLike, + onDislike: onDislike, + onReport: onReport + ) tableView.invalidateIntrinsicContentSize() } - final class Coordinator: NSObject, UITableViewDataSource, UITableViewDelegate { - var parent: CommentListTableView + final class Coordinator { + let controller = CommentListTableController() + } +} - init(parent: CommentListTableView) { - self.parent = parent - } +final class CommentListTableController: NSObject, UITableViewDataSource, UITableViewDelegate { + private(set) weak var tableView: UITableView? + private var comments: [CommentRow] = [] + private var runningActionIDs: Set = [] + private var onReply: (CommentRow) -> Void = { _ in } + private var onShowReplies: (CommentRow) -> Void = { _ in } + private var onLike: (CommentRow) -> Void = { _ in } + private var onDislike: (CommentRow) -> Void = { _ in } + private var onReport: (CommentRow) -> Void = { _ in } + + func attach(_ tableView: UITableView) { + self.tableView = tableView + configure(tableView) + tableView.dataSource = self + tableView.delegate = self + } - func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { - parent.comments.count + 1 - } + func update( + comments: [CommentRow], + runningActionIDs: Set, + onReply: @escaping (CommentRow) -> Void, + onShowReplies: @escaping (CommentRow) -> Void, + onLike: @escaping (CommentRow) -> Void, + onDislike: @escaping (CommentRow) -> Void, + onReport: @escaping (CommentRow) -> Void + ) { + self.comments = comments + self.runningActionIDs = runningActionIDs + self.onReply = onReply + self.onShowReplies = onShowReplies + self.onLike = onLike + self.onDislike = onDislike + self.onReport = onReport + tableView?.reloadData() + } - func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { - if indexPath.row == parent.comments.count { - let cell = tableView.dequeueReusableCell( - withIdentifier: CommentListFooterCell.reuseIdentifier, - for: indexPath - ) as? CommentListFooterCell ?? CommentListFooterCell(style: .default, reuseIdentifier: CommentListFooterCell.reuseIdentifier) - cell.configure() - return cell - } - - let comment = parent.comments[indexPath.row] + func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + comments.count + 1 + } + + func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + if indexPath.row == comments.count { let cell = tableView.dequeueReusableCell( - withIdentifier: HostingCommentTableViewCell.reuseIdentifier, + withIdentifier: CommentListFooterCell.reuseIdentifier, for: indexPath - ) as? HostingCommentTableViewCell ?? HostingCommentTableViewCell(style: .default, reuseIdentifier: HostingCommentTableViewCell.reuseIdentifier) - cell.configure( - comment: comment, - isRunningLike: parent.runningActionIDs.contains("like-\(comment.id)"), - onReply: { [weak self] in self?.parent.onReply(comment) }, - onShowReplies: { [weak self] in self?.parent.onShowReplies(comment) }, - onLike: { [weak self] in self?.parent.onLike(comment) }, - onDislike: { [weak self] in self?.parent.onDislike(comment) }, - onReport: { [weak self] in self?.parent.onReport(comment) } - ) + ) as? CommentListFooterCell ?? CommentListFooterCell(style: .default, reuseIdentifier: CommentListFooterCell.reuseIdentifier) + cell.configure() return cell } + + let comment = comments[indexPath.row] + let cell = tableView.dequeueReusableCell( + withIdentifier: HostingCommentTableViewCell.reuseIdentifier, + for: indexPath + ) as? HostingCommentTableViewCell ?? HostingCommentTableViewCell(style: .default, reuseIdentifier: HostingCommentTableViewCell.reuseIdentifier) + cell.configure( + comment: comment, + isRunningLike: runningActionIDs.contains("like-\(comment.id)"), + onReply: { [weak self] in self?.onReply(comment) }, + onShowReplies: { [weak self] in self?.onShowReplies(comment) }, + onLike: { [weak self] in self?.onLike(comment) }, + onDislike: { [weak self] in self?.onDislike(comment) }, + onReport: { [weak self] in self?.onReport(comment) } + ) + return cell + } + + private func configure(_ tableView: UITableView) { + tableView.backgroundColor = .clear + tableView.separatorStyle = .none + tableView.showsVerticalScrollIndicator = false + tableView.estimatedRowHeight = 0 + tableView.estimatedSectionHeaderHeight = 0 + tableView.estimatedSectionFooterHeight = 0 + tableView.rowHeight = UITableView.automaticDimension + tableView.sectionHeaderHeight = 0 + tableView.sectionFooterHeight = 0 + tableView.contentInsetAdjustmentBehavior = .never + tableView.register(HostingCommentTableViewCell.self, forCellReuseIdentifier: HostingCommentTableViewCell.reuseIdentifier) + tableView.register(CommentListFooterCell.self, forCellReuseIdentifier: CommentListFooterCell.reuseIdentifier) } } diff --git a/iosApp/VideoDetailPager.swift b/iosApp/VideoDetailPager.swift index 90569c21..27b77619 100644 --- a/iosApp/VideoDetailPager.swift +++ b/iosApp/VideoDetailPager.swift @@ -344,6 +344,10 @@ private enum VideoDetailPendingTopAlignment { case explicit(CGFloat) } +private enum VideoDetailTabPageContent { + case swiftUI(() -> AnyView) +} + private struct VideoDetailListAlignmentState { var pendingTopAlignment: VideoDetailPendingTopAlignment? var needsInitialHeaderOffsetReset = true @@ -472,7 +476,7 @@ private struct VideoDetailTabPage { let onOffsetChange: (VideoPageTab, CGFloat) -> Void let onInteractionBegan: (VideoPageTab) -> Void let onTopPullDelta: (VideoPageTab, CGFloat) -> Void - let content: () -> AnyView + let content: VideoDetailTabPageContent init( tab: VideoPageTab, @@ -493,7 +497,7 @@ private struct VideoDetailTabPage { self.onOffsetChange = onOffsetChange self.onInteractionBegan = onInteractionBegan self.onTopPullDelta = onTopPullDelta - self.content = { AnyView(content()) } + self.content = .swiftUI({ AnyView(content()) }) } } @@ -817,7 +821,10 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle } if contentUpdateRevision != page.contentUpdateRevision { contentUpdateRevision = page.contentUpdateRevision - host.rootView = page.content() + switch page.content { + case .swiftUI(let content): + host.rootView = content() + } alignmentState.resetForContentUpdate() if !alignmentState.hasAppliedInitialListOffset && !alignmentState.hasExplicitPendingTopAlignment { alignmentState.needsInitialHeaderOffsetReset = true From 764eaf2dc854e25473f3672c9cd794e5954d71ad Mon Sep 17 00:00:00 2001 From: pgs666 <74764545+pgs666@users.noreply.github.com> Date: Sun, 7 Jun 2026 13:33:47 +0800 Subject: [PATCH 137/216] Allow pager pages to use injected scroll views --- iosApp/VideoDetailPager.swift | 200 ++++++++++++++++++---------------- 1 file changed, 106 insertions(+), 94 deletions(-) diff --git a/iosApp/VideoDetailPager.swift b/iosApp/VideoDetailPager.swift index 27b77619..39697aa3 100644 --- a/iosApp/VideoDetailPager.swift +++ b/iosApp/VideoDetailPager.swift @@ -317,16 +317,16 @@ private struct VideoDetailSmoothHeaderGeometry: Equatable { ) } - func rawContentOffsetY(forNormalizedOffsetY offsetY: CGFloat, in scrollView: UIScrollView) -> CGFloat { - let inset = scrollView.adjustedContentInset + func rawContentOffsetY(forNormalizedOffsetY offsetY: CGFloat, in listScrollView: UIScrollView) -> CGFloat { + let inset = listScrollView.adjustedContentInset let minOffsetY = -inset.top - let maxOffsetY = max(minOffsetY, scrollView.contentSize.height - scrollView.bounds.height + inset.bottom) + let maxOffsetY = max(minOffsetY, listScrollView.contentSize.height - listScrollView.bounds.height + inset.bottom) let rawOffsetY = offsetY - inset.top return min(max(rawOffsetY, minOffsetY), maxOffsetY) } - func normalizedContentOffsetY(forRawOffsetY rawOffsetY: CGFloat, in scrollView: UIScrollView) -> CGFloat { - rawOffsetY + scrollView.adjustedContentInset.top + func normalizedContentOffsetY(forRawOffsetY rawOffsetY: CGFloat, in listScrollView: UIScrollView) -> CGFloat { + rawOffsetY + listScrollView.adjustedContentInset.top } } @@ -642,9 +642,9 @@ private final class VideoDetailVerticalScrollPageCoordinator: NSObject, UIScroll self.onTopPullDelta = onTopPullDelta } - func scrollViewDidScroll(_ scrollView: UIScrollView) { + func scrollViewDidScroll(_ listScrollView: UIScrollView) { guard !isApplyingExternalOffset, !isHorizontalPagingActive else { return } - let offset = scrollView.verticalContentOffsetExcludingBounce + let offset = listScrollView.verticalContentOffsetExcludingBounce onVisibleOffsetChange(tab, offset) guard lastReportedOffset.map({ abs($0 - offset) > 0.5 }) ?? true else { return } lastReportedOffset = offset @@ -656,18 +656,18 @@ private final class VideoDetailVerticalScrollPageCoordinator: NSObject, UIScroll } @objc func handlePan(_ panGestureRecognizer: UIPanGestureRecognizer) { - guard let scrollView = panGestureRecognizer.view as? UIScrollView else { return } + guard let listScrollView = panGestureRecognizer.view as? UIScrollView else { return } switch panGestureRecognizer.state { case .began: onVerticalInteractionBegan() onInteractionBegan(tab) lastTopPullTranslationY = 0 case .changed: - guard scrollView.verticalContentOffsetExcludingBounce <= visualTopContentOffsetY + 0.5 else { - lastTopPullTranslationY = panGestureRecognizer.translation(in: scrollView).y + guard listScrollView.verticalContentOffsetExcludingBounce <= visualTopContentOffsetY + 0.5 else { + lastTopPullTranslationY = panGestureRecognizer.translation(in: listScrollView).y return } - let translationY = panGestureRecognizer.translation(in: scrollView).y + let translationY = panGestureRecognizer.translation(in: listScrollView).y let delta = translationY - lastTopPullTranslationY lastTopPullTranslationY = translationY if delta > 0 { @@ -681,7 +681,8 @@ private final class VideoDetailVerticalScrollPageCoordinator: NSObject, UIScroll private final class VideoDetailVerticalScrollPageViewController: UIViewController { private let coordinator: VideoDetailVerticalScrollPageCoordinator - private let scrollView = VerticalScrollView() + private let listScrollView: UIScrollView + private let defaultScrollView: VerticalScrollView? private let contentView = UIView() private let listHeaderView = UIView() private let collapseSpacerView = UIView() @@ -696,9 +697,14 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle private var alignmentState = VideoDetailListAlignmentState() private var pendingTopAlignmentRetryCount = 0 private var firstActiveAlignmentVerificationPass = 0 + private var listScrollViewContentSizeObservation: NSKeyValueObservation? + private var listScrollViewBoundsObservation: NSKeyValueObservation? var onHeaderOffsetChanged: (VideoPageTab, CGFloat) -> Void = { _, _ in } - init(tab: VideoPageTab) { + init(tab: VideoPageTab, listScrollView: UIScrollView? = nil) { + let resolvedScrollView = listScrollView ?? VerticalScrollView() + self.listScrollView = resolvedScrollView + self.defaultScrollView = resolvedScrollView as? VerticalScrollView coordinator = VideoDetailVerticalScrollPageCoordinator( tab: tab, onOffsetChange: { _, _ in }, @@ -717,35 +723,41 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle super.viewDidLoad() view.backgroundColor = .clear - scrollView.translatesAutoresizingMaskIntoConstraints = false - scrollView.backgroundColor = .clear - scrollView.bounces = true - scrollView.alwaysBounceVertical = true - scrollView.alwaysBounceHorizontal = false - scrollView.showsHorizontalScrollIndicator = false - scrollView.showsVerticalScrollIndicator = false - scrollView.isDirectionalLockEnabled = true - scrollView.contentInsetAdjustmentBehavior = .never - scrollView.keyboardDismissMode = .interactive - scrollView.delegate = coordinator - scrollView.panGestureRecognizer.addTarget(coordinator, action: #selector(VideoDetailVerticalScrollPageCoordinator.handlePan(_:))) - scrollView.onGeometryChange = { [weak self] in + listScrollView.translatesAutoresizingMaskIntoConstraints = false + listScrollView.backgroundColor = .clear + listScrollView.bounces = true + listScrollView.alwaysBounceVertical = true + listScrollView.alwaysBounceHorizontal = false + listScrollView.showsHorizontalScrollIndicator = false + listScrollView.showsVerticalScrollIndicator = false + listScrollView.isDirectionalLockEnabled = true + listScrollView.contentInsetAdjustmentBehavior = .never + listScrollView.keyboardDismissMode = .interactive + listScrollView.delegate = coordinator + listScrollView.panGestureRecognizer.addTarget(coordinator, action: #selector(VideoDetailVerticalScrollPageCoordinator.handlePan(_:))) + defaultScrollView?.onGeometryChange = { [weak self] in self?.handleScrollGeometryChange() } - scrollView.shouldBeginVerticalPan = { [weak self] panGestureRecognizer, view in + listScrollViewContentSizeObservation = listScrollView.observe(\.contentSize, options: [.new]) { [weak self] _, _ in + self?.handleScrollGeometryChange() + } + listScrollViewBoundsObservation = listScrollView.observe(\.bounds, options: [.new]) { [weak self] _, _ in + self?.handleScrollGeometryChange() + } + defaultScrollView?.shouldBeginVerticalPan = { [weak self] panGestureRecognizer, view in self?.resolvePendingTopAlignmentIfPossible(allowDuringInteraction: true) let velocity = panGestureRecognizer.velocity(in: view) return abs(velocity.x) <= abs(velocity.y) * 1.05 } - view.addSubview(scrollView) + view.addSubview(listScrollView) contentView.translatesAutoresizingMaskIntoConstraints = false contentView.backgroundColor = .clear - scrollView.addSubview(contentView) + listScrollView.addSubview(contentView) listHeaderView.backgroundColor = .clear listHeaderView.isUserInteractionEnabled = true - scrollView.addSubview(listHeaderView) + listScrollView.addSubview(listHeaderView) addChild(host) host.view.translatesAutoresizingMaskIntoConstraints = false @@ -761,22 +773,22 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle contentBottomSpacerView.backgroundColor = .clear contentView.addSubview(contentBottomSpacerView) - hostMinimumHeightConstraint = host.view.heightAnchor.constraint(greaterThanOrEqualTo: scrollView.frameLayoutGuide.heightAnchor) + hostMinimumHeightConstraint = host.view.heightAnchor.constraint(greaterThanOrEqualTo: listScrollView.frameLayoutGuide.heightAnchor) contentMinimumHeightConstraint = contentView.heightAnchor.constraint(greaterThanOrEqualToConstant: 1) collapseSpacerHeightConstraint = collapseSpacerView.heightAnchor.constraint(equalToConstant: 1) contentBottomSpacerHeightConstraint = contentBottomSpacerView.heightAnchor.constraint(equalToConstant: 0) NSLayoutConstraint.activate([ - scrollView.leadingAnchor.constraint(equalTo: view.leadingAnchor), - scrollView.trailingAnchor.constraint(equalTo: view.trailingAnchor), - scrollView.topAnchor.constraint(equalTo: view.topAnchor), - scrollView.bottomAnchor.constraint(equalTo: view.bottomAnchor), - - contentView.leadingAnchor.constraint(equalTo: scrollView.contentLayoutGuide.leadingAnchor), - contentView.trailingAnchor.constraint(equalTo: scrollView.contentLayoutGuide.trailingAnchor), - contentView.topAnchor.constraint(equalTo: scrollView.contentLayoutGuide.topAnchor), - contentView.bottomAnchor.constraint(equalTo: scrollView.contentLayoutGuide.bottomAnchor), - contentView.widthAnchor.constraint(equalTo: scrollView.frameLayoutGuide.widthAnchor), + listScrollView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + listScrollView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + listScrollView.topAnchor.constraint(equalTo: view.topAnchor), + listScrollView.bottomAnchor.constraint(equalTo: view.bottomAnchor), + + contentView.leadingAnchor.constraint(equalTo: listScrollView.contentLayoutGuide.leadingAnchor), + contentView.trailingAnchor.constraint(equalTo: listScrollView.contentLayoutGuide.trailingAnchor), + contentView.topAnchor.constraint(equalTo: listScrollView.contentLayoutGuide.topAnchor), + contentView.bottomAnchor.constraint(equalTo: listScrollView.contentLayoutGuide.bottomAnchor), + contentView.widthAnchor.constraint(equalTo: listScrollView.frameLayoutGuide.widthAnchor), contentMinimumHeightConstraint, host.view.leadingAnchor.constraint(equalTo: contentView.leadingAnchor), @@ -834,7 +846,7 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle } let geometry = page.headerGeometry - let offsetContext = geometry.listOffsetContext(in: scrollView.bounds.height) + let offsetContext = geometry.listOffsetContext(in: listScrollView.bounds.height) let visualTopOffset = offsetContext.initialNormalizedOffsetY lastAppliedPage = page coordinator.visualTopContentOffsetY = visualTopOffset @@ -858,13 +870,13 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle private func applyCurrentPageGeometryRules() { guard let page = lastAppliedPage else { return } let geometry = page.headerGeometry - let offsetContext = geometry.listOffsetContext(in: scrollView.bounds.height) + let offsetContext = geometry.listOffsetContext(in: listScrollView.bounds.height) if abs(contentMinimumHeightConstraint.constant - offsetContext.minimumContentHeight) > 0.5 { contentMinimumHeightConstraint.constant = offsetContext.minimumContentHeight } if alignmentState.shouldReopenFirstActiveAlignment( isSelected: page.isSelected, - contentHeight: scrollView.contentSize.height + contentHeight: listScrollView.contentSize.height ) { alignmentState.reopenFirstActiveAlignmentAfterContentSizeChange() } @@ -883,9 +895,9 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle private func applyTopContentInset(_ topInset: CGFloat) { let resolvedTopInset = max(topInset, 0) - guard abs(scrollView.contentInset.top - resolvedTopInset) > 0.5 else { return } - scrollView.contentInset.top = resolvedTopInset - scrollView.verticalScrollIndicatorInsets.top = resolvedTopInset + guard abs(listScrollView.contentInset.top - resolvedTopInset) > 0.5 else { return } + listScrollView.contentInset.top = resolvedTopInset + listScrollView.verticalScrollIndicatorInsets.top = resolvedTopInset applyListHeaderFrame() } @@ -894,20 +906,20 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle if abs(contentBottomSpacerHeightConstraint.constant - resolvedBottomSpacing) > 0.5 { contentBottomSpacerHeightConstraint.constant = resolvedBottomSpacing } - if abs(scrollView.contentInset.bottom) > 0.5 { - scrollView.contentInset.bottom = 0 + if abs(listScrollView.contentInset.bottom) > 0.5 { + listScrollView.contentInset.bottom = 0 } - if abs(scrollView.verticalScrollIndicatorInsets.bottom - resolvedBottomSpacing) > 0.5 { - scrollView.verticalScrollIndicatorInsets.bottom = resolvedBottomSpacing + if abs(listScrollView.verticalScrollIndicatorInsets.bottom - resolvedBottomSpacing) > 0.5 { + listScrollView.verticalScrollIndicatorInsets.bottom = resolvedBottomSpacing } } private func applyListHeaderFrame() { - let topInset = max(scrollView.contentInset.top, 0) + let topInset = max(listScrollView.contentInset.top, 0) let nextFrame = CGRect( x: 0, y: -topInset, - width: scrollView.bounds.width, + width: listScrollView.bounds.width, height: topInset ) guard !listHeaderView.frame.isApproximatelyEqual(to: nextFrame) else { return } @@ -939,10 +951,10 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle private func resolvePendingTopAlignmentIfPossible(allowDuringInteraction: Bool = false) { guard let targetOffsetY = pendingTopAlignmentTargetOffsetY() else { return } if !allowDuringInteraction { - guard !scrollView.isTracking, !scrollView.isDragging, !scrollView.isDecelerating else { return } + guard !listScrollView.isTracking, !listScrollView.isDragging, !listScrollView.isDecelerating else { return } } guard setNormalizedContentOffsetYIfReachable(targetOffsetY) else { return } - if abs(scrollView.verticalContentOffsetExcludingBounce - targetOffsetY) <= 0.5 { + if abs(listScrollView.verticalContentOffsetExcludingBounce - targetOffsetY) <= 0.5 { alignmentState.markInitialOffsetApplied() alignmentState.cancelPendingTopAlignment() pendingTopAlignmentRetryCount = 0 @@ -955,17 +967,17 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle private func applyInitialHeaderOffsetResetIfNeeded(allowDuringInteraction: Bool = false) { guard alignmentState.needsInitialHeaderOffsetReset, let page = lastAppliedPage else { return } if !allowDuringInteraction { - guard !scrollView.isTracking, !scrollView.isDragging, !scrollView.isDecelerating else { return } + guard !listScrollView.isTracking, !listScrollView.isDragging, !listScrollView.isDecelerating else { return } } let targetOffsetY = page.headerGeometry.listOffsetContext( - in: scrollView.bounds.height + in: listScrollView.bounds.height ).initialNormalizedOffsetY guard setNormalizedContentOffsetYIfReachable(targetOffsetY) else { alignmentState.pendingTopAlignment = .initial resolvePendingTopAlignmentSoon() return } - if abs(scrollView.verticalContentOffsetExcludingBounce - targetOffsetY) <= 0.5 { + if abs(listScrollView.verticalContentOffsetExcludingBounce - targetOffsetY) <= 0.5 { alignmentState.markInitialOffsetApplied() alignmentState.cancelPendingTopAlignment() } @@ -980,7 +992,7 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle let page = lastAppliedPage else { return nil } switch pendingTopAlignment { case .initial: - return page.headerGeometry.listOffsetContext(in: scrollView.bounds.height).initialNormalizedOffsetY + return page.headerGeometry.listOffsetContext(in: listScrollView.bounds.height).initialNormalizedOffsetY case .explicit(let offsetY): return offsetY } @@ -998,7 +1010,7 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle private func applyFirstActiveAlignmentIfNeeded(allowDuringInteraction: Bool = false) { guard let page = lastAppliedPage else { return } let targetOffsetY = page.headerGeometry.listOffsetContext( - in: scrollView.bounds.height + in: listScrollView.bounds.height ).initialNormalizedOffsetY coordinator.visualTopContentOffsetY = targetOffsetY if alignmentState.needsInitialHeaderOffsetReset { @@ -1006,7 +1018,7 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle guard !alignmentState.needsInitialHeaderOffsetReset else { return } } if !allowDuringInteraction { - guard !scrollView.isTracking, !scrollView.isDragging, !scrollView.isDecelerating else { return } + guard !listScrollView.isTracking, !listScrollView.isDragging, !listScrollView.isDecelerating else { return } } if !alignmentState.hasCompletedFirstActiveAlignment { guard setNormalizedContentOffsetYIfReachable(targetOffsetY) else { @@ -1020,7 +1032,7 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle return } guard VideoDetailPagerOffsetModel.shouldAlignToVisualTopAfterHorizontalActivation( - currentOffset: scrollView.verticalContentOffsetExcludingBounce, + currentOffset: listScrollView.verticalContentOffsetExcludingBounce, visualTopOffset: targetOffsetY ) else { cancelPendingTopAlignment() @@ -1032,7 +1044,7 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle } private func markFirstActiveAlignmentCompleted() { - alignmentState.markFirstActiveAlignmentCompleted(contentHeight: scrollView.contentSize.height) + alignmentState.markFirstActiveAlignmentCompleted(contentHeight: listScrollView.contentSize.height) scheduleFirstActiveAlignmentVerification() } @@ -1060,13 +1072,13 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle } return } - alignmentState.markFirstActiveAlignmentStabilized(contentHeight: scrollView.contentSize.height) + alignmentState.markFirstActiveAlignmentStabilized(contentHeight: listScrollView.contentSize.height) firstActiveAlignmentVerificationPass = 0 } var normalizedContentOffsetY: CGFloat { loadViewIfNeeded() - return scrollView.verticalContentOffsetExcludingBounce + return listScrollView.verticalContentOffsetExcludingBounce } func syncHeaderOffsetFromActivePage(_ syncMode: VideoDetailPagerOffsetModel.InactiveSyncMode) { @@ -1084,25 +1096,25 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle func setHorizontalPagingActive(_ isActive: Bool) { coordinator.isHorizontalPagingActive = isActive if !isActive { - coordinator.resetReportedOffset(scrollView.verticalContentOffsetExcludingBounce) + coordinator.resetReportedOffset(listScrollView.verticalContentOffsetExcludingBounce) } } func setScrollsToTop(_ scrollsToTop: Bool) { loadViewIfNeeded() - scrollView.scrollsToTop = scrollsToTop + listScrollView.scrollsToTop = scrollsToTop } func reportCurrentOffset() { loadViewIfNeeded() - let offset = scrollView.verticalContentOffsetExcludingBounce + let offset = listScrollView.verticalContentOffsetExcludingBounce coordinator.resetReportedOffset(offset) coordinator.onOffsetChange(coordinator.tab, offset) } private func setNormalizedContentOffsetY(_ offsetY: CGFloat) { guard let page = lastAppliedPage else { return } - let rawTopOffsetY = page.headerGeometry.rawContentOffsetY(forNormalizedOffsetY: offsetY, in: scrollView) + let rawTopOffsetY = page.headerGeometry.rawContentOffsetY(forNormalizedOffsetY: offsetY, in: listScrollView) setRawContentOffsetYIfNeeded(rawTopOffsetY) } @@ -1117,12 +1129,12 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle } private func setRawContentOffsetYIfNeeded(_ rawTopOffsetY: CGFloat) { - guard abs(scrollView.contentOffset.y - rawTopOffsetY) > 0.5 else { return } + guard abs(listScrollView.contentOffset.y - rawTopOffsetY) > 0.5 else { return } coordinator.isApplyingExternalOffset = true defer { coordinator.isApplyingExternalOffset = false } - scrollView.setContentOffset(CGPoint(x: scrollView.contentOffset.x, y: rawTopOffsetY), animated: false) - coordinator.resetReportedOffset(scrollView.verticalContentOffsetExcludingBounce) - onHeaderOffsetChanged(coordinator.tab, scrollView.verticalContentOffsetExcludingBounce) + listScrollView.setContentOffset(CGPoint(x: listScrollView.contentOffset.x, y: rawTopOffsetY), animated: false) + coordinator.resetReportedOffset(listScrollView.verticalContentOffsetExcludingBounce) + onHeaderOffsetChanged(coordinator.tab, listScrollView.verticalContentOffsetExcludingBounce) } func headerAttachmentView() -> UIView { @@ -1132,12 +1144,12 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle private func clampedRawContentOffsetY(forNormalizedOffsetY offsetY: CGFloat) -> CGFloat { guard let page = lastAppliedPage else { return 0 } - return page.headerGeometry.rawContentOffsetY(forNormalizedOffsetY: offsetY, in: scrollView) + return page.headerGeometry.rawContentOffsetY(forNormalizedOffsetY: offsetY, in: listScrollView) } private func normalizedContentOffsetY(forRawOffsetY rawOffsetY: CGFloat) -> CGFloat { - guard let page = lastAppliedPage else { return rawOffsetY + scrollView.adjustedContentInset.top } - return page.headerGeometry.normalizedContentOffsetY(forRawOffsetY: rawOffsetY, in: scrollView) + guard let page = lastAppliedPage else { return rawOffsetY + listScrollView.adjustedContentInset.top } + return page.headerGeometry.normalizedContentOffsetY(forRawOffsetY: rawOffsetY, in: listScrollView) } } @@ -1259,30 +1271,30 @@ private struct VideoDetailTabPager: UIViewControllerRepresentable { return lastProgrammaticIndex != index } - func scrollViewWillBeginDragging(_ scrollView: UIScrollView) { + func scrollViewWillBeginDragging(_ listScrollView: UIScrollView) { onPagingActivityChanged?(true) } - func scrollViewDidScroll(_ scrollView: UIScrollView) { - let width = scrollView.bounds.width + func scrollViewDidScroll(_ listScrollView: UIScrollView) { + let width = listScrollView.bounds.width guard width > 0 else { return } - let index = Int(scrollView.contentOffset.x / width) + let index = Int(listScrollView.contentOffset.x / width) onHorizontalVisibleIndexChanged?(min(max(index, 0), VideoPageTab.allCases.count - 1)) } - func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) { - updateSelectedTab(from: scrollView) + func scrollViewDidEndDecelerating(_ listScrollView: UIScrollView) { + updateSelectedTab(from: listScrollView) onPagingActivityChanged?(false) } - func scrollViewDidEndScrollingAnimation(_ scrollView: UIScrollView) { - updateSelectedTab(from: scrollView) + func scrollViewDidEndScrollingAnimation(_ listScrollView: UIScrollView) { + updateSelectedTab(from: listScrollView) onPagingActivityChanged?(false) } - func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) { + func scrollViewDidEndDragging(_ listScrollView: UIScrollView, willDecelerate decelerate: Bool) { if !decelerate { - updateSelectedTab(from: scrollView) + updateSelectedTab(from: listScrollView) onPagingActivityChanged?(false) } } @@ -1304,10 +1316,10 @@ private struct VideoDetailTabPager: UIViewControllerRepresentable { return horizontal > 8 && horizontal > vertical * 1.18 } - private func updateSelectedTab(from scrollView: UIScrollView) { - let width = scrollView.bounds.width + private func updateSelectedTab(from listScrollView: UIScrollView) { + let width = listScrollView.bounds.width guard width > 0 else { return } - let index = Int(round(scrollView.contentOffset.x / width)) + let index = Int(round(listScrollView.contentOffset.x / width)) let tab = VideoPageTab.page(at: index) if selectedTab.wrappedValue != tab { pendingSettledIndex = index @@ -1806,11 +1818,11 @@ private extension UIView { guard let hitView = hitTest(location, with: nil) else { return false } var current: UIView? = hitView while let view = current, view !== excludedView { - if let scrollView = view as? UIScrollView, - scrollView.isScrollEnabled, - scrollView.panGestureRecognizer.isEnabled, - scrollView.contentSize.width > scrollView.bounds.width + 1, - scrollView.contentSize.width > scrollView.contentSize.height { + if let listScrollView = view as? UIScrollView, + listScrollView.isScrollEnabled, + listScrollView.panGestureRecognizer.isEnabled, + listScrollView.contentSize.width > listScrollView.bounds.width + 1, + listScrollView.contentSize.width > listScrollView.contentSize.height { return true } current = view.superview From f83615b79d5211b3d2886ebb39e854aa8d2279f4 Mon Sep 17 00:00:00 2001 From: pgs666 <74764545+pgs666@users.noreply.github.com> Date: Sun, 7 Jun 2026 13:42:27 +0800 Subject: [PATCH 138/216] Render comment page through table model --- iosApp/CommentListTableView.swift | 317 +++++++++++++++++++++++++++--- iosApp/CommentView.swift | 147 ++++---------- 2 files changed, 320 insertions(+), 144 deletions(-) diff --git a/iosApp/CommentListTableView.swift b/iosApp/CommentListTableView.swift index a0cec3b5..80b776da 100644 --- a/iosApp/CommentListTableView.swift +++ b/iosApp/CommentListTableView.swift @@ -1,14 +1,60 @@ import SwiftUI import UIKit -struct CommentListTableView: UIViewRepresentable { - let comments: [CommentRow] +struct CommentListTableModel { + let state: CommentViewModel.State + let sortMode: CommentViewModel.SortMode let runningActionIDs: Set + let comments: [CommentRow] + let onChangeSortMode: (CommentViewModel.SortMode) -> Void + let onRefresh: () -> Void + let onRetry: () -> Void let onReply: (CommentRow) -> Void let onShowReplies: (CommentRow) -> Void let onLike: (CommentRow) -> Void let onDislike: (CommentRow) -> Void let onReport: (CommentRow) -> Void +} + +struct CommentListTableView: UIViewRepresentable { + private let mode: Mode + + private enum Mode { + case full(CommentListTableModel) + case rows( + comments: [CommentRow], + runningActionIDs: Set, + onReply: (CommentRow) -> Void, + onShowReplies: (CommentRow) -> Void, + onLike: (CommentRow) -> Void, + onDislike: (CommentRow) -> Void, + onReport: (CommentRow) -> Void + ) + } + + init(model: CommentListTableModel) { + mode = .full(model) + } + + init( + comments: [CommentRow], + runningActionIDs: Set, + onReply: @escaping (CommentRow) -> Void, + onShowReplies: @escaping (CommentRow) -> Void, + onLike: @escaping (CommentRow) -> Void, + onDislike: @escaping (CommentRow) -> Void, + onReport: @escaping (CommentRow) -> Void + ) { + mode = .rows( + comments: comments, + runningActionIDs: runningActionIDs, + onReply: onReply, + onShowReplies: onShowReplies, + onLike: onLike, + onDislike: onDislike, + onReport: onReport + ) + } func makeCoordinator() -> Coordinator { Coordinator() @@ -23,15 +69,28 @@ struct CommentListTableView: UIViewRepresentable { } func updateUIView(_ tableView: IntrinsicCommentTableView, context: Context) { - context.coordinator.controller.update( - comments: comments, - runningActionIDs: runningActionIDs, - onReply: onReply, - onShowReplies: onShowReplies, - onLike: onLike, - onDislike: onDislike, - onReport: onReport - ) + switch mode { + case .full(let model): + context.coordinator.controller.update(model) + case .rows( + let comments, + let runningActionIDs, + let onReply, + let onShowReplies, + let onLike, + let onDislike, + let onReport + ): + context.coordinator.controller.updateRows( + comments: comments, + runningActionIDs: runningActionIDs, + onReply: onReply, + onShowReplies: onShowReplies, + onLike: onLike, + onDislike: onDislike, + onReport: onReport + ) + } tableView.invalidateIntrinsicContentSize() } @@ -41,9 +100,23 @@ struct CommentListTableView: UIViewRepresentable { } final class CommentListTableController: NSObject, UITableViewDataSource, UITableViewDelegate { + private enum Row { + case controls + case loading + case failed(String) + case empty + case comment(CommentRow) + case footer + } + private(set) weak var tableView: UITableView? + private var rows: [Row] = [] + private var sortMode: CommentViewModel.SortMode = .mostLikes private var comments: [CommentRow] = [] private var runningActionIDs: Set = [] + private var onChangeSortMode: (CommentViewModel.SortMode) -> Void = { _ in } + private var onRefresh: () -> Void = {} + private var onRetry: () -> Void = {} private var onReply: (CommentRow) -> Void = { _ in } private var onShowReplies: (CommentRow) -> Void = { _ in } private var onLike: (CommentRow) -> Void = { _ in } @@ -57,7 +130,23 @@ final class CommentListTableController: NSObject, UITableViewDataSource, UITable tableView.delegate = self } - func update( + func update(_ model: CommentListTableModel) { + sortMode = model.sortMode + comments = model.comments + runningActionIDs = model.runningActionIDs + onChangeSortMode = model.onChangeSortMode + onRefresh = model.onRefresh + onRetry = model.onRetry + onReply = model.onReply + onShowReplies = model.onShowReplies + onLike = model.onLike + onDislike = model.onDislike + onReport = model.onReport + rows = rows(for: model) + tableView?.reloadData() + } + + func updateRows( comments: [CommentRow], runningActionIDs: Set, onReply: @escaping (CommentRow) -> Void, @@ -73,38 +162,85 @@ final class CommentListTableController: NSObject, UITableViewDataSource, UITable self.onLike = onLike self.onDislike = onDislike self.onReport = onReport + rows = comments.map { .comment($0) } + [.footer] tableView?.reloadData() } func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { - comments.count + 1 + rows.count } func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { - if indexPath.row == comments.count { + switch rows[indexPath.row] { + case .controls: + let cell = tableView.dequeueReusableCell( + withIdentifier: HostingCommentTableViewCell.reuseIdentifier, + for: indexPath + ) as? HostingCommentTableViewCell ?? HostingCommentTableViewCell(style: .default, reuseIdentifier: HostingCommentTableViewCell.reuseIdentifier) + cell.configure { + CommentListControlsRowView( + sortMode: sortMode, + onChangeSortMode: { [weak self] mode in self?.onChangeSortMode(mode) }, + onRefresh: { [weak self] in self?.onRefresh() } + ) + .padding(.horizontal, 16) + .padding(.top, 16) + .padding(.bottom, 6) + } + return cell + case .loading: + let cell = tableView.dequeueReusableCell( + withIdentifier: HostingCommentTableViewCell.reuseIdentifier, + for: indexPath + ) as? HostingCommentTableViewCell ?? HostingCommentTableViewCell(style: .default, reuseIdentifier: HostingCommentTableViewCell.reuseIdentifier) + cell.configure { + CommentListLoadingRowView() + } + return cell + case .failed(let message): + let cell = tableView.dequeueReusableCell( + withIdentifier: HostingCommentTableViewCell.reuseIdentifier, + for: indexPath + ) as? HostingCommentTableViewCell ?? HostingCommentTableViewCell(style: .default, reuseIdentifier: HostingCommentTableViewCell.reuseIdentifier) + cell.configure { + CommentListFailedRowView( + message: message, + onRetry: { [weak self] in self?.onRetry() } + ) + } + return cell + case .empty: + let cell = tableView.dequeueReusableCell( + withIdentifier: HostingCommentTableViewCell.reuseIdentifier, + for: indexPath + ) as? HostingCommentTableViewCell ?? HostingCommentTableViewCell(style: .default, reuseIdentifier: HostingCommentTableViewCell.reuseIdentifier) + cell.configure { + CommentListEmptyRowView() + } + return cell + case .footer: let cell = tableView.dequeueReusableCell( withIdentifier: CommentListFooterCell.reuseIdentifier, for: indexPath ) as? CommentListFooterCell ?? CommentListFooterCell(style: .default, reuseIdentifier: CommentListFooterCell.reuseIdentifier) cell.configure() return cell + case .comment(let comment): + let cell = tableView.dequeueReusableCell( + withIdentifier: HostingCommentTableViewCell.reuseIdentifier, + for: indexPath + ) as? HostingCommentTableViewCell ?? HostingCommentTableViewCell(style: .default, reuseIdentifier: HostingCommentTableViewCell.reuseIdentifier) + cell.configure( + comment: comment, + isRunningLike: runningActionIDs.contains("like-\(comment.id)"), + onReply: { [weak self] in self?.onReply(comment) }, + onShowReplies: { [weak self] in self?.onShowReplies(comment) }, + onLike: { [weak self] in self?.onLike(comment) }, + onDislike: { [weak self] in self?.onDislike(comment) }, + onReport: { [weak self] in self?.onReport(comment) } + ) + return cell } - - let comment = comments[indexPath.row] - let cell = tableView.dequeueReusableCell( - withIdentifier: HostingCommentTableViewCell.reuseIdentifier, - for: indexPath - ) as? HostingCommentTableViewCell ?? HostingCommentTableViewCell(style: .default, reuseIdentifier: HostingCommentTableViewCell.reuseIdentifier) - cell.configure( - comment: comment, - isRunningLike: runningActionIDs.contains("like-\(comment.id)"), - onReply: { [weak self] in self?.onReply(comment) }, - onShowReplies: { [weak self] in self?.onShowReplies(comment) }, - onLike: { [weak self] in self?.onLike(comment) }, - onDislike: { [weak self] in self?.onDislike(comment) }, - onReport: { [weak self] in self?.onReport(comment) } - ) - return cell } private func configure(_ tableView: UITableView) { @@ -121,6 +257,20 @@ final class CommentListTableController: NSObject, UITableViewDataSource, UITable tableView.register(HostingCommentTableViewCell.self, forCellReuseIdentifier: HostingCommentTableViewCell.reuseIdentifier) tableView.register(CommentListFooterCell.self, forCellReuseIdentifier: CommentListFooterCell.reuseIdentifier) } + + private func rows(for model: CommentListTableModel) -> [Row] { + switch model.state { + case .idle, .loading: + return [.controls, .loading] + case .failed(let message): + return [.controls, .failed(message)] + case .loaded: + guard !model.comments.isEmpty else { + return [.controls, .empty] + } + return [.controls] + model.comments.map { .comment($0) } + [.footer] + } + } } final class IntrinsicCommentTableView: UITableView { @@ -161,6 +311,13 @@ private final class HostingCommentTableViewCell: UITableViewCell { contentConfiguration = nil } + func configure(@ViewBuilder content: () -> Content) { + contentConfiguration = UIHostingConfiguration { + content() + } + .margins(.all, 0) + } + func configure( comment: CommentRow, isRunningLike: Bool, @@ -189,6 +346,106 @@ private final class HostingCommentTableViewCell: UITableViewCell { } } +private struct CommentListControlsRowView: View { + let sortMode: CommentViewModel.SortMode + let onChangeSortMode: (CommentViewModel.SortMode) -> Void + let onRefresh: () -> Void + + var body: some View { + HStack(spacing: 12) { + Menu { + ForEach(CommentViewModel.SortMode.allCases) { mode in + Button { + onChangeSortMode(mode) + } label: { + if mode == sortMode { + Label(mode.title, systemImage: "checkmark") + } else { + Text(mode.title) + } + } + } + } label: { + Label(sortMode.title, systemImage: "arrow.up.arrow.down") + .font(.subheadline.weight(.semibold)) + .foregroundStyle(Color.accentColor) + .padding(.horizontal, 12) + .padding(.vertical, 7) + .background(Color.accentColor.opacity(0.12), in: RoundedRectangle(cornerRadius: 8, style: .continuous)) + } + Spacer() + + TapOnlyControl(action: onRefresh) { + Image(systemName: "arrow.clockwise") + .font(.subheadline.weight(.semibold)) + .foregroundStyle(Color.accentColor) + .padding(.horizontal, 12) + .padding(.vertical, 7) + .background(Color.accentColor.opacity(0.12), in: RoundedRectangle(cornerRadius: 8, style: .continuous)) + } + } + } +} + +private struct CommentListLoadingRowView: View { + var body: some View { + HStack { + Spacer() + ProgressView() + Spacer() + } + .padding(.vertical, 60) + } +} + +private struct CommentListFailedRowView: View { + let message: String + let onRetry: () -> Void + + var body: some View { + VStack(spacing: 12) { + Image(systemName: "text.bubble") + .font(.largeTitle) + .foregroundStyle(.secondary) + Text("评论加载失败") + .font(.headline) + Text(message) + .font(.subheadline) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + TapOnlyControl(action: onRetry) { + Text("重试") + .font(.subheadline.weight(.semibold)) + .foregroundStyle(.white) + .padding(.horizontal, 14) + .padding(.vertical, 8) + .background(Color.accentColor, in: RoundedRectangle(cornerRadius: 8, style: .continuous)) + } + CloudflareVerifyButton(errorMessage: message) + } + .frame(maxWidth: .infinity) + .padding(.horizontal, 16) + .padding(.vertical, 60) + } +} + +private struct CommentListEmptyRowView: View { + var body: some View { + VStack(spacing: 12) { + Image(systemName: "bubble.left.and.bubble.right") + .font(.largeTitle) + .foregroundStyle(.secondary) + Text("暂无评论") + .font(.headline) + Text("成为第一个评论的人。") + .font(.subheadline) + .foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity) + .padding(.vertical, 60) + } +} + private final class CommentListFooterCell: UITableViewCell { static let reuseIdentifier = "CommentListFooterCell" diff --git a/iosApp/CommentView.swift b/iosApp/CommentView.swift index 22a4a501..664da38c 100644 --- a/iosApp/CommentView.swift +++ b/iosApp/CommentView.swift @@ -18,11 +18,9 @@ struct CommentView: View { } var body: some View { - VStack(alignment: .leading, spacing: 12) { - header - content - } - .padding(.horizontal, 16) + CommentListTableView( + model: tableModel + ) .task { viewModel.loadIfNeeded() } @@ -82,117 +80,38 @@ struct CommentView: View { } } - private var header: some View { - HStack(spacing: 12) { - Menu { - ForEach(CommentViewModel.SortMode.allCases) { mode in - Button { - viewModel.changeSortMode(mode) - } label: { - if mode == viewModel.sortMode { - Label(mode.title, systemImage: "checkmark") - } else { - Text(mode.title) - } - } - } - } label: { - Label(viewModel.sortMode.title, systemImage: "arrow.up.arrow.down") - .font(.subheadline.weight(.semibold)) - .foregroundStyle(Color.accentColor) - .padding(.horizontal, 12) - .padding(.vertical, 7) - .background(Color.accentColor.opacity(0.12), in: RoundedRectangle(cornerRadius: 8, style: .continuous)) - } - Spacer() - - TapOnlyControl { + private var tableModel: CommentListTableModel { + CommentListTableModel( + state: viewModel.state, + sortMode: viewModel.sortMode, + runningActionIDs: viewModel.runningActionIDs, + comments: viewModel.sortedComments, + onChangeSortMode: { mode in + viewModel.changeSortMode(mode) + }, + onRefresh: { viewModel.load() - } label: { - Image(systemName: "arrow.clockwise") - .font(.subheadline.weight(.semibold)) - .foregroundStyle(Color.accentColor) - .padding(.horizontal, 12) - .padding(.vertical, 7) - .background(Color.accentColor.opacity(0.12), in: RoundedRectangle(cornerRadius: 8, style: .continuous)) - } - } - } - - @ViewBuilder - private var content: some View { - switch viewModel.state { - case .idle, .loading: - HStack { - Spacer() - ProgressView() - Spacer() - } - .padding(.vertical, 60) - case .failed(let message): - VStack(spacing: 12) { - Image(systemName: "text.bubble") - .font(.largeTitle) - .foregroundStyle(.secondary) - Text("评论加载失败") - .font(.headline) - Text(message) - .font(.subheadline) - .foregroundStyle(.secondary) - .multilineTextAlignment(.center) - TapOnlyControl { - viewModel.load() - } label: { - Text("重试") - .font(.subheadline.weight(.semibold)) - .foregroundStyle(.white) - .padding(.horizontal, 14) - .padding(.vertical, 8) - .background(Color.accentColor, in: RoundedRectangle(cornerRadius: 8, style: .continuous)) - } - CloudflareVerifyButton(errorMessage: message) - } - .frame(maxWidth: .infinity) - .padding(.vertical, 60) - case .loaded: - let comments = viewModel.sortedComments - if comments.isEmpty { - VStack(spacing: 12) { - Image(systemName: "bubble.left.and.bubble.right") - .font(.largeTitle) - .foregroundStyle(.secondary) - Text("暂无评论") - .font(.headline) - Text("成为第一个评论的人。") - .font(.subheadline) - .foregroundStyle(.secondary) - } - .frame(maxWidth: .infinity) - .padding(.vertical, 60) - } else { - CommentListTableView( - comments: comments, - runningActionIDs: viewModel.runningActionIDs, - onReply: { comment in - replyText = "@\(comment.username) " - replyTarget = comment - }, - onShowReplies: { comment in - repliesTarget = comment - }, - onLike: { comment in - viewModel.like(comment: comment, isPositive: true) - }, - onDislike: { comment in - viewModel.like(comment: comment, isPositive: false) - }, - onReport: { comment in - reportTarget = comment - } - ) - .id(viewModel.sortMode.id) + }, + onRetry: { + viewModel.load() + }, + onReply: { comment in + replyText = "@\(comment.username) " + replyTarget = comment + }, + onShowReplies: { comment in + repliesTarget = comment + }, + onLike: { comment in + viewModel.like(comment: comment, isPositive: true) + }, + onDislike: { comment in + viewModel.like(comment: comment, isPositive: false) + }, + onReport: { comment in + reportTarget = comment } - } + ) } private var actionMessageBinding: Binding { From 3e0c9b1065b98a21cf9bf3eb8683059c5b3b9876 Mon Sep 17 00:00:00 2001 From: pgs666 <74764545+pgs666@users.noreply.github.com> Date: Sun, 7 Jun 2026 14:03:11 +0800 Subject: [PATCH 139/216] Use native comment table in detail pager --- iosApp/CommentListTableView.swift | 6 + iosApp/CommentView.swift | 133 +++++++++++++---------- iosApp/VideoDetailPager.swift | 175 ++++++++++++++++++++++++------ iosApp/VideoDetailView.swift | 103 +++++++++++------- 4 files changed, 287 insertions(+), 130 deletions(-) diff --git a/iosApp/CommentListTableView.swift b/iosApp/CommentListTableView.swift index 80b776da..b272f6a0 100644 --- a/iosApp/CommentListTableView.swift +++ b/iosApp/CommentListTableView.swift @@ -130,6 +130,12 @@ final class CommentListTableController: NSObject, UITableViewDataSource, UITable tableView.delegate = self } + func makeTableView() -> UITableView { + let tableView = UITableView(frame: .zero, style: .plain) + attach(tableView) + return tableView + } + func update(_ model: CommentListTableModel) { sortMode = model.sortMode comments = model.comments diff --git a/iosApp/CommentView.swift b/iosApp/CommentView.swift index 664da38c..a347f302 100644 --- a/iosApp/CommentView.swift +++ b/iosApp/CommentView.swift @@ -4,6 +4,29 @@ import Han1meShared struct CommentView: View { @ObservedObject private var viewModel: CommentViewModel private let onOverlayActivityChanged: (Bool) -> Void + + init( + viewModel: CommentViewModel, + onOverlayActivityChanged: @escaping (Bool) -> Void = { _ in } + ) { + _viewModel = ObservedObject(wrappedValue: viewModel) + self.onOverlayActivityChanged = onOverlayActivityChanged + } + + var body: some View { + CommentOverlayHost( + viewModel: viewModel, + onOverlayActivityChanged: onOverlayActivityChanged + ) { tableModel in + CommentListTableView(model: tableModel) + } + } +} + +struct CommentOverlayHost: View { + @ObservedObject private var viewModel: CommentViewModel + private let onOverlayActivityChanged: (Bool) -> Void + private let content: (CommentListTableModel) -> Content @State private var replyTarget: CommentRow? @State private var replyText = "" @State private var reportTarget: CommentRow? @@ -11,76 +34,76 @@ struct CommentView: View { init( viewModel: CommentViewModel, - onOverlayActivityChanged: @escaping (Bool) -> Void = { _ in } + onOverlayActivityChanged: @escaping (Bool) -> Void = { _ in }, + @ViewBuilder content: @escaping (CommentListTableModel) -> Content ) { _viewModel = ObservedObject(wrappedValue: viewModel) self.onOverlayActivityChanged = onOverlayActivityChanged + self.content = content } var body: some View { - CommentListTableView( - model: tableModel - ) - .task { - viewModel.loadIfNeeded() - } - .onValueChange(of: replyTarget?.id) { _ in - notifyOverlayActivityChanged() - } - .onValueChange(of: repliesTarget?.id) { _ in - notifyOverlayActivityChanged() - } - .onDisappear { - onOverlayActivityChanged(false) - } - .alert("提示", isPresented: actionMessageBinding) { - Button("好", role: .cancel) { - viewModel.actionMessage = nil + content(tableModel) + .task { + viewModel.loadIfNeeded() } - } message: { - Text(viewModel.actionMessage ?? "") - } - .sheet(item: $replyTarget) { comment in - CommentTextSheet( - title: "回复 \(comment.username)", - text: $replyText, - placeholder: "输入回复", - submitTitle: "回复", - onCancel: { - replyTarget = nil - replyText = "" - }, - onSubmit: { - viewModel.postReply(to: comment, text: replyText) - replyTarget = nil - replyText = "" + .onValueChange(of: replyTarget?.id) { _ in + notifyOverlayActivityChanged() + } + .onValueChange(of: repliesTarget?.id) { _ in + notifyOverlayActivityChanged() + } + .onDisappear { + onOverlayActivityChanged(false) + } + .alert("提示", isPresented: actionMessageBinding) { + Button("好", role: .cancel) { + viewModel.actionMessage = nil } - ) - } - .sheet(item: $repliesTarget) { comment in - CommentRepliesSheet( - comment: comment, - viewModel: viewModel - ) { reply in - beginReplyFromRepliesSheet(reply) + } message: { + Text(viewModel.actionMessage ?? "") } - } - .confirmationDialog("举报原因", isPresented: reportDialogBinding, titleVisibility: .visible) { - ForEach(viewModel.reportReasons) { reason in - Button(reason.title) { - if let reportTarget { - viewModel.report(comment: reportTarget, reason: reason) + .sheet(item: $replyTarget) { comment in + CommentTextSheet( + title: "回复 \(comment.username)", + text: $replyText, + placeholder: "输入回复", + submitTitle: "回复", + onCancel: { + replyTarget = nil + replyText = "" + }, + onSubmit: { + viewModel.postReply(to: comment, text: replyText) + replyTarget = nil + replyText = "" } - reportTarget = nil + ) + } + .sheet(item: $repliesTarget) { comment in + CommentRepliesSheet( + comment: comment, + viewModel: viewModel + ) { reply in + beginReplyFromRepliesSheet(reply) } } - Button("取消", role: .cancel) { - reportTarget = nil + .confirmationDialog("举报原因", isPresented: reportDialogBinding, titleVisibility: .visible) { + ForEach(viewModel.reportReasons) { reason in + Button(reason.title) { + if let reportTarget { + viewModel.report(comment: reportTarget, reason: reason) + } + reportTarget = nil + } + } + Button("取消", role: .cancel) { + reportTarget = nil + } } - } } - private var tableModel: CommentListTableModel { + var tableModel: CommentListTableModel { CommentListTableModel( state: viewModel.state, sortMode: viewModel.sortMode, diff --git a/iosApp/VideoDetailPager.swift b/iosApp/VideoDetailPager.swift index 39697aa3..b0c5c9ad 100644 --- a/iosApp/VideoDetailPager.swift +++ b/iosApp/VideoDetailPager.swift @@ -346,6 +346,7 @@ private enum VideoDetailPendingTopAlignment { private enum VideoDetailTabPageContent { case swiftUI(() -> AnyView) + case nativeScrollView(listScrollView: UIScrollView, update: () -> Void) } private struct VideoDetailListAlignmentState { @@ -499,6 +500,36 @@ private struct VideoDetailTabPage { self.onTopPullDelta = onTopPullDelta self.content = .swiftUI({ AnyView(content()) }) } + + init( + tab: VideoPageTab, + contentBottomPadding: CGFloat, + isSelected: Bool, + headerGeometry: VideoDetailSmoothHeaderGeometry, + contentUpdateRevision: Int, + onOffsetChange: @escaping (VideoPageTab, CGFloat) -> Void, + onInteractionBegan: @escaping (VideoPageTab) -> Void, + onTopPullDelta: @escaping (VideoPageTab, CGFloat) -> Void, + listScrollView: UIScrollView, + nativeUpdate: @escaping () -> Void + ) { + self.tab = tab + self.contentBottomPadding = contentBottomPadding + self.isSelected = isSelected + self.headerGeometry = headerGeometry + self.contentUpdateRevision = contentUpdateRevision + self.onOffsetChange = onOffsetChange + self.onInteractionBegan = onInteractionBegan + self.onTopPullDelta = onTopPullDelta + self.content = .nativeScrollView(listScrollView: listScrollView, update: nativeUpdate) + } + + var nativeListScrollView: UIScrollView? { + if case .nativeScrollView(let listScrollView, _) = content { + return listScrollView + } + return nil + } } struct VideoDetailPagerContainer: View { @@ -518,6 +549,8 @@ struct VideoDetailPagerContainer Introduction let comments: () -> Comments + let nativeCommentsListScrollView: UIScrollView? + let nativeCommentsUpdate: (() -> Void)? private var selectedTabBinding: Binding { Binding( @@ -542,12 +575,7 @@ struct VideoDetailPagerContainer Void) { withTransaction(Transaction(animation: nil)) { var nextState = state @@ -831,11 +901,18 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle if !page.isSelected, !hasExplicitPendingTopAlignment { cancelPendingTopAlignment() } + if case .nativeScrollView(_, let update) = page.content { + update() + } if contentUpdateRevision != page.contentUpdateRevision { contentUpdateRevision = page.contentUpdateRevision switch page.content { case .swiftUI(let content): + host.view.isHidden = false host.rootView = content() + case .nativeScrollView: + host.view.isHidden = true + host.rootView = AnyView(EmptyView()) } alignmentState.resetForContentUpdate() if !alignmentState.hasAppliedInitialListOffset && !alignmentState.hasExplicitPendingTopAlignment { @@ -852,9 +929,18 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle coordinator.visualTopContentOffsetY = visualTopOffset applyTopContentInset(offsetContext.contentTopInset) applyListHeaderFrame() - applyBottomContentSpacing(page.contentBottomPadding) - collapseSpacerHeightConstraint.constant = offsetContext.collapseSpacerHeight - contentMinimumHeightConstraint.constant = offsetContext.minimumContentHeight + switch page.content { + case .swiftUI: + applyBottomContentSpacing(page.contentBottomPadding, usesContentSpacer: true) + collapseSpacerHeightConstraint.constant = offsetContext.collapseSpacerHeight + contentMinimumHeightConstraint.constant = offsetContext.minimumContentHeight + hostMinimumHeightConstraint.isActive = true + case .nativeScrollView: + applyBottomContentSpacing(page.contentBottomPadding, usesContentSpacer: false) + collapseSpacerHeightConstraint.constant = 0 + contentMinimumHeightConstraint.constant = 1 + hostMinimumHeightConstraint.isActive = false + } if alignmentState.needsInitialHeaderOffsetReset { applyInitialHeaderOffsetResetIfNeeded() } else if alignmentState.pendingTopAlignment != nil { @@ -901,13 +987,15 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle applyListHeaderFrame() } - private func applyBottomContentSpacing(_ bottomSpacing: CGFloat) { + private func applyBottomContentSpacing(_ bottomSpacing: CGFloat, usesContentSpacer: Bool) { let resolvedBottomSpacing = max(bottomSpacing, 0) - if abs(contentBottomSpacerHeightConstraint.constant - resolvedBottomSpacing) > 0.5 { - contentBottomSpacerHeightConstraint.constant = resolvedBottomSpacing + let contentSpacerHeight = usesContentSpacer ? resolvedBottomSpacing : 0 + if abs(contentBottomSpacerHeightConstraint.constant - contentSpacerHeight) > 0.5 { + contentBottomSpacerHeightConstraint.constant = contentSpacerHeight } - if abs(listScrollView.contentInset.bottom) > 0.5 { - listScrollView.contentInset.bottom = 0 + let bottomInset = usesContentSpacer ? 0 : resolvedBottomSpacing + if abs(listScrollView.contentInset.bottom - bottomInset) > 0.5 { + listScrollView.contentInset.bottom = bottomInset } if abs(listScrollView.verticalScrollIndicatorInsets.bottom - resolvedBottomSpacing) > 0.5 { listScrollView.verticalScrollIndicatorInsets.bottom = resolvedBottomSpacing @@ -1344,7 +1432,8 @@ private struct VideoDetailTabPager: UIViewControllerRepresentable { private let continuationHeaderHost = UIHostingController(rootView: AnyView(EmptyView())) private let pinHeaderHost = UIHostingController(rootView: AnyView(EmptyView())) private let introductionPage = VideoDetailVerticalScrollPageViewController(tab: .introduction) - private let commentsPage = VideoDetailVerticalScrollPageViewController(tab: .comments) + private var commentsPage: VideoDetailVerticalScrollPageViewController? + private weak var commentsListScrollView: UIScrollView? private var pagerPosition = VideoDetailHorizontalPagerPosition() private var pendingSelectedIndex: Int? private var lastLaidOutWidth: CGFloat = 0 @@ -1401,6 +1490,7 @@ private struct VideoDetailTabPager: UIViewControllerRepresentable { contentView.translatesAutoresizingMaskIntoConstraints = false contentView.backgroundColor = .clear scrollView.addSubview(contentView) + addPage(introductionPage) headerContainerView.backgroundColor = .clear headerContainerView.pinHeaderHeight = 48 @@ -1413,15 +1503,10 @@ private struct VideoDetailTabPager: UIViewControllerRepresentable { headerContainerView.addSubview(pinHeaderHost.view) pinHeaderHost.didMove(toParent: self) - addPage(introductionPage) - addPage(commentsPage) updateScrollsToTop(for: VideoPageTab.page(at: pagerPosition.selectedIndex)) introductionPage.onHeaderOffsetChanged = { [weak self] tab, offset in self?.updateHeaderContainerPosition(for: tab, offsetY: offset) } - commentsPage.onHeaderOffsetChanged = { [weak self] tab, offset in - self?.updateHeaderContainerPosition(for: tab, offsetY: offset) - } NSLayoutConstraint.activate([ scrollView.leadingAnchor.constraint(equalTo: view.leadingAnchor), @@ -1439,14 +1524,7 @@ private struct VideoDetailTabPager: UIViewControllerRepresentable { introductionPage.view.topAnchor.constraint(equalTo: contentView.topAnchor), introductionPage.view.bottomAnchor.constraint(equalTo: contentView.bottomAnchor), introductionPage.view.widthAnchor.constraint(equalTo: scrollView.frameLayoutGuide.widthAnchor), - introductionPage.view.heightAnchor.constraint(equalTo: scrollView.frameLayoutGuide.heightAnchor), - - commentsPage.view.leadingAnchor.constraint(equalTo: introductionPage.view.trailingAnchor), - commentsPage.view.trailingAnchor.constraint(equalTo: contentView.trailingAnchor), - commentsPage.view.topAnchor.constraint(equalTo: contentView.topAnchor), - commentsPage.view.bottomAnchor.constraint(equalTo: contentView.bottomAnchor), - commentsPage.view.widthAnchor.constraint(equalTo: scrollView.frameLayoutGuide.widthAnchor), - commentsPage.view.heightAnchor.constraint(equalTo: scrollView.frameLayoutGuide.heightAnchor) + introductionPage.view.heightAnchor.constraint(equalTo: scrollView.frameLayoutGuide.heightAnchor) ]) } @@ -1484,6 +1562,7 @@ private struct VideoDetailTabPager: UIViewControllerRepresentable { } latestPages[.introduction] = introduction latestPages[.comments] = comments + prepareCommentsPageIfNeeded(for: comments) updateHeaderHosts( headerContentRevision: headerContentRevision, continuationHeader: continuationHeader, @@ -1493,7 +1572,7 @@ private struct VideoDetailTabPager: UIViewControllerRepresentable { page: VideoPageTab.page(at: pagerPosition.selectedIndex) ) introductionPage.update(page: introduction) - commentsPage.update(page: comments) + commentsPage?.update(page: comments) updateScrollsToTop(for: VideoPageTab.page(at: pagerPosition.selectedIndex)) updateHeaderAttachmentForCurrentState() if !pagerPosition.isPagingActive { @@ -1558,9 +1637,9 @@ private struct VideoDetailTabPager: UIViewControllerRepresentable { introductionPage.reportCurrentOffset() case .comments: if didChangeSettledIndex { - commentsPage.settleAfterHorizontalActivation() + commentsPage?.settleAfterHorizontalActivation() } - commentsPage.reportCurrentOffset() + commentsPage?.reportCurrentOffset() } updateHeaderAttachmentForCurrentState() syncInactivePageHeaderOffset() @@ -1568,7 +1647,7 @@ private struct VideoDetailTabPager: UIViewControllerRepresentable { private func updateScrollsToTop(for activeTab: VideoPageTab) { introductionPage.setScrollsToTop(activeTab == .introduction) - commentsPage.setScrollsToTop(activeTab == .comments) + commentsPage?.setScrollsToTop(activeTab == .comments) } private func setHorizontalPagingActive(_ isActive: Bool) { @@ -1577,7 +1656,7 @@ private struct VideoDetailTabPager: UIViewControllerRepresentable { settledIndex: isActive ? nil : settledIndexFromHorizontalOffset() ) else { return } introductionPage.setHorizontalPagingActive(isActive) - commentsPage.setHorizontalPagingActive(isActive) + commentsPage?.setHorizontalPagingActive(isActive) updateHeaderAttachmentForCurrentState() if !isActive { syncInactivePageHeaderOffset() @@ -1647,6 +1726,36 @@ private struct VideoDetailTabPager: UIViewControllerRepresentable { } } + private func prepareCommentsPageIfNeeded(for comments: VideoDetailTabPage) { + let nativeScrollView = comments.nativeListScrollView + if let commentsPage, commentsListScrollView === nativeScrollView { + return + } + if let commentsPage { + commentsPage.willMove(toParent: nil) + commentsPage.view.removeFromSuperview() + commentsPage.removeFromParent() + } + let nextCommentsPage = VideoDetailVerticalScrollPageViewController( + tab: .comments, + listScrollView: nativeScrollView + ) + nextCommentsPage.onHeaderOffsetChanged = { [weak self] tab, offset in + self?.updateHeaderContainerPosition(for: tab, offsetY: offset) + } + addPage(nextCommentsPage) + NSLayoutConstraint.activate([ + nextCommentsPage.view.leadingAnchor.constraint(equalTo: introductionPage.view.trailingAnchor), + nextCommentsPage.view.trailingAnchor.constraint(equalTo: contentView.trailingAnchor), + nextCommentsPage.view.topAnchor.constraint(equalTo: contentView.topAnchor), + nextCommentsPage.view.bottomAnchor.constraint(equalTo: contentView.bottomAnchor), + nextCommentsPage.view.widthAnchor.constraint(equalTo: scrollView.frameLayoutGuide.widthAnchor), + nextCommentsPage.view.heightAnchor.constraint(equalTo: scrollView.frameLayoutGuide.heightAnchor) + ]) + commentsPage = nextCommentsPage + commentsListScrollView = nativeScrollView + } + private func updateHeaderHosts( headerContentRevision: Int, continuationHeader: AnyView, diff --git a/iosApp/VideoDetailView.swift b/iosApp/VideoDetailView.swift index deed9b9f..4267e99a 100644 --- a/iosApp/VideoDetailView.swift +++ b/iosApp/VideoDetailView.swift @@ -2,6 +2,19 @@ import SwiftUI import UIKit import Han1meShared +private final class NativeCommentListHolder: ObservableObject { + let controller = CommentListTableController() + let tableView: UITableView + + init() { + tableView = controller.makeTableView() + } + + func update(_ model: CommentListTableModel) { + controller.update(model) + } +} + struct VideoDetailView: View { let videoCode: String private let videoFeature: VideoFeature @@ -13,6 +26,7 @@ struct VideoDetailView: View { private let commentComposerBottomSlack: CGFloat = 24 @StateObject private var viewModel: VideoDetailViewModel @StateObject private var commentViewModel: CommentViewModel + @StateObject private var nativeCommentList = NativeCommentListHolder() @State private var pagerState = VideoDetailPagerState() @State private var isPlayerFullscreen = false @State private var playerPlayRequestToken = 0 @@ -408,49 +422,54 @@ struct VideoDetailView: View { let contentRevision = tabContentRevision(snapshot: snapshot, showsRelated: showsRelated) let headerContentRevision = pagerHeaderContentRevision(snapshot: snapshot) - return VideoDetailPagerContainer( - state: $pagerState, - collapseDistance: collapseDistance, - headerHeight: headerHeight, - pinHeaderHeight: pinHeaderHeight, - pinnedVisibleHeight: pinnedVisibleHeight, - playerScrollAway: playerScrollAway, - continuationProgress: continuationProgress, - introductionContentBottomPadding: introductionContentClearance, - commentsContentBottomPadding: composerContentClearance, - introductionContentRevision: contentRevision.introduction, - commentsContentRevision: contentRevision.comments, - headerContentRevision: headerContentRevision, - continuationHeader: { - continuePlayingStrip(snapshot: snapshot, progress: 1) - .frame(height: playerContinuationStripHeight) - }, - isContinuationHeaderInteractive: !isPlayerFullscreen && continuationProgress > 0.05, - introduction: { - AndroidStyleIntroduction( - snapshot: snapshot, - videoFeature: videoFeature, - commentFeature: commentFeature, - isArtistActionRunning: viewModel.isActionRunning("artistSubscription"), - onToggleArtistSubscription: { viewModel.toggleArtistSubscription(snapshot: snapshot) }, - onToggleFavorite: { viewModel.toggleFavorite(snapshot: snapshot) }, - onToggleWatchLater: { viewModel.toggleWatchLater(snapshot: snapshot) }, - onSetMyListItem: { item, isSelected in viewModel.setMyListItem(snapshot: snapshot, item: item, isSelected: isSelected) }, - onShowMessage: { viewModel.showActionMessage($0) }, - showsRelated: showsRelated - ) - .padding(.top, 16) - }, - comments: { - CommentView( - viewModel: commentViewModel, - onOverlayActivityChanged: { isActive in - isCommentInternalOverlayActive = isActive - } - ) - .padding(.top, 16) + return CommentOverlayHost( + viewModel: commentViewModel, + onOverlayActivityChanged: { isActive in + isCommentInternalOverlayActive = isActive } - ) + ) { commentTableModel in + VideoDetailPagerContainer( + state: $pagerState, + collapseDistance: collapseDistance, + headerHeight: headerHeight, + pinHeaderHeight: pinHeaderHeight, + pinnedVisibleHeight: pinnedVisibleHeight, + playerScrollAway: playerScrollAway, + continuationProgress: continuationProgress, + introductionContentBottomPadding: introductionContentClearance, + commentsContentBottomPadding: composerContentClearance, + introductionContentRevision: contentRevision.introduction, + commentsContentRevision: contentRevision.comments, + headerContentRevision: headerContentRevision, + continuationHeader: { + continuePlayingStrip(snapshot: snapshot, progress: 1) + .frame(height: playerContinuationStripHeight) + }, + isContinuationHeaderInteractive: !isPlayerFullscreen && continuationProgress > 0.05, + introduction: { + AndroidStyleIntroduction( + snapshot: snapshot, + videoFeature: videoFeature, + commentFeature: commentFeature, + isArtistActionRunning: viewModel.isActionRunning("artistSubscription"), + onToggleArtistSubscription: { viewModel.toggleArtistSubscription(snapshot: snapshot) }, + onToggleFavorite: { viewModel.toggleFavorite(snapshot: snapshot) }, + onToggleWatchLater: { viewModel.toggleWatchLater(snapshot: snapshot) }, + onSetMyListItem: { item, isSelected in viewModel.setMyListItem(snapshot: snapshot, item: item, isSelected: isSelected) }, + onShowMessage: { viewModel.showActionMessage($0) }, + showsRelated: showsRelated + ) + .padding(.top, 16) + }, + comments: { + EmptyView() + }, + nativeCommentsListScrollView: nativeCommentList.tableView, + nativeCommentsUpdate: { + nativeCommentList.update(commentTableModel) + } + ) + } } private func submitComment() { From 807da0c35a9f7c8a489d00e85c67c6e573df76c3 Mon Sep 17 00:00:00 2001 From: pgs666 <74764545+pgs666@users.noreply.github.com> Date: Sun, 7 Jun 2026 14:15:18 +0800 Subject: [PATCH 140/216] Stabilize native comment table updates --- iosApp/CommentListTableView.swift | 63 +++++++++++++++++++++++++++++++ iosApp/VideoDetailPager.swift | 45 +++++++++++++++++++++- 2 files changed, 107 insertions(+), 1 deletion(-) diff --git a/iosApp/CommentListTableView.swift b/iosApp/CommentListTableView.swift index b272f6a0..e3f4690f 100644 --- a/iosApp/CommentListTableView.swift +++ b/iosApp/CommentListTableView.swift @@ -100,6 +100,64 @@ struct CommentListTableView: UIViewRepresentable { } final class CommentListTableController: NSObject, UITableViewDataSource, UITableViewDelegate { + private enum StateSignature: Equatable { + case idle + case loading + case loaded + case failed(String) + + init(_ state: CommentViewModel.State) { + switch state { + case .idle: + self = .idle + case .loading: + self = .loading + case .loaded: + self = .loaded + case .failed(let message): + self = .failed(message) + } + } + } + + private struct ModelSignature: Equatable { + let state: StateSignature + let sortMode: CommentViewModel.SortMode + let runningActionIDs: Set + let comments: [CommentSignature] + + init(_ model: CommentListTableModel) { + state = StateSignature(model.state) + sortMode = model.sortMode + runningActionIDs = model.runningActionIDs + comments = model.comments.map(CommentSignature.init) + } + } + + private struct CommentSignature: Equatable { + let id: String + let username: String + let date: String + let content: String + let thumbUp: Int? + let hasMoreReplies: Bool + let replyCount: Int? + let likeCommentStatus: Bool + let unlikeCommentStatus: Bool + + init(_ comment: CommentRow) { + id = comment.id + username = comment.username + date = comment.date + content = comment.content + thumbUp = comment.thumbUp + hasMoreReplies = comment.hasMoreReplies + replyCount = comment.replyCount + likeCommentStatus = comment.likeCommentStatus + unlikeCommentStatus = comment.unlikeCommentStatus + } + } + private enum Row { case controls case loading @@ -122,6 +180,7 @@ final class CommentListTableController: NSObject, UITableViewDataSource, UITable private var onLike: (CommentRow) -> Void = { _ in } private var onDislike: (CommentRow) -> Void = { _ in } private var onReport: (CommentRow) -> Void = { _ in } + private var modelSignature: ModelSignature? func attach(_ tableView: UITableView) { self.tableView = tableView @@ -137,6 +196,9 @@ final class CommentListTableController: NSObject, UITableViewDataSource, UITable } func update(_ model: CommentListTableModel) { + let nextSignature = ModelSignature(model) + let shouldReload = modelSignature != nextSignature + modelSignature = nextSignature sortMode = model.sortMode comments = model.comments runningActionIDs = model.runningActionIDs @@ -148,6 +210,7 @@ final class CommentListTableController: NSObject, UITableViewDataSource, UITable onLike = model.onLike onDislike = model.onDislike onReport = model.onReport + guard shouldReload else { return } rows = rows(for: model) tableView?.reloadData() } diff --git a/iosApp/VideoDetailPager.swift b/iosApp/VideoDetailPager.swift index b0c5c9ad..a08754b9 100644 --- a/iosApp/VideoDetailPager.swift +++ b/iosApp/VideoDetailPager.swift @@ -757,6 +757,7 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle private let listHeaderView = UIView() private let collapseSpacerView = UIView() private let contentBottomSpacerView = UIView() + private let nativeMinimumContentFooterView = UIView(frame: .zero) private let host = UIHostingController(rootView: AnyView(EmptyView())) private var hostMinimumHeightConstraint: NSLayoutConstraint! private var contentMinimumHeightConstraint: NSLayoutConstraint! @@ -957,9 +958,16 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle guard let page = lastAppliedPage else { return } let geometry = page.headerGeometry let offsetContext = geometry.listOffsetContext(in: listScrollView.bounds.height) + let isNativeScrollView: Bool + if case .nativeScrollView = page.content { + isNativeScrollView = true + } else { + isNativeScrollView = false + } if abs(contentMinimumHeightConstraint.constant - offsetContext.minimumContentHeight) > 0.5 { - contentMinimumHeightConstraint.constant = offsetContext.minimumContentHeight + contentMinimumHeightConstraint.constant = isNativeScrollView ? 1 : offsetContext.minimumContentHeight } + applyNativeMinimumContentFooterIfNeeded(page: page, offsetContext: offsetContext) if alignmentState.shouldReopenFirstActiveAlignment( isSelected: page.isSelected, contentHeight: listScrollView.contentSize.height @@ -1002,6 +1010,41 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle } } + private func applyNativeMinimumContentFooterIfNeeded( + page: VideoDetailTabPage, + offsetContext: VideoDetailListOffsetContext + ) { + guard case .nativeScrollView = page.content, + let tableView = listScrollView as? UITableView else { + if let tableView = listScrollView as? UITableView, + tableView.tableFooterView === nativeMinimumContentFooterView { + tableView.tableFooterView = nil + } + return + } + tableView.layoutIfNeeded() + let requiredContentHeight = max( + offsetContext.minimumContentHeight, + listScrollView.bounds.height - listScrollView.adjustedContentInset.top - listScrollView.adjustedContentInset.bottom + ) + let currentFooterHeight = tableView.tableFooterView === nativeMinimumContentFooterView + ? nativeMinimumContentFooterView.bounds.height + : 0 + let currentContentHeightWithoutFooter = max(tableView.contentSize.height - currentFooterHeight, 0) + let footerHeight = max(requiredContentHeight - currentContentHeightWithoutFooter, 0) + guard abs(nativeMinimumContentFooterView.frame.height - footerHeight) > 0.5 + || tableView.tableFooterView !== nativeMinimumContentFooterView else { + return + } + nativeMinimumContentFooterView.frame = CGRect( + x: 0, + y: 0, + width: tableView.bounds.width, + height: footerHeight + ) + tableView.tableFooterView = footerHeight > 0 ? nativeMinimumContentFooterView : nil + } + private func applyListHeaderFrame() { let topInset = max(listScrollView.contentInset.top, 0) let nextFrame = CGRect( From f4f6d56e33653473249151429feafc370a558e4b Mon Sep 17 00:00:00 2001 From: pgs666 <74764545+pgs666@users.noreply.github.com> Date: Sun, 7 Jun 2026 14:30:57 +0800 Subject: [PATCH 141/216] Separate native list minimum height --- iosApp/VideoDetailPager.swift | 23 ++++++++++++++++++----- 1 file changed, 18 insertions(+), 5 deletions(-) diff --git a/iosApp/VideoDetailPager.swift b/iosApp/VideoDetailPager.swift index a08754b9..c4af3624 100644 --- a/iosApp/VideoDetailPager.swift +++ b/iosApp/VideoDetailPager.swift @@ -54,6 +54,13 @@ enum VideoDetailPagerOffsetModel { ) } + static func minimumListContentHeight( + scrollBoundsHeight: CGFloat, + pinHeaderHeight: CGFloat + ) -> CGFloat { + max(scrollBoundsHeight - max(pinHeaderHeight, 0), 1) + } + static func shouldAlignToVisualTopAfterHorizontalActivation( currentOffset: CGFloat, visualTopOffset: CGFloat @@ -298,7 +305,8 @@ private struct VideoDetailSmoothHeaderGeometry: Equatable { initialNormalizedOffsetY: visualTopOffset, inactiveSyncMode: inactiveSyncMode, collapseSpacerHeight: collapseSpacerHeight, - minimumContentHeight: minimumContentHeight(in: scrollBoundsHeight) + minimumContentHeight: minimumContentHeight(in: scrollBoundsHeight), + minimumListContentHeight: minimumListContentHeight(in: scrollBoundsHeight) ) } @@ -317,6 +325,13 @@ private struct VideoDetailSmoothHeaderGeometry: Equatable { ) } + func minimumListContentHeight(in scrollBoundsHeight: CGFloat) -> CGFloat { + VideoDetailPagerOffsetModel.minimumListContentHeight( + scrollBoundsHeight: scrollBoundsHeight, + pinHeaderHeight: pinHeaderHeight + ) + } + func rawContentOffsetY(forNormalizedOffsetY offsetY: CGFloat, in listScrollView: UIScrollView) -> CGFloat { let inset = listScrollView.adjustedContentInset let minOffsetY = -inset.top @@ -337,6 +352,7 @@ private struct VideoDetailListOffsetContext: Equatable { let inactiveSyncMode: VideoDetailPagerOffsetModel.InactiveSyncMode let collapseSpacerHeight: CGFloat let minimumContentHeight: CGFloat + let minimumListContentHeight: CGFloat } private enum VideoDetailPendingTopAlignment { @@ -1023,10 +1039,7 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle return } tableView.layoutIfNeeded() - let requiredContentHeight = max( - offsetContext.minimumContentHeight, - listScrollView.bounds.height - listScrollView.adjustedContentInset.top - listScrollView.adjustedContentInset.bottom - ) + let requiredContentHeight = offsetContext.minimumListContentHeight let currentFooterHeight = tableView.tableFooterView === nativeMinimumContentFooterView ? nativeMinimumContentFooterView.bounds.height : 0 From 439b3640068395dde67d7d89cee2097bb28dc4d2 Mon Sep 17 00:00:00 2001 From: pgs666 <74764545+pgs666@users.noreply.github.com> Date: Sun, 7 Jun 2026 14:39:33 +0800 Subject: [PATCH 142/216] Forward native comment table scroll delegate --- iosApp/CommentListTableView.swift | 21 ++++++++++++ iosApp/VideoDetailPager.swift | 54 ++++++++++++++++++++++++++----- iosApp/VideoDetailView.swift | 7 ++++ 3 files changed, 74 insertions(+), 8 deletions(-) diff --git a/iosApp/CommentListTableView.swift b/iosApp/CommentListTableView.swift index e3f4690f..14f8ef94 100644 --- a/iosApp/CommentListTableView.swift +++ b/iosApp/CommentListTableView.swift @@ -181,6 +181,7 @@ final class CommentListTableController: NSObject, UITableViewDataSource, UITable private var onDislike: (CommentRow) -> Void = { _ in } private var onReport: (CommentRow) -> Void = { _ in } private var modelSignature: ModelSignature? + weak var scrollDelegate: UIScrollViewDelegate? func attach(_ tableView: UITableView) { self.tableView = tableView @@ -312,6 +313,26 @@ final class CommentListTableController: NSObject, UITableViewDataSource, UITable } } + func scrollViewDidScroll(_ scrollView: UIScrollView) { + scrollDelegate?.scrollViewDidScroll?(scrollView) + } + + func scrollViewWillBeginDragging(_ scrollView: UIScrollView) { + scrollDelegate?.scrollViewWillBeginDragging?(scrollView) + } + + func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) { + scrollDelegate?.scrollViewDidEndDragging?(scrollView, willDecelerate: decelerate) + } + + func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) { + scrollDelegate?.scrollViewDidEndDecelerating?(scrollView) + } + + func scrollViewDidEndScrollingAnimation(_ scrollView: UIScrollView) { + scrollDelegate?.scrollViewDidEndScrollingAnimation?(scrollView) + } + private func configure(_ tableView: UITableView) { tableView.backgroundColor = .clear tableView.separatorStyle = .none diff --git a/iosApp/VideoDetailPager.swift b/iosApp/VideoDetailPager.swift index c4af3624..0b50309f 100644 --- a/iosApp/VideoDetailPager.swift +++ b/iosApp/VideoDetailPager.swift @@ -360,9 +360,15 @@ private enum VideoDetailPendingTopAlignment { case explicit(CGFloat) } +private struct VideoDetailNativeScrollPage { + let listScrollView: UIScrollView + let attachScrollDelegate: (UIScrollViewDelegate?) -> Void + let update: () -> Void +} + private enum VideoDetailTabPageContent { case swiftUI(() -> AnyView) - case nativeScrollView(listScrollView: UIScrollView, update: () -> Void) + case nativeScrollView(VideoDetailNativeScrollPage) } private struct VideoDetailListAlignmentState { @@ -527,6 +533,7 @@ private struct VideoDetailTabPage { onInteractionBegan: @escaping (VideoPageTab) -> Void, onTopPullDelta: @escaping (VideoPageTab, CGFloat) -> Void, listScrollView: UIScrollView, + attachScrollDelegate: @escaping (UIScrollViewDelegate?) -> Void, nativeUpdate: @escaping () -> Void ) { self.tab = tab @@ -537,12 +544,25 @@ private struct VideoDetailTabPage { self.onOffsetChange = onOffsetChange self.onInteractionBegan = onInteractionBegan self.onTopPullDelta = onTopPullDelta - self.content = .nativeScrollView(listScrollView: listScrollView, update: nativeUpdate) + self.content = .nativeScrollView( + VideoDetailNativeScrollPage( + listScrollView: listScrollView, + attachScrollDelegate: attachScrollDelegate, + update: nativeUpdate + ) + ) } var nativeListScrollView: UIScrollView? { - if case .nativeScrollView(let listScrollView, _) = content { - return listScrollView + if case .nativeScrollView(let nativePage) = content { + return nativePage.listScrollView + } + return nil + } + + var nativeScrollDelegateAttachment: ((UIScrollViewDelegate?) -> Void)? { + if case .nativeScrollView(let nativePage) = content { + return nativePage.attachScrollDelegate } return nil } @@ -566,6 +586,7 @@ struct VideoDetailPagerContainer Introduction let comments: () -> Comments let nativeCommentsListScrollView: UIScrollView? + let nativeCommentsAttachScrollDelegate: ((UIScrollViewDelegate?) -> Void)? let nativeCommentsUpdate: (() -> Void)? private var selectedTabBinding: Binding { @@ -658,7 +679,9 @@ struct VideoDetailPagerContainer Void)? var onHeaderOffsetChanged: (VideoPageTab, CGFloat) -> Void = { _, _ in } init(tab: VideoPageTab, listScrollView: UIScrollView? = nil) { @@ -806,6 +831,10 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle fatalError("init(coder:) has not been implemented") } + deinit { + nativeScrollDelegateAttachment?(nil) + } + override func viewDidLoad() { super.viewDidLoad() view.backgroundColor = .clear @@ -820,7 +849,9 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle listScrollView.isDirectionalLockEnabled = true listScrollView.contentInsetAdjustmentBehavior = .never listScrollView.keyboardDismissMode = .interactive - listScrollView.delegate = coordinator + if defaultScrollView != nil { + listScrollView.delegate = coordinator + } listScrollView.panGestureRecognizer.addTarget(coordinator, action: #selector(VideoDetailVerticalScrollPageCoordinator.handlePan(_:))) defaultScrollView?.onGeometryChange = { [weak self] in self?.handleScrollGeometryChange() @@ -918,8 +949,15 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle if !page.isSelected, !hasExplicitPendingTopAlignment { cancelPendingTopAlignment() } - if case .nativeScrollView(_, let update) = page.content { - update() + switch page.content { + case .nativeScrollView(let nativePage): + if nativeScrollDelegateAttachment == nil { + nativeScrollDelegateAttachment = nativePage.attachScrollDelegate + nativePage.attachScrollDelegate(coordinator) + } + nativePage.update() + case .swiftUI: + break } if contentUpdateRevision != page.contentUpdateRevision { contentUpdateRevision = page.contentUpdateRevision diff --git a/iosApp/VideoDetailView.swift b/iosApp/VideoDetailView.swift index 4267e99a..5386e7d0 100644 --- a/iosApp/VideoDetailView.swift +++ b/iosApp/VideoDetailView.swift @@ -13,6 +13,10 @@ private final class NativeCommentListHolder: ObservableObject { func update(_ model: CommentListTableModel) { controller.update(model) } + + func attachScrollDelegate(_ delegate: UIScrollViewDelegate?) { + controller.scrollDelegate = delegate + } } struct VideoDetailView: View { @@ -465,6 +469,9 @@ struct VideoDetailView: View { EmptyView() }, nativeCommentsListScrollView: nativeCommentList.tableView, + nativeCommentsAttachScrollDelegate: { delegate in + nativeCommentList.attachScrollDelegate(delegate) + }, nativeCommentsUpdate: { nativeCommentList.update(commentTableModel) } From 0f4d4b025d4e846dd883c3d3e7f01390e92b1930 Mon Sep 17 00:00:00 2001 From: pgs666 <74764545+pgs666@users.noreply.github.com> Date: Sun, 7 Jun 2026 14:47:17 +0800 Subject: [PATCH 143/216] Remove embedded comment table fallback --- iosApp/CommentListTableView.swift | 117 ------------------------------ iosApp/CommentView.swift | 22 ------ 2 files changed, 139 deletions(-) diff --git a/iosApp/CommentListTableView.swift b/iosApp/CommentListTableView.swift index 14f8ef94..54eb9acf 100644 --- a/iosApp/CommentListTableView.swift +++ b/iosApp/CommentListTableView.swift @@ -16,89 +16,6 @@ struct CommentListTableModel { let onReport: (CommentRow) -> Void } -struct CommentListTableView: UIViewRepresentable { - private let mode: Mode - - private enum Mode { - case full(CommentListTableModel) - case rows( - comments: [CommentRow], - runningActionIDs: Set, - onReply: (CommentRow) -> Void, - onShowReplies: (CommentRow) -> Void, - onLike: (CommentRow) -> Void, - onDislike: (CommentRow) -> Void, - onReport: (CommentRow) -> Void - ) - } - - init(model: CommentListTableModel) { - mode = .full(model) - } - - init( - comments: [CommentRow], - runningActionIDs: Set, - onReply: @escaping (CommentRow) -> Void, - onShowReplies: @escaping (CommentRow) -> Void, - onLike: @escaping (CommentRow) -> Void, - onDislike: @escaping (CommentRow) -> Void, - onReport: @escaping (CommentRow) -> Void - ) { - mode = .rows( - comments: comments, - runningActionIDs: runningActionIDs, - onReply: onReply, - onShowReplies: onShowReplies, - onLike: onLike, - onDislike: onDislike, - onReport: onReport - ) - } - - func makeCoordinator() -> Coordinator { - Coordinator() - } - - func makeUIView(context: Context) -> IntrinsicCommentTableView { - let tableView = IntrinsicCommentTableView(frame: .zero, style: .plain) - tableView.isScrollEnabled = false - tableView.alwaysBounceVertical = false - context.coordinator.controller.attach(tableView) - return tableView - } - - func updateUIView(_ tableView: IntrinsicCommentTableView, context: Context) { - switch mode { - case .full(let model): - context.coordinator.controller.update(model) - case .rows( - let comments, - let runningActionIDs, - let onReply, - let onShowReplies, - let onLike, - let onDislike, - let onReport - ): - context.coordinator.controller.updateRows( - comments: comments, - runningActionIDs: runningActionIDs, - onReply: onReply, - onShowReplies: onShowReplies, - onLike: onLike, - onDislike: onDislike, - onReport: onReport - ) - } - tableView.invalidateIntrinsicContentSize() - } - - final class Coordinator { - let controller = CommentListTableController() - } -} - final class CommentListTableController: NSObject, UITableViewDataSource, UITableViewDelegate { private enum StateSignature: Equatable { case idle @@ -216,26 +133,6 @@ final class CommentListTableController: NSObject, UITableViewDataSource, UITable tableView?.reloadData() } - func updateRows( - comments: [CommentRow], - runningActionIDs: Set, - onReply: @escaping (CommentRow) -> Void, - onShowReplies: @escaping (CommentRow) -> Void, - onLike: @escaping (CommentRow) -> Void, - onDislike: @escaping (CommentRow) -> Void, - onReport: @escaping (CommentRow) -> Void - ) { - self.comments = comments - self.runningActionIDs = runningActionIDs - self.onReply = onReply - self.onShowReplies = onShowReplies - self.onLike = onLike - self.onDislike = onDislike - self.onReport = onReport - rows = comments.map { .comment($0) } + [.footer] - tableView?.reloadData() - } - func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { rows.count } @@ -363,20 +260,6 @@ final class CommentListTableController: NSObject, UITableViewDataSource, UITable } } -final class IntrinsicCommentTableView: UITableView { - override var contentSize: CGSize { - didSet { - guard abs(contentSize.height - oldValue.height) > 0.5 else { return } - invalidateIntrinsicContentSize() - } - } - - override var intrinsicContentSize: CGSize { - layoutIfNeeded() - return CGSize(width: UIView.noIntrinsicMetric, height: contentSize.height) - } -} - private final class HostingCommentTableViewCell: UITableViewCell { static let reuseIdentifier = "HostingCommentTableViewCell" diff --git a/iosApp/CommentView.swift b/iosApp/CommentView.swift index a347f302..b450a99e 100644 --- a/iosApp/CommentView.swift +++ b/iosApp/CommentView.swift @@ -1,28 +1,6 @@ import SwiftUI import Han1meShared -struct CommentView: View { - @ObservedObject private var viewModel: CommentViewModel - private let onOverlayActivityChanged: (Bool) -> Void - - init( - viewModel: CommentViewModel, - onOverlayActivityChanged: @escaping (Bool) -> Void = { _ in } - ) { - _viewModel = ObservedObject(wrappedValue: viewModel) - self.onOverlayActivityChanged = onOverlayActivityChanged - } - - var body: some View { - CommentOverlayHost( - viewModel: viewModel, - onOverlayActivityChanged: onOverlayActivityChanged - ) { tableModel in - CommentListTableView(model: tableModel) - } - } -} - struct CommentOverlayHost: View { @ObservedObject private var viewModel: CommentViewModel private let onOverlayActivityChanged: (Bool) -> Void From 5af3d7e603aa149226ef546e7e478c77fe2f771f Mon Sep 17 00:00:00 2001 From: pgs666 <74764545+pgs666@users.noreply.github.com> Date: Sun, 7 Jun 2026 14:58:32 +0800 Subject: [PATCH 144/216] Align detail pager settling with smooth paging --- iosApp/VideoDetailPager.swift | 44 +++++++---------------------------- 1 file changed, 8 insertions(+), 36 deletions(-) diff --git a/iosApp/VideoDetailPager.swift b/iosApp/VideoDetailPager.swift index 0b50309f..05c674cc 100644 --- a/iosApp/VideoDetailPager.swift +++ b/iosApp/VideoDetailPager.swift @@ -797,7 +797,6 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle private let listHeaderView = UIView() private let collapseSpacerView = UIView() private let contentBottomSpacerView = UIView() - private let nativeMinimumContentFooterView = UIView(frame: .zero) private let host = UIHostingController(rootView: AnyView(EmptyView())) private var hostMinimumHeightConstraint: NSLayoutConstraint! private var contentMinimumHeightConstraint: NSLayoutConstraint! @@ -1021,7 +1020,7 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle if abs(contentMinimumHeightConstraint.constant - offsetContext.minimumContentHeight) > 0.5 { contentMinimumHeightConstraint.constant = isNativeScrollView ? 1 : offsetContext.minimumContentHeight } - applyNativeMinimumContentFooterIfNeeded(page: page, offsetContext: offsetContext) + applyNativeMinimumContentSizeIfNeeded(page: page, offsetContext: offsetContext) if alignmentState.shouldReopenFirstActiveAlignment( isSelected: page.isSelected, contentHeight: listScrollView.contentSize.height @@ -1064,36 +1063,21 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle } } - private func applyNativeMinimumContentFooterIfNeeded( + private func applyNativeMinimumContentSizeIfNeeded( page: VideoDetailTabPage, offsetContext: VideoDetailListOffsetContext ) { - guard case .nativeScrollView = page.content, - let tableView = listScrollView as? UITableView else { - if let tableView = listScrollView as? UITableView, - tableView.tableFooterView === nativeMinimumContentFooterView { - tableView.tableFooterView = nil - } + guard case .nativeScrollView = page.content else { return } - tableView.layoutIfNeeded() let requiredContentHeight = offsetContext.minimumListContentHeight - let currentFooterHeight = tableView.tableFooterView === nativeMinimumContentFooterView - ? nativeMinimumContentFooterView.bounds.height - : 0 - let currentContentHeightWithoutFooter = max(tableView.contentSize.height - currentFooterHeight, 0) - let footerHeight = max(requiredContentHeight - currentContentHeightWithoutFooter, 0) - guard abs(nativeMinimumContentFooterView.frame.height - footerHeight) > 0.5 - || tableView.tableFooterView !== nativeMinimumContentFooterView else { + guard listScrollView.contentSize.height < requiredContentHeight - 0.5 else { return } - nativeMinimumContentFooterView.frame = CGRect( - x: 0, - y: 0, - width: tableView.bounds.width, - height: footerHeight + listScrollView.contentSize = CGSize( + width: listScrollView.contentSize.width, + height: requiredContentHeight ) - tableView.tableFooterView = footerHeight > 0 ? nativeMinimumContentFooterView : nil } private func applyListHeaderFrame() { @@ -1441,7 +1425,6 @@ private struct VideoDetailTabPager: UIViewControllerRepresentable { var onPagingActivityChanged: ((Bool) -> Void)? var onHorizontalVisibleIndexChanged: ((Int) -> Void)? private var lastProgrammaticIndex: Int? - private var pendingSettledIndex: Int? init(selectedTab: Binding) { self.selectedTab = selectedTab @@ -1504,18 +1487,10 @@ private struct VideoDetailTabPager: UIViewControllerRepresentable { let index = Int(round(listScrollView.contentOffset.x / width)) let tab = VideoPageTab.page(at: index) if selectedTab.wrappedValue != tab { - pendingSettledIndex = index selectedTab.wrappedValue = tab - return } onSelectedIndexSettled?(index) } - - func consumePendingSettledIndex(for selectedIndex: Int) -> Bool { - guard pendingSettledIndex == selectedIndex else { return false } - pendingSettledIndex = nil - return true - } } final class PagingViewController: UIViewController { @@ -1673,11 +1648,8 @@ private struct VideoDetailTabPager: UIViewControllerRepresentable { syncInactivePageHeaderOffset() } guard !scrollView.isTracking, !scrollView.isDragging, !scrollView.isDecelerating else { return } - let consumedSettledIndex = coordinator.consumePendingSettledIndex(for: pagerPosition.selectedIndex) setSelectedIndex(pagerPosition.selectedIndex, animated: animated) - if consumedSettledIndex { - finishHorizontalPaging(at: pagerPosition.selectedIndex) - } else if !animated { + if !animated { settleSelectedPageIfNeeded(pagerPosition.selectedIndex) } } From 0073642390858c7c029552af0d6da89f5c07d855 Mon Sep 17 00:00:00 2001 From: pgs666 <74764545+pgs666@users.noreply.github.com> Date: Sun, 7 Jun 2026 15:05:59 +0800 Subject: [PATCH 145/216] Simplify detail pager activation alignment --- iosApp/VideoDetailPager.swift | 95 +++-------------------------------- 1 file changed, 7 insertions(+), 88 deletions(-) diff --git a/iosApp/VideoDetailPager.swift b/iosApp/VideoDetailPager.swift index 05c674cc..762be1c8 100644 --- a/iosApp/VideoDetailPager.swift +++ b/iosApp/VideoDetailPager.swift @@ -376,9 +376,6 @@ private struct VideoDetailListAlignmentState { var needsInitialHeaderOffsetReset = true var hasAppliedInitialListOffset = false var hasCompletedFirstActiveAlignment = false - var isFirstActiveAlignmentStabilizing = false - var firstActiveAlignedContentHeight: CGFloat? - var hasUserInteractedSinceFirstActiveAlignment = false var hasExplicitPendingTopAlignment: Bool { guard let pendingTopAlignment else { return false } @@ -394,15 +391,6 @@ private struct VideoDetailListAlignmentState { mutating func resetForContentUpdate() { hasCompletedFirstActiveAlignment = false - isFirstActiveAlignmentStabilizing = false - firstActiveAlignedContentHeight = nil - hasUserInteractedSinceFirstActiveAlignment = false - } - - mutating func reopenFirstActiveAlignmentAfterContentSizeChange() { - hasCompletedFirstActiveAlignment = false - isFirstActiveAlignmentStabilizing = false - firstActiveAlignedContentHeight = nil } mutating func markInitialOffsetApplied() { @@ -410,36 +398,8 @@ private struct VideoDetailListAlignmentState { hasAppliedInitialListOffset = true } - mutating func markFirstActiveAlignmentCompleted(contentHeight: CGFloat) { + mutating func markFirstActiveAlignmentCompleted() { hasCompletedFirstActiveAlignment = true - isFirstActiveAlignmentStabilizing = true - firstActiveAlignedContentHeight = contentHeight - hasUserInteractedSinceFirstActiveAlignment = false - } - - mutating func markFirstActiveAlignmentStabilized(contentHeight: CGFloat) { - hasCompletedFirstActiveAlignment = true - isFirstActiveAlignmentStabilizing = false - firstActiveAlignedContentHeight = contentHeight - hasUserInteractedSinceFirstActiveAlignment = false - } - - mutating func markUserInteractionAfterFirstActiveAlignment() { - guard hasCompletedFirstActiveAlignment, !isFirstActiveAlignmentStabilizing else { return } - hasUserInteractedSinceFirstActiveAlignment = true - } - - func shouldReopenFirstActiveAlignment( - isSelected: Bool, - contentHeight: CGFloat - ) -> Bool { - guard isSelected, - hasCompletedFirstActiveAlignment, - !hasUserInteractedSinceFirstActiveAlignment, - let firstActiveAlignedContentHeight else { - return false - } - return abs(contentHeight - firstActiveAlignedContentHeight) > 0.5 } } @@ -806,7 +766,6 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle private var lastAppliedPage: VideoDetailTabPage? private var alignmentState = VideoDetailListAlignmentState() private var pendingTopAlignmentRetryCount = 0 - private var firstActiveAlignmentVerificationPass = 0 private var listScrollViewContentSizeObservation: NSKeyValueObservation? private var listScrollViewBoundsObservation: NSKeyValueObservation? private var nativeScrollDelegateAttachment: ((UIScrollViewDelegate?) -> Void)? @@ -939,7 +898,6 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle coordinator.onInteractionBegan = page.onInteractionBegan coordinator.onTopPullDelta = page.onTopPullDelta coordinator.onVerticalInteractionBegan = { [weak self] in - self?.alignmentState.markUserInteractionAfterFirstActiveAlignment() self?.resolvePendingTopAlignmentIfPossible(allowDuringInteraction: true) } coordinator.onVisibleOffsetChange = { [weak self] tab, offset in @@ -1021,12 +979,6 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle contentMinimumHeightConstraint.constant = isNativeScrollView ? 1 : offsetContext.minimumContentHeight } applyNativeMinimumContentSizeIfNeeded(page: page, offsetContext: offsetContext) - if alignmentState.shouldReopenFirstActiveAlignment( - isSelected: page.isSelected, - contentHeight: listScrollView.contentSize.height - ) { - alignmentState.reopenFirstActiveAlignmentAfterContentSizeChange() - } if alignmentState.pendingTopAlignment != nil { resolvePendingTopAlignmentIfPossible() return @@ -1125,7 +1077,7 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle alignmentState.cancelPendingTopAlignment() pendingTopAlignmentRetryCount = 0 if lastAppliedPage?.isSelected == true { - markFirstActiveAlignmentCompleted() + alignmentState.markFirstActiveAlignmentCompleted() } } } @@ -1193,7 +1145,7 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle return } alignmentState.markInitialOffsetApplied() - markFirstActiveAlignmentCompleted() + alignmentState.markFirstActiveAlignmentCompleted() cancelPendingTopAlignment() return } @@ -1206,40 +1158,7 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle } setNormalizedContentOffsetY(targetOffsetY) alignmentState.markInitialOffsetApplied() - markFirstActiveAlignmentCompleted() - } - - private func markFirstActiveAlignmentCompleted() { - alignmentState.markFirstActiveAlignmentCompleted(contentHeight: listScrollView.contentSize.height) - scheduleFirstActiveAlignmentVerification() - } - - private func scheduleFirstActiveAlignmentVerification() { - guard firstActiveAlignmentVerificationPass == 0 else { return } - firstActiveAlignmentVerificationPass = 1 - DispatchQueue.main.async { [weak self] in - self?.runFirstActiveAlignmentVerification() - } - } - - private func runFirstActiveAlignmentVerification() { - view.layoutIfNeeded() - applyCurrentPageGeometryRules() - guard alignmentState.hasCompletedFirstActiveAlignment, - alignmentState.isFirstActiveAlignmentStabilizing, - lastAppliedPage?.isSelected == true else { - firstActiveAlignmentVerificationPass = 0 - return - } - if firstActiveAlignmentVerificationPass < 3 { - firstActiveAlignmentVerificationPass += 1 - DispatchQueue.main.async { [weak self] in - self?.runFirstActiveAlignmentVerification() - } - return - } - alignmentState.markFirstActiveAlignmentStabilized(contentHeight: listScrollView.contentSize.height) - firstActiveAlignmentVerificationPass = 0 + alignmentState.markFirstActiveAlignmentCompleted() } var normalizedContentOffsetY: CGFloat { @@ -1448,19 +1367,19 @@ private struct VideoDetailTabPager: UIViewControllerRepresentable { } func scrollViewDidEndDecelerating(_ listScrollView: UIScrollView) { - updateSelectedTab(from: listScrollView) onPagingActivityChanged?(false) + updateSelectedTab(from: listScrollView) } func scrollViewDidEndScrollingAnimation(_ listScrollView: UIScrollView) { - updateSelectedTab(from: listScrollView) onPagingActivityChanged?(false) + updateSelectedTab(from: listScrollView) } func scrollViewDidEndDragging(_ listScrollView: UIScrollView, willDecelerate decelerate: Bool) { if !decelerate { - updateSelectedTab(from: listScrollView) onPagingActivityChanged?(false) + updateSelectedTab(from: listScrollView) } } From 9715d99ad132608020ff079777763d90e2a5432c Mon Sep 17 00:00:00 2001 From: pgs666 <74764545+pgs666@users.noreply.github.com> Date: Sun, 7 Jun 2026 15:11:48 +0800 Subject: [PATCH 146/216] Make pager alignment event driven --- iosApp/VideoDetailPager.swift | 32 ++++---------------------------- 1 file changed, 4 insertions(+), 28 deletions(-) diff --git a/iosApp/VideoDetailPager.swift b/iosApp/VideoDetailPager.swift index 762be1c8..85ef786f 100644 --- a/iosApp/VideoDetailPager.swift +++ b/iosApp/VideoDetailPager.swift @@ -765,7 +765,6 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle private var contentUpdateRevision: Int? private var lastAppliedPage: VideoDetailTabPage? private var alignmentState = VideoDetailListAlignmentState() - private var pendingTopAlignmentRetryCount = 0 private var listScrollViewContentSizeObservation: NSKeyValueObservation? private var listScrollViewBoundsObservation: NSKeyValueObservation? private var nativeScrollDelegateAttachment: ((UIScrollViewDelegate?) -> Void)? @@ -956,7 +955,7 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle if alignmentState.needsInitialHeaderOffsetReset { applyInitialHeaderOffsetResetIfNeeded() } else if alignmentState.pendingTopAlignment != nil { - resolvePendingTopAlignmentSoon() + resolvePendingTopAlignmentIfPossible() } } @@ -1044,28 +1043,6 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle listHeaderView.frame = nextFrame } - private func resolvePendingTopAlignmentSoon() { - resolvePendingTopAlignmentIfPossible() - schedulePendingTopAlignmentRetry() - } - - private func schedulePendingTopAlignmentRetry() { - guard alignmentState.pendingTopAlignment != nil else { - pendingTopAlignmentRetryCount = 0 - return - } - guard pendingTopAlignmentRetryCount < 8 else { return } - pendingTopAlignmentRetryCount += 1 - DispatchQueue.main.async { [weak self] in - guard let self else { return } - self.view.layoutIfNeeded() - self.resolvePendingTopAlignmentIfPossible() - if self.alignmentState.pendingTopAlignment != nil { - self.schedulePendingTopAlignmentRetry() - } - } - } - private func resolvePendingTopAlignmentIfPossible(allowDuringInteraction: Bool = false) { guard let targetOffsetY = pendingTopAlignmentTargetOffsetY() else { return } if !allowDuringInteraction { @@ -1075,7 +1052,6 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle if abs(listScrollView.verticalContentOffsetExcludingBounce - targetOffsetY) <= 0.5 { alignmentState.markInitialOffsetApplied() alignmentState.cancelPendingTopAlignment() - pendingTopAlignmentRetryCount = 0 if lastAppliedPage?.isSelected == true { alignmentState.markFirstActiveAlignmentCompleted() } @@ -1092,7 +1068,7 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle ).initialNormalizedOffsetY guard setNormalizedContentOffsetYIfReachable(targetOffsetY) else { alignmentState.pendingTopAlignment = .initial - resolvePendingTopAlignmentSoon() + resolvePendingTopAlignmentIfPossible() return } if abs(listScrollView.verticalContentOffsetExcludingBounce - targetOffsetY) <= 0.5 { @@ -1141,7 +1117,7 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle if !alignmentState.hasCompletedFirstActiveAlignment { guard setNormalizedContentOffsetYIfReachable(targetOffsetY) else { alignmentState.pendingTopAlignment = .initial - resolvePendingTopAlignmentSoon() + resolvePendingTopAlignmentIfPossible() return } alignmentState.markInitialOffsetApplied() @@ -1172,7 +1148,7 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle let syncOffsetY = syncMode.normalizedOffsetY guard setNormalizedContentOffsetYIfReachable(syncOffsetY) else { alignmentState.pendingTopAlignment = .explicit(syncOffsetY) - resolvePendingTopAlignmentSoon() + resolvePendingTopAlignmentIfPossible() return } alignmentState.markInitialOffsetApplied() From 747c34d6e8f1351cbf2dcb9718e28381323c1eb6 Mon Sep 17 00:00:00 2001 From: pgs666 <74764545+pgs666@users.noreply.github.com> Date: Sun, 7 Jun 2026 15:21:34 +0800 Subject: [PATCH 147/216] Settle pager with target index --- iosApp/VideoDetailPager.swift | 20 ++++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/iosApp/VideoDetailPager.swift b/iosApp/VideoDetailPager.swift index 85ef786f..1f9060cb 100644 --- a/iosApp/VideoDetailPager.swift +++ b/iosApp/VideoDetailPager.swift @@ -1343,19 +1343,19 @@ private struct VideoDetailTabPager: UIViewControllerRepresentable { } func scrollViewDidEndDecelerating(_ listScrollView: UIScrollView) { - onPagingActivityChanged?(false) updateSelectedTab(from: listScrollView) + onPagingActivityChanged?(false) } func scrollViewDidEndScrollingAnimation(_ listScrollView: UIScrollView) { - onPagingActivityChanged?(false) updateSelectedTab(from: listScrollView) + onPagingActivityChanged?(false) } func scrollViewDidEndDragging(_ listScrollView: UIScrollView, willDecelerate decelerate: Bool) { if !decelerate { - onPagingActivityChanged?(false) updateSelectedTab(from: listScrollView) + onPagingActivityChanged?(false) } } @@ -1406,6 +1406,7 @@ private struct VideoDetailTabPager: UIViewControllerRepresentable { activeOffset: 0, collapseDistance: 0 ) + private var pendingHorizontalSettledIndex: Int? private var hasSyncedInactivePages = false private var latestPages: [VideoPageTab: VideoDetailTabPage] = [:] @@ -1582,7 +1583,13 @@ private struct VideoDetailTabPager: UIViewControllerRepresentable { } private func settlePageAfterHorizontalSelection(_ index: Int) { - finishHorizontalPaging(at: index) + let clampedIndex = min(max(index, 0), VideoPageTab.allCases.count - 1) + pendingHorizontalSettledIndex = clampedIndex + if pagerPosition.isPagingActive { + setHorizontalPagingActive(false) + } + pendingHorizontalSettledIndex = nil + finishHorizontalPaging(at: clampedIndex) } private func finishHorizontalPaging(at index: Int) { @@ -1612,14 +1619,15 @@ private struct VideoDetailTabPager: UIViewControllerRepresentable { } private func setHorizontalPagingActive(_ isActive: Bool) { + let settledIndex = pendingHorizontalSettledIndex ?? settledIndexFromHorizontalOffset() guard pagerPosition.setPagingActive( isActive, - settledIndex: isActive ? nil : settledIndexFromHorizontalOffset() + settledIndex: isActive ? nil : settledIndex ) else { return } introductionPage.setHorizontalPagingActive(isActive) commentsPage?.setHorizontalPagingActive(isActive) updateHeaderAttachmentForCurrentState() - if !isActive { + if !isActive, pendingHorizontalSettledIndex == nil { syncInactivePageHeaderOffset() } } From a2720b931e1516a9eee62c9b44a8e2cea6276230 Mon Sep 17 00:00:00 2001 From: pgs666 <74764545+pgs666@users.noreply.github.com> Date: Sun, 7 Jun 2026 15:30:39 +0800 Subject: [PATCH 148/216] Avoid full comment reload for action state --- iosApp/CommentListTableView.swift | 32 ++++++++++++++++++++++++++++--- 1 file changed, 29 insertions(+), 3 deletions(-) diff --git a/iosApp/CommentListTableView.swift b/iosApp/CommentListTableView.swift index 54eb9acf..6afffea0 100644 --- a/iosApp/CommentListTableView.swift +++ b/iosApp/CommentListTableView.swift @@ -40,13 +40,11 @@ final class CommentListTableController: NSObject, UITableViewDataSource, UITable private struct ModelSignature: Equatable { let state: StateSignature let sortMode: CommentViewModel.SortMode - let runningActionIDs: Set let comments: [CommentSignature] init(_ model: CommentListTableModel) { state = StateSignature(model.state) sortMode = model.sortMode - runningActionIDs = model.runningActionIDs comments = model.comments.map(CommentSignature.init) } } @@ -98,6 +96,7 @@ final class CommentListTableController: NSObject, UITableViewDataSource, UITable private var onDislike: (CommentRow) -> Void = { _ in } private var onReport: (CommentRow) -> Void = { _ in } private var modelSignature: ModelSignature? + private var previousRunningActionIDs: Set = [] weak var scrollDelegate: UIScrollViewDelegate? func attach(_ tableView: UITableView) { @@ -116,7 +115,9 @@ final class CommentListTableController: NSObject, UITableViewDataSource, UITable func update(_ model: CommentListTableModel) { let nextSignature = ModelSignature(model) let shouldReload = modelSignature != nextSignature + let didChangeRunningActions = previousRunningActionIDs != model.runningActionIDs modelSignature = nextSignature + previousRunningActionIDs = model.runningActionIDs sortMode = model.sortMode comments = model.comments runningActionIDs = model.runningActionIDs @@ -128,7 +129,12 @@ final class CommentListTableController: NSObject, UITableViewDataSource, UITable onLike = model.onLike onDislike = model.onDislike onReport = model.onReport - guard shouldReload else { return } + guard shouldReload else { + if didChangeRunningActions { + updateVisibleCommentRunningStates() + } + return + } rows = rows(for: model) tableView?.reloadData() } @@ -258,6 +264,26 @@ final class CommentListTableController: NSObject, UITableViewDataSource, UITable return [.controls] + model.comments.map { .comment($0) } + [.footer] } } + + private func updateVisibleCommentRunningStates() { + guard let tableView else { return } + for indexPath in tableView.indexPathsForVisibleRows ?? [] { + guard indexPath.row < rows.count, + case .comment(let comment) = rows[indexPath.row], + let cell = tableView.cellForRow(at: indexPath) as? HostingCommentTableViewCell else { + continue + } + cell.configure( + comment: comment, + isRunningLike: runningActionIDs.contains("like-\(comment.id)"), + onReply: { [weak self] in self?.onReply(comment) }, + onShowReplies: { [weak self] in self?.onShowReplies(comment) }, + onLike: { [weak self] in self?.onLike(comment) }, + onDislike: { [weak self] in self?.onDislike(comment) }, + onReport: { [weak self] in self?.onReport(comment) } + ) + } + } } private final class HostingCommentTableViewCell: UITableViewCell { From 6802d73f46c037827f3b29def81be4dac4f68051 Mon Sep 17 00:00:00 2001 From: pgs666 <74764545+pgs666@users.noreply.github.com> Date: Sun, 7 Jun 2026 15:39:19 +0800 Subject: [PATCH 149/216] Avoid full comment reload for like state --- iosApp/CommentListTableView.swift | 32 ++++++++++++++++++++++++------- 1 file changed, 25 insertions(+), 7 deletions(-) diff --git a/iosApp/CommentListTableView.swift b/iosApp/CommentListTableView.swift index 6afffea0..6ef7241f 100644 --- a/iosApp/CommentListTableView.swift +++ b/iosApp/CommentListTableView.swift @@ -54,20 +54,28 @@ final class CommentListTableController: NSObject, UITableViewDataSource, UITable let username: String let date: String let content: String - let thumbUp: Int? let hasMoreReplies: Bool let replyCount: Int? - let likeCommentStatus: Bool - let unlikeCommentStatus: Bool init(_ comment: CommentRow) { id = comment.id username = comment.username date = comment.date content = comment.content - thumbUp = comment.thumbUp hasMoreReplies = comment.hasMoreReplies replyCount = comment.replyCount + } + } + + private struct CommentActionSignature: Equatable { + let id: String + let thumbUp: Int? + let likeCommentStatus: Bool + let unlikeCommentStatus: Bool + + init(_ comment: CommentRow) { + id = comment.id + thumbUp = comment.thumbUp likeCommentStatus = comment.likeCommentStatus unlikeCommentStatus = comment.unlikeCommentStatus } @@ -96,6 +104,7 @@ final class CommentListTableController: NSObject, UITableViewDataSource, UITable private var onDislike: (CommentRow) -> Void = { _ in } private var onReport: (CommentRow) -> Void = { _ in } private var modelSignature: ModelSignature? + private var commentActionSignatures: [CommentActionSignature] = [] private var previousRunningActionIDs: Set = [] weak var scrollDelegate: UIScrollViewDelegate? @@ -115,8 +124,11 @@ final class CommentListTableController: NSObject, UITableViewDataSource, UITable func update(_ model: CommentListTableModel) { let nextSignature = ModelSignature(model) let shouldReload = modelSignature != nextSignature + let nextCommentActionSignatures = model.comments.map(CommentActionSignature.init) + let didChangeCommentActions = commentActionSignatures != nextCommentActionSignatures let didChangeRunningActions = previousRunningActionIDs != model.runningActionIDs modelSignature = nextSignature + commentActionSignatures = nextCommentActionSignatures previousRunningActionIDs = model.runningActionIDs sortMode = model.sortMode comments = model.comments @@ -129,9 +141,15 @@ final class CommentListTableController: NSObject, UITableViewDataSource, UITable onLike = model.onLike onDislike = model.onDislike onReport = model.onReport + let shouldReloadForActionChange = didChangeCommentActions + && (model.sortMode == .mostLikes || model.sortMode == .mostDislikes) guard shouldReload else { - if didChangeRunningActions { - updateVisibleCommentRunningStates() + if shouldReloadForActionChange { + rows = rows(for: model) + tableView?.reloadData() + } else if didChangeRunningActions || didChangeCommentActions { + rows = rows(for: model) + updateVisibleCommentRows() } return } @@ -265,7 +283,7 @@ final class CommentListTableController: NSObject, UITableViewDataSource, UITable } } - private func updateVisibleCommentRunningStates() { + private func updateVisibleCommentRows() { guard let tableView else { return } for indexPath in tableView.indexPathsForVisibleRows ?? [] { guard indexPath.row < rows.count, From 449758cc70d2676e88402f6d588393f0d52bb573 Mon Sep 17 00:00:00 2001 From: pgs666 <74764545+pgs666@users.noreply.github.com> Date: Sun, 7 Jun 2026 15:52:24 +0800 Subject: [PATCH 150/216] Align native comments pager with smooth list layout --- iosApp/VideoDetailPager.swift | 173 +++++++++++++++++++++------------- 1 file changed, 105 insertions(+), 68 deletions(-) diff --git a/iosApp/VideoDetailPager.swift b/iosApp/VideoDetailPager.swift index 1f9060cb..5f7072b8 100644 --- a/iosApp/VideoDetailPager.swift +++ b/iosApp/VideoDetailPager.swift @@ -753,15 +753,17 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle private let coordinator: VideoDetailVerticalScrollPageCoordinator private let listScrollView: UIScrollView private let defaultScrollView: VerticalScrollView? + private let usesNativeListScrollView: Bool private let contentView = UIView() private let listHeaderView = UIView() private let collapseSpacerView = UIView() private let contentBottomSpacerView = UIView() + private let nativeBottomSpacerView = UIView() private let host = UIHostingController(rootView: AnyView(EmptyView())) - private var hostMinimumHeightConstraint: NSLayoutConstraint! - private var contentMinimumHeightConstraint: NSLayoutConstraint! - private var collapseSpacerHeightConstraint: NSLayoutConstraint! - private var contentBottomSpacerHeightConstraint: NSLayoutConstraint! + private var hostMinimumHeightConstraint: NSLayoutConstraint? + private var contentMinimumHeightConstraint: NSLayoutConstraint? + private var collapseSpacerHeightConstraint: NSLayoutConstraint? + private var contentBottomSpacerHeightConstraint: NSLayoutConstraint? private var contentUpdateRevision: Int? private var lastAppliedPage: VideoDetailTabPage? private var alignmentState = VideoDetailListAlignmentState() @@ -774,6 +776,7 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle let resolvedScrollView = listScrollView ?? VerticalScrollView() self.listScrollView = resolvedScrollView self.defaultScrollView = resolvedScrollView as? VerticalScrollView + self.usesNativeListScrollView = listScrollView != nil coordinator = VideoDetailVerticalScrollPageCoordinator( tab: tab, onOffsetChange: { _, _ in }, @@ -826,67 +829,79 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle } view.addSubview(listScrollView) - contentView.translatesAutoresizingMaskIntoConstraints = false - contentView.backgroundColor = .clear - listScrollView.addSubview(contentView) - listHeaderView.backgroundColor = .clear listHeaderView.isUserInteractionEnabled = true listScrollView.addSubview(listHeaderView) - addChild(host) - host.view.translatesAutoresizingMaskIntoConstraints = false - host.view.backgroundColor = .clear - contentView.addSubview(host.view) - host.didMove(toParent: self) - - collapseSpacerView.translatesAutoresizingMaskIntoConstraints = false - collapseSpacerView.backgroundColor = .clear - contentView.addSubview(collapseSpacerView) - - contentBottomSpacerView.translatesAutoresizingMaskIntoConstraints = false - contentBottomSpacerView.backgroundColor = .clear - contentView.addSubview(contentBottomSpacerView) - - hostMinimumHeightConstraint = host.view.heightAnchor.constraint(greaterThanOrEqualTo: listScrollView.frameLayoutGuide.heightAnchor) - contentMinimumHeightConstraint = contentView.heightAnchor.constraint(greaterThanOrEqualToConstant: 1) - collapseSpacerHeightConstraint = collapseSpacerView.heightAnchor.constraint(equalToConstant: 1) - contentBottomSpacerHeightConstraint = contentBottomSpacerView.heightAnchor.constraint(equalToConstant: 0) - - NSLayoutConstraint.activate([ + var constraints = [ listScrollView.leadingAnchor.constraint(equalTo: view.leadingAnchor), listScrollView.trailingAnchor.constraint(equalTo: view.trailingAnchor), listScrollView.topAnchor.constraint(equalTo: view.topAnchor), - listScrollView.bottomAnchor.constraint(equalTo: view.bottomAnchor), - - contentView.leadingAnchor.constraint(equalTo: listScrollView.contentLayoutGuide.leadingAnchor), - contentView.trailingAnchor.constraint(equalTo: listScrollView.contentLayoutGuide.trailingAnchor), - contentView.topAnchor.constraint(equalTo: listScrollView.contentLayoutGuide.topAnchor), - contentView.bottomAnchor.constraint(equalTo: listScrollView.contentLayoutGuide.bottomAnchor), - contentView.widthAnchor.constraint(equalTo: listScrollView.frameLayoutGuide.widthAnchor), - contentMinimumHeightConstraint, + listScrollView.bottomAnchor.constraint(equalTo: view.bottomAnchor) + ] - host.view.leadingAnchor.constraint(equalTo: contentView.leadingAnchor), - host.view.trailingAnchor.constraint(equalTo: contentView.trailingAnchor), - host.view.topAnchor.constraint(equalTo: contentView.topAnchor), - hostMinimumHeightConstraint, - - collapseSpacerView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor), - collapseSpacerView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor), - collapseSpacerView.topAnchor.constraint(equalTo: host.view.bottomAnchor), - collapseSpacerHeightConstraint, - - contentBottomSpacerView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor), - contentBottomSpacerView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor), - contentBottomSpacerView.topAnchor.constraint(equalTo: collapseSpacerView.bottomAnchor), - contentBottomSpacerView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor), - contentBottomSpacerHeightConstraint - ]) + if !usesNativeListScrollView { + contentView.translatesAutoresizingMaskIntoConstraints = false + contentView.backgroundColor = .clear + listScrollView.addSubview(contentView) + + addChild(host) + host.view.translatesAutoresizingMaskIntoConstraints = false + host.view.backgroundColor = .clear + contentView.addSubview(host.view) + host.didMove(toParent: self) + + collapseSpacerView.translatesAutoresizingMaskIntoConstraints = false + collapseSpacerView.backgroundColor = .clear + contentView.addSubview(collapseSpacerView) + + contentBottomSpacerView.translatesAutoresizingMaskIntoConstraints = false + contentBottomSpacerView.backgroundColor = .clear + contentView.addSubview(contentBottomSpacerView) + + let hostMinimumHeightConstraint = host.view.heightAnchor.constraint(greaterThanOrEqualTo: listScrollView.frameLayoutGuide.heightAnchor) + let contentMinimumHeightConstraint = contentView.heightAnchor.constraint(greaterThanOrEqualToConstant: 1) + let collapseSpacerHeightConstraint = collapseSpacerView.heightAnchor.constraint(equalToConstant: 1) + let contentBottomSpacerHeightConstraint = contentBottomSpacerView.heightAnchor.constraint(equalToConstant: 0) + self.hostMinimumHeightConstraint = hostMinimumHeightConstraint + self.contentMinimumHeightConstraint = contentMinimumHeightConstraint + self.collapseSpacerHeightConstraint = collapseSpacerHeightConstraint + self.contentBottomSpacerHeightConstraint = contentBottomSpacerHeightConstraint + + constraints.append(contentsOf: [ + contentView.leadingAnchor.constraint(equalTo: listScrollView.contentLayoutGuide.leadingAnchor), + contentView.trailingAnchor.constraint(equalTo: listScrollView.contentLayoutGuide.trailingAnchor), + contentView.topAnchor.constraint(equalTo: listScrollView.contentLayoutGuide.topAnchor), + contentView.bottomAnchor.constraint(equalTo: listScrollView.contentLayoutGuide.bottomAnchor), + contentView.widthAnchor.constraint(equalTo: listScrollView.frameLayoutGuide.widthAnchor), + contentMinimumHeightConstraint, + + host.view.leadingAnchor.constraint(equalTo: contentView.leadingAnchor), + host.view.trailingAnchor.constraint(equalTo: contentView.trailingAnchor), + host.view.topAnchor.constraint(equalTo: contentView.topAnchor), + hostMinimumHeightConstraint, + + collapseSpacerView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor), + collapseSpacerView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor), + collapseSpacerView.topAnchor.constraint(equalTo: host.view.bottomAnchor), + collapseSpacerHeightConstraint, + + contentBottomSpacerView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor), + contentBottomSpacerView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor), + contentBottomSpacerView.topAnchor.constraint(equalTo: collapseSpacerView.bottomAnchor), + contentBottomSpacerView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor), + contentBottomSpacerHeightConstraint + ]) + } + NSLayoutConstraint.activate(constraints) } override func viewDidLayoutSubviews() { super.viewDidLayoutSubviews() applyListHeaderFrame() + if usesNativeListScrollView, let page = lastAppliedPage { + applyNativeBottomSpacer(page.contentBottomPadding) + } handleScrollGeometryChange() } @@ -922,8 +937,10 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle host.view.isHidden = false host.rootView = content() case .nativeScrollView: - host.view.isHidden = true - host.rootView = AnyView(EmptyView()) + if !usesNativeListScrollView { + host.view.isHidden = true + host.rootView = AnyView(EmptyView()) + } } alignmentState.resetForContentUpdate() if !alignmentState.hasAppliedInitialListOffset && !alignmentState.hasExplicitPendingTopAlignment { @@ -943,14 +960,14 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle switch page.content { case .swiftUI: applyBottomContentSpacing(page.contentBottomPadding, usesContentSpacer: true) - collapseSpacerHeightConstraint.constant = offsetContext.collapseSpacerHeight - contentMinimumHeightConstraint.constant = offsetContext.minimumContentHeight - hostMinimumHeightConstraint.isActive = true + collapseSpacerHeightConstraint?.constant = offsetContext.collapseSpacerHeight + contentMinimumHeightConstraint?.constant = offsetContext.minimumContentHeight + hostMinimumHeightConstraint?.isActive = true case .nativeScrollView: applyBottomContentSpacing(page.contentBottomPadding, usesContentSpacer: false) - collapseSpacerHeightConstraint.constant = 0 - contentMinimumHeightConstraint.constant = 1 - hostMinimumHeightConstraint.isActive = false + collapseSpacerHeightConstraint?.constant = 0 + contentMinimumHeightConstraint?.constant = 1 + hostMinimumHeightConstraint?.isActive = false } if alignmentState.needsInitialHeaderOffsetReset { applyInitialHeaderOffsetResetIfNeeded() @@ -974,7 +991,8 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle } else { isNativeScrollView = false } - if abs(contentMinimumHeightConstraint.constant - offsetContext.minimumContentHeight) > 0.5 { + if let contentMinimumHeightConstraint, + abs(contentMinimumHeightConstraint.constant - offsetContext.minimumContentHeight) > 0.5 { contentMinimumHeightConstraint.constant = isNativeScrollView ? 1 : offsetContext.minimumContentHeight } applyNativeMinimumContentSizeIfNeeded(page: page, offsetContext: offsetContext) @@ -1002,16 +1020,35 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle private func applyBottomContentSpacing(_ bottomSpacing: CGFloat, usesContentSpacer: Bool) { let resolvedBottomSpacing = max(bottomSpacing, 0) let contentSpacerHeight = usesContentSpacer ? resolvedBottomSpacing : 0 - if abs(contentBottomSpacerHeightConstraint.constant - contentSpacerHeight) > 0.5 { + if let contentBottomSpacerHeightConstraint, + abs(contentBottomSpacerHeightConstraint.constant - contentSpacerHeight) > 0.5 { contentBottomSpacerHeightConstraint.constant = contentSpacerHeight } - let bottomInset = usesContentSpacer ? 0 : resolvedBottomSpacing + let bottomInset: CGFloat = 0 if abs(listScrollView.contentInset.bottom - bottomInset) > 0.5 { listScrollView.contentInset.bottom = bottomInset } if abs(listScrollView.verticalScrollIndicatorInsets.bottom - resolvedBottomSpacing) > 0.5 { listScrollView.verticalScrollIndicatorInsets.bottom = resolvedBottomSpacing } + if !usesContentSpacer { + applyNativeBottomSpacer(resolvedBottomSpacing) + } + } + + private func applyNativeBottomSpacer(_ bottomSpacing: CGFloat) { + guard let tableView = listScrollView as? UITableView else { return } + let width = max(tableView.bounds.width, 1) + let nextFrame = CGRect(x: 0, y: 0, width: width, height: bottomSpacing) + nativeBottomSpacerView.backgroundColor = .clear + if tableView.tableFooterView !== nativeBottomSpacerView { + nativeBottomSpacerView.frame = nextFrame + tableView.tableFooterView = nativeBottomSpacerView + return + } + guard !nativeBottomSpacerView.frame.isApproximatelyEqual(to: nextFrame) else { return } + nativeBottomSpacerView.frame = nextFrame + tableView.tableFooterView = nativeBottomSpacerView } private func applyNativeMinimumContentSizeIfNeeded( @@ -1343,18 +1380,18 @@ private struct VideoDetailTabPager: UIViewControllerRepresentable { } func scrollViewDidEndDecelerating(_ listScrollView: UIScrollView) { - updateSelectedTab(from: listScrollView) + settleSelectedIndex(from: listScrollView) onPagingActivityChanged?(false) } func scrollViewDidEndScrollingAnimation(_ listScrollView: UIScrollView) { - updateSelectedTab(from: listScrollView) + settleSelectedIndex(from: listScrollView) onPagingActivityChanged?(false) } func scrollViewDidEndDragging(_ listScrollView: UIScrollView, willDecelerate decelerate: Bool) { if !decelerate { - updateSelectedTab(from: listScrollView) + settleSelectedIndex(from: listScrollView) onPagingActivityChanged?(false) } } @@ -1376,15 +1413,15 @@ private struct VideoDetailTabPager: UIViewControllerRepresentable { return horizontal > 8 && horizontal > vertical * 1.18 } - private func updateSelectedTab(from listScrollView: UIScrollView) { + private func settleSelectedIndex(from listScrollView: UIScrollView) { let width = listScrollView.bounds.width guard width > 0 else { return } let index = Int(round(listScrollView.contentOffset.x / width)) + onSelectedIndexSettled?(index) let tab = VideoPageTab.page(at: index) if selectedTab.wrappedValue != tab { selectedTab.wrappedValue = tab } - onSelectedIndexSettled?(index) } } From ed0d2221f7b623baadaae446505cdf68ba135475 Mon Sep 17 00:00:00 2001 From: pgs666 <74764545+pgs666@users.noreply.github.com> Date: Sun, 7 Jun 2026 15:59:36 +0800 Subject: [PATCH 151/216] Keep smooth header stable during horizontal paging --- iosApp/VideoDetailPager.swift | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/iosApp/VideoDetailPager.swift b/iosApp/VideoDetailPager.swift index 5f7072b8..c2b72a92 100644 --- a/iosApp/VideoDetailPager.swift +++ b/iosApp/VideoDetailPager.swift @@ -410,7 +410,7 @@ private struct VideoDetailHorizontalPagerPosition: Equatable { private(set) var isPagingActive = false var activeHeaderIndex: Int { - isPagingActive ? visibleIndex : selectedIndex + selectedIndex } mutating func setSelectedIndex(_ index: Int) { @@ -1671,6 +1671,10 @@ private struct VideoDetailTabPager: UIViewControllerRepresentable { private func setHeaderVisibleIndex(_ index: Int) { guard pagerPosition.setVisibleIndex(index) else { return } + guard !pagerPosition.isPagingActive else { + attachActiveHeaderToPagerContainer() + return + } updateHeaderAttachmentForCurrentState() } @@ -1838,6 +1842,17 @@ private struct VideoDetailTabPager: UIViewControllerRepresentable { applyHeaderAttachment(attachmentState, pageController: pageController) } + private func attachActiveHeaderToPagerContainer() { + let headerTab = activeHeaderTab + guard let page = latestPages[headerTab], + let pageController = verticalPageController(for: headerTab) else { return } + layoutHeaderHosts() + let offset = pageController.normalizedContentOffsetY + let syncState = page.headerGeometry.smoothHeaderSyncState(activeOffset: offset) + headerSyncState = syncState + attachHeader(to: view, originY: syncState.headerContainerY) + } + private func applyHeaderAttachment( _ attachmentState: VideoDetailHeaderAttachmentState, pageController: VideoDetailVerticalScrollPageViewController From e3ea9d334817a8ffac8f9ed230a0008de63d25ec Mon Sep 17 00:00:00 2001 From: pgs666 <74764545+pgs666@users.noreply.github.com> Date: Sun, 7 Jun 2026 16:09:17 +0800 Subject: [PATCH 152/216] Sync inactive pager lists directly --- iosApp/VideoDetailPager.swift | 29 +---------------------------- 1 file changed, 1 insertion(+), 28 deletions(-) diff --git a/iosApp/VideoDetailPager.swift b/iosApp/VideoDetailPager.swift index c2b72a92..07e32ad9 100644 --- a/iosApp/VideoDetailPager.swift +++ b/iosApp/VideoDetailPager.swift @@ -1444,7 +1444,6 @@ private struct VideoDetailTabPager: UIViewControllerRepresentable { collapseDistance: 0 ) private var pendingHorizontalSettledIndex: Int? - private var hasSyncedInactivePages = false private var latestPages: [VideoPageTab: VideoDetailTabPage] = [:] init(coordinator: Coordinator) { @@ -1558,10 +1557,6 @@ private struct VideoDetailTabPager: UIViewControllerRepresentable { ) { loadViewIfNeeded() pagerPosition.setSelectedIndex(selectedIndex) - if introduction.contentUpdateRevision != latestPages[.introduction]?.contentUpdateRevision - || comments.contentUpdateRevision != latestPages[.comments]?.contentUpdateRevision { - hasSyncedInactivePages = false - } latestPages[.introduction] = introduction latestPages[.comments] = comments prepareCommentsPageIfNeeded(for: comments) @@ -1690,32 +1685,10 @@ private struct VideoDetailTabPager: UIViewControllerRepresentable { return } let activeOffset = providedActiveOffset ?? activePage.normalizedContentOffsetY - let previousSyncState = headerSyncState let nextSyncState = updateHeaderSyncState(activeOffset: activeOffset) - guard let syncMode = inactiveSyncMode( - previousState: previousSyncState, - nextState: nextSyncState - ) else { return } - hasSyncedInactivePages = true for tab in VideoPageTab.allCases where tab.pageIndex != pagerPosition.selectedIndex { - verticalPageController(for: tab)?.syncHeaderOffsetFromActivePage(syncMode) - } - } - - private func inactiveSyncMode( - previousState: VideoDetailSmoothHeaderSyncState, - nextState: VideoDetailSmoothHeaderSyncState - ) -> VideoDetailPagerOffsetModel.InactiveSyncMode? { - if !hasSyncedInactivePages { - return nextState.inactiveSyncMode - } - if nextState.isSyncingListOffsets { - return nextState.inactiveSyncMode - } - if previousState.isSyncingListOffsets { - return nextState.inactiveSyncMode + verticalPageController(for: tab)?.syncHeaderOffsetFromActivePage(nextSyncState.inactiveSyncMode) } - return nil } @discardableResult From f7dd7d2e86388aa4393106ce05449b6274680c06 Mon Sep 17 00:00:00 2001 From: pgs666 <74764545+pgs666@users.noreply.github.com> Date: Sun, 7 Jun 2026 16:16:24 +0800 Subject: [PATCH 153/216] Activate pager lists with current header offset --- iosApp/VideoDetailPager.swift | 28 ++++++++++++++++++++++------ 1 file changed, 22 insertions(+), 6 deletions(-) diff --git a/iosApp/VideoDetailPager.swift b/iosApp/VideoDetailPager.swift index 07e32ad9..e9cd0096 100644 --- a/iosApp/VideoDetailPager.swift +++ b/iosApp/VideoDetailPager.swift @@ -1133,9 +1133,12 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle alignmentState.hasExplicitPendingTopAlignment } - func settleAfterHorizontalActivation() { + func settleAfterHorizontalActivation(targetOffsetY: CGFloat) { loadViewIfNeeded() - applyFirstActiveAlignmentIfNeeded(allowDuringInteraction: true) + applyFirstActiveAlignmentIfNeeded( + targetOffsetY: targetOffsetY, + allowDuringInteraction: true + ) } private func applyFirstActiveAlignmentIfNeeded(allowDuringInteraction: Bool = false) { @@ -1143,10 +1146,19 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle let targetOffsetY = page.headerGeometry.listOffsetContext( in: listScrollView.bounds.height ).initialNormalizedOffsetY + applyFirstActiveAlignmentIfNeeded( + targetOffsetY: targetOffsetY, + allowDuringInteraction: allowDuringInteraction + ) + } + + private func applyFirstActiveAlignmentIfNeeded( + targetOffsetY: CGFloat, + allowDuringInteraction: Bool = false + ) { coordinator.visualTopContentOffsetY = targetOffsetY if alignmentState.needsInitialHeaderOffsetReset { - applyInitialHeaderOffsetResetIfNeeded(allowDuringInteraction: allowDuringInteraction) - guard !alignmentState.needsInitialHeaderOffsetReset else { return } + alignmentState.markInitialOffsetApplied() } if !allowDuringInteraction { guard !listScrollView.isTracking, !listScrollView.isDragging, !listScrollView.isDecelerating else { return } @@ -1626,18 +1638,22 @@ private struct VideoDetailTabPager: UIViewControllerRepresentable { private func finishHorizontalPaging(at index: Int) { let clampedIndex = min(max(index, 0), VideoPageTab.allCases.count - 1) + if let activePage = verticalPageController(for: VideoPageTab.page(at: pagerPosition.selectedIndex)) { + updateHeaderSyncState(activeOffset: activePage.normalizedContentOffsetY) + } + let activationOffset = headerSyncState.inactiveSyncMode.normalizedOffsetY let didChangeSettledIndex = pagerPosition.markSettled(clampedIndex) updateScrollsToTop(for: VideoPageTab.page(at: clampedIndex)) layoutHeaderHosts() switch VideoPageTab.page(at: clampedIndex) { case .introduction: if didChangeSettledIndex { - introductionPage.settleAfterHorizontalActivation() + introductionPage.settleAfterHorizontalActivation(targetOffsetY: activationOffset) } introductionPage.reportCurrentOffset() case .comments: if didChangeSettledIndex { - commentsPage?.settleAfterHorizontalActivation() + commentsPage?.settleAfterHorizontalActivation(targetOffsetY: activationOffset) } commentsPage?.reportCurrentOffset() } From c941749c56bffb028a2cbeb7709b58b0bb31ea7c Mon Sep 17 00:00:00 2001 From: pgs666 <74764545+pgs666@users.noreply.github.com> Date: Sun, 7 Jun 2026 16:25:16 +0800 Subject: [PATCH 154/216] Keep visual top separate from activation offset --- iosApp/VideoDetailPager.swift | 15 --------------- 1 file changed, 15 deletions(-) diff --git a/iosApp/VideoDetailPager.swift b/iosApp/VideoDetailPager.swift index e9cd0096..66146367 100644 --- a/iosApp/VideoDetailPager.swift +++ b/iosApp/VideoDetailPager.swift @@ -61,13 +61,6 @@ enum VideoDetailPagerOffsetModel { max(scrollBoundsHeight - max(pinHeaderHeight, 0), 1) } - static func shouldAlignToVisualTopAfterHorizontalActivation( - currentOffset: CGFloat, - visualTopOffset: CGFloat - ) -> Bool { - currentOffset <= visualTopOffset + 0.5 - } - private static func clamp(_ value: CGFloat, upperBound: CGFloat) -> CGFloat { min(max(value, 0), max(upperBound, 0)) } @@ -1156,7 +1149,6 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle targetOffsetY: CGFloat, allowDuringInteraction: Bool = false ) { - coordinator.visualTopContentOffsetY = targetOffsetY if alignmentState.needsInitialHeaderOffsetReset { alignmentState.markInitialOffsetApplied() } @@ -1174,13 +1166,6 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle cancelPendingTopAlignment() return } - guard VideoDetailPagerOffsetModel.shouldAlignToVisualTopAfterHorizontalActivation( - currentOffset: listScrollView.verticalContentOffsetExcludingBounce, - visualTopOffset: targetOffsetY - ) else { - cancelPendingTopAlignment() - return - } setNormalizedContentOffsetY(targetOffsetY) alignmentState.markInitialOffsetApplied() alignmentState.markFirstActiveAlignmentCompleted() From dcd775b35ffa2870ad8eb77db23641ad15ca69bc Mon Sep 17 00:00:00 2001 From: pgs666 <74764545+pgs666@users.noreply.github.com> Date: Sun, 7 Jun 2026 16:35:43 +0800 Subject: [PATCH 155/216] Resolve native pager alignment after content sizing --- iosApp/VideoDetailPager.swift | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/iosApp/VideoDetailPager.swift b/iosApp/VideoDetailPager.swift index 66146367..324ae36c 100644 --- a/iosApp/VideoDetailPager.swift +++ b/iosApp/VideoDetailPager.swift @@ -988,7 +988,16 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle abs(contentMinimumHeightConstraint.constant - offsetContext.minimumContentHeight) > 0.5 { contentMinimumHeightConstraint.constant = isNativeScrollView ? 1 : offsetContext.minimumContentHeight } - applyNativeMinimumContentSizeIfNeeded(page: page, offsetContext: offsetContext) + let didApplyNativeMinimumContentSize = applyNativeMinimumContentSizeIfNeeded( + page: page, + offsetContext: offsetContext + ) + if didApplyNativeMinimumContentSize, alignmentState.pendingTopAlignment != nil { + resolvePendingTopAlignmentIfPossible(allowDuringInteraction: true) + } + if didApplyNativeMinimumContentSize, alignmentState.needsInitialHeaderOffsetReset { + applyInitialHeaderOffsetResetIfNeeded(allowDuringInteraction: true) + } if alignmentState.pendingTopAlignment != nil { resolvePendingTopAlignmentIfPossible() return @@ -1044,21 +1053,23 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle tableView.tableFooterView = nativeBottomSpacerView } + @discardableResult private func applyNativeMinimumContentSizeIfNeeded( page: VideoDetailTabPage, offsetContext: VideoDetailListOffsetContext - ) { + ) -> Bool { guard case .nativeScrollView = page.content else { - return + return false } let requiredContentHeight = offsetContext.minimumListContentHeight guard listScrollView.contentSize.height < requiredContentHeight - 0.5 else { - return + return false } listScrollView.contentSize = CGSize( width: listScrollView.contentSize.width, height: requiredContentHeight ) + return true } private func applyListHeaderFrame() { From cd2bd09886e3f98060da6005118304f47af20d42 Mon Sep 17 00:00:00 2001 From: pgs666 <74764545+pgs666@users.noreply.github.com> Date: Sun, 7 Jun 2026 16:42:56 +0800 Subject: [PATCH 156/216] Apply native pager offsets directly --- iosApp/VideoDetailPager.swift | 26 ++++++++++++++++++++++++-- 1 file changed, 24 insertions(+), 2 deletions(-) diff --git a/iosApp/VideoDetailPager.swift b/iosApp/VideoDetailPager.swift index 324ae36c..3f7efa46 100644 --- a/iosApp/VideoDetailPager.swift +++ b/iosApp/VideoDetailPager.swift @@ -1167,7 +1167,7 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle guard !listScrollView.isTracking, !listScrollView.isDragging, !listScrollView.isDecelerating else { return } } if !alignmentState.hasCompletedFirstActiveAlignment { - guard setNormalizedContentOffsetYIfReachable(targetOffsetY) else { + guard setNormalizedContentOffsetYForAlignment(targetOffsetY) else { alignmentState.pendingTopAlignment = .initial resolvePendingTopAlignmentIfPossible() return @@ -1191,7 +1191,7 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle loadViewIfNeeded() cancelPendingTopAlignment() let syncOffsetY = syncMode.normalizedOffsetY - guard setNormalizedContentOffsetYIfReachable(syncOffsetY) else { + guard setNormalizedContentOffsetYForAlignment(syncOffsetY) else { alignmentState.pendingTopAlignment = .explicit(syncOffsetY) resolvePendingTopAlignmentIfPossible() return @@ -1224,6 +1224,20 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle setRawContentOffsetYIfNeeded(rawTopOffsetY) } + @discardableResult + private func setNormalizedContentOffsetYForAlignment(_ offsetY: CGFloat) -> Bool { + guard let page = lastAppliedPage else { return false } + if isNativeListPage { + let rawTopOffsetY = page.headerGeometry.rawContentOffsetY( + forNormalizedOffsetY: offsetY, + in: listScrollView + ) + setRawContentOffsetYIfNeeded(rawTopOffsetY) + return true + } + return setNormalizedContentOffsetYIfReachable(offsetY) + } + @discardableResult private func setNormalizedContentOffsetYIfReachable(_ offsetY: CGFloat) -> Bool { let rawTopOffsetY = clampedRawContentOffsetY(forNormalizedOffsetY: offsetY) @@ -1257,6 +1271,14 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle guard let page = lastAppliedPage else { return rawOffsetY + listScrollView.adjustedContentInset.top } return page.headerGeometry.normalizedContentOffsetY(forRawOffsetY: rawOffsetY, in: listScrollView) } + + private var isNativeListPage: Bool { + guard let page = lastAppliedPage else { return false } + if case .nativeScrollView = page.content { + return true + } + return false + } } private final class VerticalScrollView: UIScrollView { From 48cdd32d299a24b52f3d588038ab634193ce8b74 Mon Sep 17 00:00:00 2001 From: pgs666 <74764545+pgs666@users.noreply.github.com> Date: Sun, 7 Jun 2026 16:53:00 +0800 Subject: [PATCH 157/216] Route native pending alignment through direct offsets --- iosApp/VideoDetailPager.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/iosApp/VideoDetailPager.swift b/iosApp/VideoDetailPager.swift index 3f7efa46..e96924a7 100644 --- a/iosApp/VideoDetailPager.swift +++ b/iosApp/VideoDetailPager.swift @@ -1089,7 +1089,7 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle if !allowDuringInteraction { guard !listScrollView.isTracking, !listScrollView.isDragging, !listScrollView.isDecelerating else { return } } - guard setNormalizedContentOffsetYIfReachable(targetOffsetY) else { return } + guard setNormalizedContentOffsetYForAlignment(targetOffsetY) else { return } if abs(listScrollView.verticalContentOffsetExcludingBounce - targetOffsetY) <= 0.5 { alignmentState.markInitialOffsetApplied() alignmentState.cancelPendingTopAlignment() @@ -1107,7 +1107,7 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle let targetOffsetY = page.headerGeometry.listOffsetContext( in: listScrollView.bounds.height ).initialNormalizedOffsetY - guard setNormalizedContentOffsetYIfReachable(targetOffsetY) else { + guard setNormalizedContentOffsetYForAlignment(targetOffsetY) else { alignmentState.pendingTopAlignment = .initial resolvePendingTopAlignmentIfPossible() return From 3af83f806c157911b23e9ce010e8da78740e33cb Mon Sep 17 00:00:00 2001 From: pgs666 <74764545+pgs666@users.noreply.github.com> Date: Sun, 7 Jun 2026 17:03:32 +0800 Subject: [PATCH 158/216] Stabilize comments pager activation --- iosApp/CommentListTableView.swift | 71 +++++++++++++++++++++++++-- iosApp/VideoDetailPager.swift | 81 +------------------------------ iosApp/VideoDetailView.swift | 9 ++-- 3 files changed, 76 insertions(+), 85 deletions(-) diff --git a/iosApp/CommentListTableView.swift b/iosApp/CommentListTableView.swift index 6ef7241f..ca40b02f 100644 --- a/iosApp/CommentListTableView.swift +++ b/iosApp/CommentListTableView.swift @@ -88,12 +88,14 @@ final class CommentListTableController: NSObject, UITableViewDataSource, UITable case empty case comment(CommentRow) case footer + case bottomSpacer(CGFloat) } private(set) weak var tableView: UITableView? private var rows: [Row] = [] private var sortMode: CommentViewModel.SortMode = .mostLikes private var comments: [CommentRow] = [] + private var bottomSpacerHeight: CGFloat = 0 private var runningActionIDs: Set = [] private var onChangeSortMode: (CommentViewModel.SortMode) -> Void = { _ in } private var onRefresh: () -> Void = {} @@ -121,17 +123,20 @@ final class CommentListTableController: NSObject, UITableViewDataSource, UITable return tableView } - func update(_ model: CommentListTableModel) { + func update(_ model: CommentListTableModel, bottomSpacerHeight: CGFloat = 0) { let nextSignature = ModelSignature(model) let shouldReload = modelSignature != nextSignature let nextCommentActionSignatures = model.comments.map(CommentActionSignature.init) let didChangeCommentActions = commentActionSignatures != nextCommentActionSignatures let didChangeRunningActions = previousRunningActionIDs != model.runningActionIDs + let resolvedBottomSpacerHeight = max(bottomSpacerHeight, 0) + let didChangeBottomSpacerHeight = abs(self.bottomSpacerHeight - resolvedBottomSpacerHeight) > 0.5 modelSignature = nextSignature commentActionSignatures = nextCommentActionSignatures previousRunningActionIDs = model.runningActionIDs sortMode = model.sortMode comments = model.comments + self.bottomSpacerHeight = resolvedBottomSpacerHeight runningActionIDs = model.runningActionIDs onChangeSortMode = model.onChangeSortMode onRefresh = model.onRefresh @@ -150,6 +155,17 @@ final class CommentListTableController: NSObject, UITableViewDataSource, UITable } else if didChangeRunningActions || didChangeCommentActions { rows = rows(for: model) updateVisibleCommentRows() + } else if didChangeBottomSpacerHeight { + let previousRowCount = rows.count + rows = rows(for: model) + if rows.count != previousRowCount { + tableView?.reloadData() + } else { + UIView.performWithoutAnimation { + tableView?.beginUpdates() + tableView?.endUpdates() + } + } } return } @@ -216,6 +232,13 @@ final class CommentListTableController: NSObject, UITableViewDataSource, UITable ) as? CommentListFooterCell ?? CommentListFooterCell(style: .default, reuseIdentifier: CommentListFooterCell.reuseIdentifier) cell.configure() return cell + case .bottomSpacer(let height): + let cell = tableView.dequeueReusableCell( + withIdentifier: CommentListBottomSpacerCell.reuseIdentifier, + for: indexPath + ) as? CommentListBottomSpacerCell ?? CommentListBottomSpacerCell(style: .default, reuseIdentifier: CommentListBottomSpacerCell.reuseIdentifier) + cell.configure(height: height) + return cell case .comment(let comment): let cell = tableView.dequeueReusableCell( withIdentifier: HostingCommentTableViewCell.reuseIdentifier, @@ -234,6 +257,22 @@ final class CommentListTableController: NSObject, UITableViewDataSource, UITable } } + func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat { + guard indexPath.row < rows.count else { return UITableView.automaticDimension } + if case .bottomSpacer(let height) = rows[indexPath.row] { + return max(height, 0) + } + return UITableView.automaticDimension + } + + func tableView(_ tableView: UITableView, estimatedHeightForRowAt indexPath: IndexPath) -> CGFloat { + guard indexPath.row < rows.count else { return UITableView.automaticDimension } + if case .bottomSpacer(let height) = rows[indexPath.row] { + return max(height, 0) + } + return UITableView.automaticDimension + } + func scrollViewDidScroll(_ scrollView: UIScrollView) { scrollDelegate?.scrollViewDidScroll?(scrollView) } @@ -267,6 +306,7 @@ final class CommentListTableController: NSObject, UITableViewDataSource, UITable tableView.contentInsetAdjustmentBehavior = .never tableView.register(HostingCommentTableViewCell.self, forCellReuseIdentifier: HostingCommentTableViewCell.reuseIdentifier) tableView.register(CommentListFooterCell.self, forCellReuseIdentifier: CommentListFooterCell.reuseIdentifier) + tableView.register(CommentListBottomSpacerCell.self, forCellReuseIdentifier: CommentListBottomSpacerCell.reuseIdentifier) } private func rows(for model: CommentListTableModel) -> [Row] { @@ -277,12 +317,17 @@ final class CommentListTableController: NSObject, UITableViewDataSource, UITable return [.controls, .failed(message)] case .loaded: guard !model.comments.isEmpty else { - return [.controls, .empty] + return rowsWithBottomSpacer([.controls, .empty]) } - return [.controls] + model.comments.map { .comment($0) } + [.footer] + return rowsWithBottomSpacer([.controls] + model.comments.map { .comment($0) } + [.footer]) } } + private func rowsWithBottomSpacer(_ baseRows: [Row]) -> [Row] { + guard bottomSpacerHeight > 0.5 else { return baseRows } + return baseRows + [.bottomSpacer(bottomSpacerHeight)] + } + private func updateVisibleCommentRows() { guard let tableView else { return } for indexPath in tableView.indexPathsForVisibleRows ?? [] { @@ -489,3 +534,23 @@ private final class CommentListFooterCell: UITableViewCell { .margins(.all, 0) } } + +private final class CommentListBottomSpacerCell: UITableViewCell { + static let reuseIdentifier = "CommentListBottomSpacerCell" + + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + super.init(style: style, reuseIdentifier: reuseIdentifier) + backgroundColor = .clear + contentView.backgroundColor = .clear + selectionStyle = .none + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func configure(height _: CGFloat) { + contentConfiguration = nil + } +} diff --git a/iosApp/VideoDetailPager.swift b/iosApp/VideoDetailPager.swift index e96924a7..6c5328d5 100644 --- a/iosApp/VideoDetailPager.swift +++ b/iosApp/VideoDetailPager.swift @@ -368,7 +368,6 @@ private struct VideoDetailListAlignmentState { var pendingTopAlignment: VideoDetailPendingTopAlignment? var needsInitialHeaderOffsetReset = true var hasAppliedInitialListOffset = false - var hasCompletedFirstActiveAlignment = false var hasExplicitPendingTopAlignment: Bool { guard let pendingTopAlignment else { return false } @@ -382,18 +381,10 @@ private struct VideoDetailListAlignmentState { pendingTopAlignment = nil } - mutating func resetForContentUpdate() { - hasCompletedFirstActiveAlignment = false - } - mutating func markInitialOffsetApplied() { needsInitialHeaderOffsetReset = false hasAppliedInitialListOffset = true } - - mutating func markFirstActiveAlignmentCompleted() { - hasCompletedFirstActiveAlignment = true - } } private struct VideoDetailHorizontalPagerPosition: Equatable { @@ -751,7 +742,6 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle private let listHeaderView = UIView() private let collapseSpacerView = UIView() private let contentBottomSpacerView = UIView() - private let nativeBottomSpacerView = UIView() private let host = UIHostingController(rootView: AnyView(EmptyView())) private var hostMinimumHeightConstraint: NSLayoutConstraint? private var contentMinimumHeightConstraint: NSLayoutConstraint? @@ -892,9 +882,6 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle override func viewDidLayoutSubviews() { super.viewDidLayoutSubviews() applyListHeaderFrame() - if usesNativeListScrollView, let page = lastAppliedPage { - applyNativeBottomSpacer(page.contentBottomPadding) - } handleScrollGeometryChange() } @@ -935,7 +922,6 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle host.rootView = AnyView(EmptyView()) } } - alignmentState.resetForContentUpdate() if !alignmentState.hasAppliedInitialListOffset && !alignmentState.hasExplicitPendingTopAlignment { alignmentState.needsInitialHeaderOffsetReset = true } @@ -1002,10 +988,6 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle resolvePendingTopAlignmentIfPossible() return } - if page.isSelected, !alignmentState.hasCompletedFirstActiveAlignment { - applyFirstActiveAlignmentIfNeeded() - return - } if alignmentState.needsInitialHeaderOffsetReset { applyInitialHeaderOffsetResetIfNeeded() } @@ -1033,24 +1015,6 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle if abs(listScrollView.verticalScrollIndicatorInsets.bottom - resolvedBottomSpacing) > 0.5 { listScrollView.verticalScrollIndicatorInsets.bottom = resolvedBottomSpacing } - if !usesContentSpacer { - applyNativeBottomSpacer(resolvedBottomSpacing) - } - } - - private func applyNativeBottomSpacer(_ bottomSpacing: CGFloat) { - guard let tableView = listScrollView as? UITableView else { return } - let width = max(tableView.bounds.width, 1) - let nextFrame = CGRect(x: 0, y: 0, width: width, height: bottomSpacing) - nativeBottomSpacerView.backgroundColor = .clear - if tableView.tableFooterView !== nativeBottomSpacerView { - nativeBottomSpacerView.frame = nextFrame - tableView.tableFooterView = nativeBottomSpacerView - return - } - guard !nativeBottomSpacerView.frame.isApproximatelyEqual(to: nextFrame) else { return } - nativeBottomSpacerView.frame = nextFrame - tableView.tableFooterView = nativeBottomSpacerView } @discardableResult @@ -1093,9 +1057,6 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle if abs(listScrollView.verticalContentOffsetExcludingBounce - targetOffsetY) <= 0.5 { alignmentState.markInitialOffsetApplied() alignmentState.cancelPendingTopAlignment() - if lastAppliedPage?.isSelected == true { - alignmentState.markFirstActiveAlignmentCompleted() - } } } @@ -1139,47 +1100,9 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle func settleAfterHorizontalActivation(targetOffsetY: CGFloat) { loadViewIfNeeded() - applyFirstActiveAlignmentIfNeeded( - targetOffsetY: targetOffsetY, - allowDuringInteraction: true - ) - } - - private func applyFirstActiveAlignmentIfNeeded(allowDuringInteraction: Bool = false) { - guard let page = lastAppliedPage else { return } - let targetOffsetY = page.headerGeometry.listOffsetContext( - in: listScrollView.bounds.height - ).initialNormalizedOffsetY - applyFirstActiveAlignmentIfNeeded( - targetOffsetY: targetOffsetY, - allowDuringInteraction: allowDuringInteraction - ) - } - - private func applyFirstActiveAlignmentIfNeeded( - targetOffsetY: CGFloat, - allowDuringInteraction: Bool = false - ) { - if alignmentState.needsInitialHeaderOffsetReset { - alignmentState.markInitialOffsetApplied() - } - if !allowDuringInteraction { - guard !listScrollView.isTracking, !listScrollView.isDragging, !listScrollView.isDecelerating else { return } - } - if !alignmentState.hasCompletedFirstActiveAlignment { - guard setNormalizedContentOffsetYForAlignment(targetOffsetY) else { - alignmentState.pendingTopAlignment = .initial - resolvePendingTopAlignmentIfPossible() - return - } - alignmentState.markInitialOffsetApplied() - alignmentState.markFirstActiveAlignmentCompleted() - cancelPendingTopAlignment() - return - } - setNormalizedContentOffsetY(targetOffsetY) + setNormalizedContentOffsetYForAlignment(targetOffsetY) alignmentState.markInitialOffsetApplied() - alignmentState.markFirstActiveAlignmentCompleted() + cancelPendingTopAlignment() } var normalizedContentOffsetY: CGFloat { diff --git a/iosApp/VideoDetailView.swift b/iosApp/VideoDetailView.swift index 5386e7d0..79bf78f9 100644 --- a/iosApp/VideoDetailView.swift +++ b/iosApp/VideoDetailView.swift @@ -10,8 +10,8 @@ private final class NativeCommentListHolder: ObservableObject { tableView = controller.makeTableView() } - func update(_ model: CommentListTableModel) { - controller.update(model) + func update(_ model: CommentListTableModel, bottomSpacerHeight: CGFloat) { + controller.update(model, bottomSpacerHeight: bottomSpacerHeight) } func attachScrollDelegate(_ delegate: UIScrollViewDelegate?) { @@ -473,7 +473,10 @@ struct VideoDetailView: View { nativeCommentList.attachScrollDelegate(delegate) }, nativeCommentsUpdate: { - nativeCommentList.update(commentTableModel) + nativeCommentList.update( + commentTableModel, + bottomSpacerHeight: composerContentClearance + ) } ) } From a1ef34aee2185c8aae3d699ad46bb0666f249a30 Mon Sep 17 00:00:00 2001 From: pgs666 <74764545+pgs666@users.noreply.github.com> Date: Sun, 7 Jun 2026 17:13:02 +0800 Subject: [PATCH 159/216] Simplify smooth pager horizontal state --- iosApp/VideoDetailPager.swift | 94 ++++++++++++++--------------------- 1 file changed, 36 insertions(+), 58 deletions(-) diff --git a/iosApp/VideoDetailPager.swift b/iosApp/VideoDetailPager.swift index 6c5328d5..02af81ce 100644 --- a/iosApp/VideoDetailPager.swift +++ b/iosApp/VideoDetailPager.swift @@ -389,7 +389,6 @@ private struct VideoDetailListAlignmentState { private struct VideoDetailHorizontalPagerPosition: Equatable { private(set) var selectedIndex = 0 - private(set) var visibleIndex = 0 private(set) var settledIndex: Int? private(set) var isPagingActive = false @@ -399,31 +398,17 @@ private struct VideoDetailHorizontalPagerPosition: Equatable { mutating func setSelectedIndex(_ index: Int) { selectedIndex = clamped(index) - if !isPagingActive { - visibleIndex = selectedIndex - } - } - - mutating func setVisibleIndex(_ index: Int) -> Bool { - let nextIndex = clamped(index) - guard visibleIndex != nextIndex else { return false } - visibleIndex = nextIndex - return true } - mutating func setPagingActive(_ isActive: Bool, settledIndex: Int?) -> Bool { + mutating func setPagingActive(_ isActive: Bool) -> Bool { guard isPagingActive != isActive else { return false } isPagingActive = isActive - if !isActive, let settledIndex { - visibleIndex = clamped(settledIndex) - } return true } mutating func markSettled(_ index: Int) -> Bool { let nextIndex = clamped(index) selectedIndex = nextIndex - visibleIndex = nextIndex guard settledIndex != nextIndex else { return false } settledIndex = nextIndex return true @@ -979,7 +964,7 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle offsetContext: offsetContext ) if didApplyNativeMinimumContentSize, alignmentState.pendingTopAlignment != nil { - resolvePendingTopAlignmentIfPossible(allowDuringInteraction: true) + applyNativePendingTopAlignmentIfNeeded() } if didApplyNativeMinimumContentSize, alignmentState.needsInitialHeaderOffsetReset { applyInitialHeaderOffsetResetIfNeeded(allowDuringInteraction: true) @@ -1050,6 +1035,12 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle private func resolvePendingTopAlignmentIfPossible(allowDuringInteraction: Bool = false) { guard let targetOffsetY = pendingTopAlignmentTargetOffsetY() else { return } + if isNativeListPage { + setNormalizedContentOffsetY(targetOffsetY) + alignmentState.markInitialOffsetApplied() + alignmentState.cancelPendingTopAlignment() + return + } if !allowDuringInteraction { guard !listScrollView.isTracking, !listScrollView.isDragging, !listScrollView.isDecelerating else { return } } @@ -1068,6 +1059,12 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle let targetOffsetY = page.headerGeometry.listOffsetContext( in: listScrollView.bounds.height ).initialNormalizedOffsetY + if isNativeListPage { + setNormalizedContentOffsetY(targetOffsetY) + alignmentState.markInitialOffsetApplied() + alignmentState.cancelPendingTopAlignment() + return + } guard setNormalizedContentOffsetYForAlignment(targetOffsetY) else { alignmentState.pendingTopAlignment = .initial resolvePendingTopAlignmentIfPossible() @@ -1098,9 +1095,16 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle alignmentState.hasExplicitPendingTopAlignment } + private func applyNativePendingTopAlignmentIfNeeded() { + guard isNativeListPage, let targetOffsetY = pendingTopAlignmentTargetOffsetY() else { return } + setNormalizedContentOffsetY(targetOffsetY) + alignmentState.markInitialOffsetApplied() + alignmentState.cancelPendingTopAlignment() + } + func settleAfterHorizontalActivation(targetOffsetY: CGFloat) { loadViewIfNeeded() - setNormalizedContentOffsetYForAlignment(targetOffsetY) + setNormalizedContentOffsetY(targetOffsetY) alignmentState.markInitialOffsetApplied() cancelPendingTopAlignment() } @@ -1114,6 +1118,11 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle loadViewIfNeeded() cancelPendingTopAlignment() let syncOffsetY = syncMode.normalizedOffsetY + if isNativeListPage { + setNormalizedContentOffsetY(syncOffsetY) + alignmentState.markInitialOffsetApplied() + return + } guard setNormalizedContentOffsetYForAlignment(syncOffsetY) else { alignmentState.pendingTopAlignment = .explicit(syncOffsetY) resolvePendingTopAlignmentIfPossible() @@ -1149,15 +1158,6 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle @discardableResult private func setNormalizedContentOffsetYForAlignment(_ offsetY: CGFloat) -> Bool { - guard let page = lastAppliedPage else { return false } - if isNativeListPage { - let rawTopOffsetY = page.headerGeometry.rawContentOffsetY( - forNormalizedOffsetY: offsetY, - in: listScrollView - ) - setRawContentOffsetYIfNeeded(rawTopOffsetY) - return true - } return setNormalizedContentOffsetYIfReachable(offsetY) } @@ -1308,7 +1308,6 @@ private struct VideoDetailTabPager: UIViewControllerRepresentable { var selectedTab: Binding var onSelectedIndexSettled: ((Int) -> Void)? var onPagingActivityChanged: ((Bool) -> Void)? - var onHorizontalVisibleIndexChanged: ((Int) -> Void)? private var lastProgrammaticIndex: Int? init(selectedTab: Binding) { @@ -1328,8 +1327,10 @@ private struct VideoDetailTabPager: UIViewControllerRepresentable { func scrollViewDidScroll(_ listScrollView: UIScrollView) { let width = listScrollView.bounds.width guard width > 0 else { return } - let index = Int(listScrollView.contentOffset.x / width) - onHorizontalVisibleIndexChanged?(min(max(index, 0), VideoPageTab.allCases.count - 1)) + guard abs(listScrollView.contentOffset.x - CGFloat(selectedTab.wrappedValue.pageIndex) * width) > 0.5 else { + return + } + onPagingActivityChanged?(true) } func scrollViewDidEndDecelerating(_ listScrollView: UIScrollView) { @@ -1396,7 +1397,7 @@ private struct VideoDetailTabPager: UIViewControllerRepresentable { activeOffset: 0, collapseDistance: 0 ) - private var pendingHorizontalSettledIndex: Int? + private var isFinishingHorizontalSelection = false private var latestPages: [VideoPageTab: VideoDetailTabPage] = [:] init(coordinator: Coordinator) { @@ -1436,9 +1437,6 @@ private struct VideoDetailTabPager: UIViewControllerRepresentable { coordinator.onPagingActivityChanged = { [weak self] isActive in self?.setHorizontalPagingActive(isActive) } - coordinator.onHorizontalVisibleIndexChanged = { [weak self] index in - self?.setHeaderVisibleIndex(index) - } view.addSubview(scrollView) contentView.translatesAutoresizingMaskIntoConstraints = false @@ -1569,11 +1567,11 @@ private struct VideoDetailTabPager: UIViewControllerRepresentable { private func settlePageAfterHorizontalSelection(_ index: Int) { let clampedIndex = min(max(index, 0), VideoPageTab.allCases.count - 1) - pendingHorizontalSettledIndex = clampedIndex + isFinishingHorizontalSelection = true if pagerPosition.isPagingActive { setHorizontalPagingActive(false) } - pendingHorizontalSettledIndex = nil + isFinishingHorizontalSelection = false finishHorizontalPaging(at: clampedIndex) } @@ -1608,35 +1606,15 @@ private struct VideoDetailTabPager: UIViewControllerRepresentable { } private func setHorizontalPagingActive(_ isActive: Bool) { - let settledIndex = pendingHorizontalSettledIndex ?? settledIndexFromHorizontalOffset() - guard pagerPosition.setPagingActive( - isActive, - settledIndex: isActive ? nil : settledIndex - ) else { return } + guard pagerPosition.setPagingActive(isActive) else { return } introductionPage.setHorizontalPagingActive(isActive) commentsPage?.setHorizontalPagingActive(isActive) updateHeaderAttachmentForCurrentState() - if !isActive, pendingHorizontalSettledIndex == nil { + if !isActive, !isFinishingHorizontalSelection { syncInactivePageHeaderOffset() } } - private func setHeaderVisibleIndex(_ index: Int) { - guard pagerPosition.setVisibleIndex(index) else { return } - guard !pagerPosition.isPagingActive else { - attachActiveHeaderToPagerContainer() - return - } - updateHeaderAttachmentForCurrentState() - } - - private func settledIndexFromHorizontalOffset() -> Int { - let width = scrollView.bounds.width - guard width > 0 else { return pagerPosition.selectedIndex } - let index = Int(round(scrollView.contentOffset.x / width)) - return min(max(index, 0), VideoPageTab.allCases.count - 1) - } - private func syncInactivePageHeaderOffset(activeOffset providedActiveOffset: CGFloat? = nil) { guard let activePage = verticalPageController(for: VideoPageTab.page(at: pagerPosition.selectedIndex)) else { return From d78b2ed6b5ede340732eb84de5808cb0d4c90cfe Mon Sep 17 00:00:00 2001 From: pgs666 <74764545+pgs666@users.noreply.github.com> Date: Sun, 7 Jun 2026 17:21:34 +0800 Subject: [PATCH 160/216] Direct native list pager offsets --- iosApp/VideoDetailPager.swift | 73 +++++++++++++++++++++++------------ 1 file changed, 49 insertions(+), 24 deletions(-) diff --git a/iosApp/VideoDetailPager.swift b/iosApp/VideoDetailPager.swift index 02af81ce..4067b8ee 100644 --- a/iosApp/VideoDetailPager.swift +++ b/iosApp/VideoDetailPager.swift @@ -927,16 +927,17 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle collapseSpacerHeightConstraint?.constant = offsetContext.collapseSpacerHeight contentMinimumHeightConstraint?.constant = offsetContext.minimumContentHeight hostMinimumHeightConstraint?.isActive = true + if alignmentState.needsInitialHeaderOffsetReset { + applyInitialHeaderOffsetResetIfNeeded() + } else if alignmentState.pendingTopAlignment != nil { + resolvePendingTopAlignmentIfPossible() + } case .nativeScrollView: applyBottomContentSpacing(page.contentBottomPadding, usesContentSpacer: false) collapseSpacerHeightConstraint?.constant = 0 contentMinimumHeightConstraint?.constant = 1 hostMinimumHeightConstraint?.isActive = false - } - if alignmentState.needsInitialHeaderOffsetReset { - applyInitialHeaderOffsetResetIfNeeded() - } else if alignmentState.pendingTopAlignment != nil { - resolvePendingTopAlignmentIfPossible() + applyNativeInitialOffsetIfNeeded(offsetContext.initialNormalizedOffsetY) } } @@ -963,18 +964,20 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle page: page, offsetContext: offsetContext ) - if didApplyNativeMinimumContentSize, alignmentState.pendingTopAlignment != nil { - applyNativePendingTopAlignmentIfNeeded() - } - if didApplyNativeMinimumContentSize, alignmentState.needsInitialHeaderOffsetReset { - applyInitialHeaderOffsetResetIfNeeded(allowDuringInteraction: true) - } - if alignmentState.pendingTopAlignment != nil { - resolvePendingTopAlignmentIfPossible() - return - } - if alignmentState.needsInitialHeaderOffsetReset { - applyInitialHeaderOffsetResetIfNeeded() + if isNativeScrollView { + if didApplyNativeMinimumContentSize { + applyNativeAlignmentAfterContentSizeChange(offsetContext.initialNormalizedOffsetY) + } else { + applyNativeInitialOffsetIfNeeded(offsetContext.initialNormalizedOffsetY) + } + } else { + if alignmentState.pendingTopAlignment != nil { + resolvePendingTopAlignmentIfPossible() + return + } + if alignmentState.needsInitialHeaderOffsetReset { + applyInitialHeaderOffsetResetIfNeeded() + } } } @@ -1036,7 +1039,7 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle private func resolvePendingTopAlignmentIfPossible(allowDuringInteraction: Bool = false) { guard let targetOffsetY = pendingTopAlignmentTargetOffsetY() else { return } if isNativeListPage { - setNormalizedContentOffsetY(targetOffsetY) + setNativeNormalizedContentOffsetY(targetOffsetY) alignmentState.markInitialOffsetApplied() alignmentState.cancelPendingTopAlignment() return @@ -1060,7 +1063,7 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle in: listScrollView.bounds.height ).initialNormalizedOffsetY if isNativeListPage { - setNormalizedContentOffsetY(targetOffsetY) + setNativeNormalizedContentOffsetY(targetOffsetY) alignmentState.markInitialOffsetApplied() alignmentState.cancelPendingTopAlignment() return @@ -1095,16 +1098,29 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle alignmentState.hasExplicitPendingTopAlignment } - private func applyNativePendingTopAlignmentIfNeeded() { - guard isNativeListPage, let targetOffsetY = pendingTopAlignmentTargetOffsetY() else { return } - setNormalizedContentOffsetY(targetOffsetY) + private func applyNativeInitialOffsetIfNeeded(_ targetOffsetY: CGFloat) { + guard isNativeListPage, alignmentState.needsInitialHeaderOffsetReset else { return } + setNativeNormalizedContentOffsetY(targetOffsetY) + alignmentState.markInitialOffsetApplied() + alignmentState.cancelPendingTopAlignment() + } + + private func applyNativeAlignmentAfterContentSizeChange(_ initialOffsetY: CGFloat) { + guard isNativeListPage else { return } + let targetOffsetY = pendingTopAlignmentTargetOffsetY() ?? (alignmentState.needsInitialHeaderOffsetReset ? initialOffsetY : nil) + guard let targetOffsetY else { return } + setNativeNormalizedContentOffsetY(targetOffsetY) alignmentState.markInitialOffsetApplied() alignmentState.cancelPendingTopAlignment() } func settleAfterHorizontalActivation(targetOffsetY: CGFloat) { loadViewIfNeeded() - setNormalizedContentOffsetY(targetOffsetY) + if isNativeListPage { + setNativeNormalizedContentOffsetY(targetOffsetY) + } else { + setNormalizedContentOffsetY(targetOffsetY) + } alignmentState.markInitialOffsetApplied() cancelPendingTopAlignment() } @@ -1119,7 +1135,7 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle cancelPendingTopAlignment() let syncOffsetY = syncMode.normalizedOffsetY if isNativeListPage { - setNormalizedContentOffsetY(syncOffsetY) + setNativeNormalizedContentOffsetY(syncOffsetY) alignmentState.markInitialOffsetApplied() return } @@ -1156,6 +1172,15 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle setRawContentOffsetYIfNeeded(rawTopOffsetY) } + private func setNativeNormalizedContentOffsetY(_ offsetY: CGFloat) { + guard isNativeListPage else { + setNormalizedContentOffsetY(offsetY) + return + } + let rawTopOffsetY = offsetY - listScrollView.adjustedContentInset.top + setRawContentOffsetYIfNeeded(rawTopOffsetY) + } + @discardableResult private func setNormalizedContentOffsetYForAlignment(_ offsetY: CGFloat) -> Bool { return setNormalizedContentOffsetYIfReachable(offsetY) From ff001569b30a00e0c1007c55fbeb66baefe94e2f Mon Sep 17 00:00:00 2001 From: pgs666 <74764545+pgs666@users.noreply.github.com> Date: Sun, 7 Jun 2026 17:31:26 +0800 Subject: [PATCH 161/216] Separate native pager geometry alignment --- iosApp/VideoDetailPager.swift | 37 ++++++++++++++++++++++------------- 1 file changed, 23 insertions(+), 14 deletions(-) diff --git a/iosApp/VideoDetailPager.swift b/iosApp/VideoDetailPager.swift index 4067b8ee..54895a17 100644 --- a/iosApp/VideoDetailPager.swift +++ b/iosApp/VideoDetailPager.swift @@ -943,7 +943,6 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle private func handleScrollGeometryChange() { applyCurrentPageGeometryRules() - resolvePendingTopAlignmentIfPossible() } private func applyCurrentPageGeometryRules() { @@ -965,19 +964,18 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle offsetContext: offsetContext ) if isNativeScrollView { - if didApplyNativeMinimumContentSize { - applyNativeAlignmentAfterContentSizeChange(offsetContext.initialNormalizedOffsetY) - } else { - applyNativeInitialOffsetIfNeeded(offsetContext.initialNormalizedOffsetY) - } - } else { - if alignmentState.pendingTopAlignment != nil { - resolvePendingTopAlignmentIfPossible() - return - } - if alignmentState.needsInitialHeaderOffsetReset { - applyInitialHeaderOffsetResetIfNeeded() - } + applyNativeGeometryAlignment( + initialOffsetY: offsetContext.initialNormalizedOffsetY, + didApplyMinimumContentSize: didApplyNativeMinimumContentSize + ) + return + } + if alignmentState.pendingTopAlignment != nil { + resolvePendingTopAlignmentIfPossible() + return + } + if alignmentState.needsInitialHeaderOffsetReset { + applyInitialHeaderOffsetResetIfNeeded() } } @@ -1105,6 +1103,17 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle alignmentState.cancelPendingTopAlignment() } + private func applyNativeGeometryAlignment( + initialOffsetY: CGFloat, + didApplyMinimumContentSize: Bool + ) { + if didApplyMinimumContentSize { + applyNativeAlignmentAfterContentSizeChange(initialOffsetY) + } else { + applyNativeInitialOffsetIfNeeded(initialOffsetY) + } + } + private func applyNativeAlignmentAfterContentSizeChange(_ initialOffsetY: CGFloat) { guard isNativeListPage else { return } let targetOffsetY = pendingTopAlignmentTargetOffsetY() ?? (alignmentState.needsInitialHeaderOffsetReset ? initialOffsetY : nil) From 393347d81e5e68b71987644282c45efb5428f03e Mon Sep 17 00:00:00 2001 From: pgs666 <74764545+pgs666@users.noreply.github.com> Date: Sun, 7 Jun 2026 17:39:27 +0800 Subject: [PATCH 162/216] Stabilize comment row height estimates --- iosApp/CommentListTableView.swift | 35 +++++++++++++++++++++++++++++-- 1 file changed, 33 insertions(+), 2 deletions(-) diff --git a/iosApp/CommentListTableView.swift b/iosApp/CommentListTableView.swift index ca40b02f..783f89b6 100644 --- a/iosApp/CommentListTableView.swift +++ b/iosApp/CommentListTableView.swift @@ -81,6 +81,25 @@ final class CommentListTableController: NSObject, UITableViewDataSource, UITable } } + private enum RowHeightEstimate { + static let controls: CGFloat = 61 + static let loading: CGFloat = 120 + static let failed: CGFloat = 230 + static let empty: CGFloat = 180 + static let footer: CGFloat = 44 + static let commentMinimum: CGFloat = 118 + static let commentLineHeight: CGFloat = 22 + static let commentCharactersPerLine: CGFloat = 22 + + static func comment(_ comment: CommentRow) -> CGFloat { + let normalizedLength = max(comment.content.count, 1) + let estimatedLines = ceil(CGFloat(normalizedLength) / commentCharactersPerLine) + let replyHeight: CGFloat = comment.hasMoreReplies ? 18 : 0 + let childReduction: CGFloat = comment.isChildComment ? 8 : 0 + return max(commentMinimum - childReduction, 92 + estimatedLines * commentLineHeight + replyHeight) + } + } + private enum Row { case controls case loading @@ -267,10 +286,22 @@ final class CommentListTableController: NSObject, UITableViewDataSource, UITable func tableView(_ tableView: UITableView, estimatedHeightForRowAt indexPath: IndexPath) -> CGFloat { guard indexPath.row < rows.count else { return UITableView.automaticDimension } - if case .bottomSpacer(let height) = rows[indexPath.row] { + switch rows[indexPath.row] { + case .controls: + return RowHeightEstimate.controls + case .loading: + return RowHeightEstimate.loading + case .failed: + return RowHeightEstimate.failed + case .empty: + return RowHeightEstimate.empty + case .comment(let comment): + return RowHeightEstimate.comment(comment) + case .footer: + return RowHeightEstimate.footer + case .bottomSpacer(let height): return max(height, 0) } - return UITableView.automaticDimension } func scrollViewDidScroll(_ scrollView: UIScrollView) { From dbb96a00af486e59c882a56538f18c433c59b0bc Mon Sep 17 00:00:00 2001 From: pgs666 <74764545+pgs666@users.noreply.github.com> Date: Sun, 7 Jun 2026 17:53:18 +0800 Subject: [PATCH 163/216] Align comment pager with smooth header model --- iosApp/CommentListTableView.swift | 23 +++++++- iosApp/VideoDetailPager.swift | 96 +++++++++++++++++++++++++------ 2 files changed, 97 insertions(+), 22 deletions(-) diff --git a/iosApp/CommentListTableView.swift b/iosApp/CommentListTableView.swift index 783f89b6..52570e2d 100644 --- a/iosApp/CommentListTableView.swift +++ b/iosApp/CommentListTableView.swift @@ -127,6 +127,7 @@ final class CommentListTableController: NSObject, UITableViewDataSource, UITable private var modelSignature: ModelSignature? private var commentActionSignatures: [CommentActionSignature] = [] private var previousRunningActionIDs: Set = [] + private var hasRenderedRows = false weak var scrollDelegate: UIScrollViewDelegate? func attach(_ tableView: UITableView) { @@ -170,7 +171,7 @@ final class CommentListTableController: NSObject, UITableViewDataSource, UITable guard shouldReload else { if shouldReloadForActionChange { rows = rows(for: model) - tableView?.reloadData() + reloadTablePreservingOffset() } else if didChangeRunningActions || didChangeCommentActions { rows = rows(for: model) updateVisibleCommentRows() @@ -178,7 +179,7 @@ final class CommentListTableController: NSObject, UITableViewDataSource, UITable let previousRowCount = rows.count rows = rows(for: model) if rows.count != previousRowCount { - tableView?.reloadData() + reloadTablePreservingOffset() } else { UIView.performWithoutAnimation { tableView?.beginUpdates() @@ -189,7 +190,7 @@ final class CommentListTableController: NSObject, UITableViewDataSource, UITable return } rows = rows(for: model) - tableView?.reloadData() + reloadTablePreservingOffset() } func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { @@ -378,6 +379,22 @@ final class CommentListTableController: NSObject, UITableViewDataSource, UITable ) } } + + private func reloadTablePreservingOffset() { + guard let tableView else { return } + guard hasRenderedRows else { + tableView.reloadData() + tableView.layoutIfNeeded() + hasRenderedRows = true + return + } + let previousOffset = tableView.contentOffset + UIView.performWithoutAnimation { + tableView.reloadData() + tableView.layoutIfNeeded() + tableView.setContentOffset(previousOffset, animated: false) + } + } } private final class HostingCommentTableViewCell: UITableViewCell { diff --git a/iosApp/VideoDetailPager.swift b/iosApp/VideoDetailPager.swift index 54895a17..66baa936 100644 --- a/iosApp/VideoDetailPager.swift +++ b/iosApp/VideoDetailPager.swift @@ -56,9 +56,9 @@ enum VideoDetailPagerOffsetModel { static func minimumListContentHeight( scrollBoundsHeight: CGFloat, - pinHeaderHeight: CGFloat + pinnedVisibleHeight: CGFloat ) -> CGFloat { - max(scrollBoundsHeight - max(pinHeaderHeight, 0), 1) + max(scrollBoundsHeight - max(pinnedVisibleHeight, 0), 1) } private static func clamp(_ value: CGFloat, upperBound: CGFloat) -> CGFloat { @@ -98,14 +98,13 @@ private enum VideoDetailHeaderAttachmentState: Equatable { static func state( isHorizontalPagingActive: Bool, - selectedOffset: CGFloat, - syncState: VideoDetailSmoothHeaderSyncState, - collapseDistance: CGFloat + canAttachToListHeader: Bool, + syncState: VideoDetailSmoothHeaderSyncState ) -> VideoDetailHeaderAttachmentState { if isHorizontalPagingActive { return .pagerContainer(syncState.headerContainerY) } - if selectedOffset <= collapseDistance + 0.5 { + if canAttachToListHeader { return .listHeader } return .pagerContainer(syncState.headerContainerY) @@ -321,7 +320,7 @@ private struct VideoDetailSmoothHeaderGeometry: Equatable { func minimumListContentHeight(in scrollBoundsHeight: CGFloat) -> CGFloat { VideoDetailPagerOffsetModel.minimumListContentHeight( scrollBoundsHeight: scrollBoundsHeight, - pinHeaderHeight: pinHeaderHeight + pinnedVisibleHeight: pinnedVisibleHeight ) } @@ -368,6 +367,8 @@ private struct VideoDetailListAlignmentState { var pendingTopAlignment: VideoDetailPendingTopAlignment? var needsInitialHeaderOffsetReset = true var hasAppliedInitialListOffset = false + var maintainsNativeInitialAlignment = false + var nativeMaintainedAlignmentOffsetY: CGFloat? var hasExplicitPendingTopAlignment: Bool { guard let pendingTopAlignment else { return false } @@ -385,6 +386,24 @@ private struct VideoDetailListAlignmentState { needsInitialHeaderOffsetReset = false hasAppliedInitialListOffset = true } + + mutating func requestInitialOffsetReset(maintainNativeAlignment: Bool) { + needsInitialHeaderOffsetReset = true + if maintainNativeAlignment { + maintainsNativeInitialAlignment = true + nativeMaintainedAlignmentOffsetY = nil + } + } + + mutating func maintainNativeAlignment(offsetY: CGFloat) { + maintainsNativeInitialAlignment = true + nativeMaintainedAlignmentOffsetY = offsetY + } + + mutating func stopMaintainingNativeAlignment() { + maintainsNativeInitialAlignment = false + nativeMaintainedAlignmentOffsetY = nil + } } private struct VideoDetailHorizontalPagerPosition: Equatable { @@ -877,7 +896,7 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle coordinator.onInteractionBegan = page.onInteractionBegan coordinator.onTopPullDelta = page.onTopPullDelta coordinator.onVerticalInteractionBegan = { [weak self] in - self?.resolvePendingTopAlignmentIfPossible(allowDuringInteraction: true) + self?.handleVerticalInteractionBegan() } coordinator.onVisibleOffsetChange = { [weak self] tab, offset in self?.onHeaderOffsetChanged(tab, offset) @@ -907,8 +926,14 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle host.rootView = AnyView(EmptyView()) } } - if !alignmentState.hasAppliedInitialListOffset && !alignmentState.hasExplicitPendingTopAlignment { - alignmentState.needsInitialHeaderOffsetReset = true + if !alignmentState.hasExplicitPendingTopAlignment { + let maintainsNativeAlignment: Bool + if case .nativeScrollView = page.content { + maintainsNativeAlignment = true + } else { + maintainsNativeAlignment = false + } + alignmentState.requestInitialOffsetReset(maintainNativeAlignment: maintainsNativeAlignment) } view.setNeedsLayout() view.layoutIfNeeded() @@ -1052,6 +1077,11 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle } } + private func handleVerticalInteractionBegan() { + alignmentState.stopMaintainingNativeAlignment() + resolvePendingTopAlignmentIfPossible(allowDuringInteraction: true) + } + private func applyInitialHeaderOffsetResetIfNeeded(allowDuringInteraction: Bool = false) { guard alignmentState.needsInitialHeaderOffsetReset, let page = lastAppliedPage else { return } if !allowDuringInteraction { @@ -1111,22 +1141,31 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle applyNativeAlignmentAfterContentSizeChange(initialOffsetY) } else { applyNativeInitialOffsetIfNeeded(initialOffsetY) + applyNativeMaintainedInitialOffsetIfNeeded(initialOffsetY) } } private func applyNativeAlignmentAfterContentSizeChange(_ initialOffsetY: CGFloat) { guard isNativeListPage else { return } - let targetOffsetY = pendingTopAlignmentTargetOffsetY() ?? (alignmentState.needsInitialHeaderOffsetReset ? initialOffsetY : nil) + let targetOffsetY = pendingTopAlignmentTargetOffsetY() ?? nativeMaintainedInitialTargetOffsetY(initialOffsetY) guard let targetOffsetY else { return } setNativeNormalizedContentOffsetY(targetOffsetY) - alignmentState.markInitialOffsetApplied() - alignmentState.cancelPendingTopAlignment() + if alignmentState.needsInitialHeaderOffsetReset { + alignmentState.markInitialOffsetApplied() + alignmentState.cancelPendingTopAlignment() + } + } + + private func applyNativeMaintainedInitialOffsetIfNeeded(_ initialOffsetY: CGFloat) { + guard let targetOffsetY = nativeMaintainedInitialTargetOffsetY(initialOffsetY) else { return } + setNativeNormalizedContentOffsetY(targetOffsetY) } func settleAfterHorizontalActivation(targetOffsetY: CGFloat) { loadViewIfNeeded() if isNativeListPage { setNativeNormalizedContentOffsetY(targetOffsetY) + alignmentState.maintainNativeAlignment(offsetY: targetOffsetY) } else { setNormalizedContentOffsetY(targetOffsetY) } @@ -1142,6 +1181,7 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle func syncHeaderOffsetFromActivePage(_ syncMode: VideoDetailPagerOffsetModel.InactiveSyncMode) { loadViewIfNeeded() cancelPendingTopAlignment() + alignmentState.stopMaintainingNativeAlignment() let syncOffsetY = syncMode.normalizedOffsetY if isNativeListPage { setNativeNormalizedContentOffsetY(syncOffsetY) @@ -1175,6 +1215,12 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle coordinator.onOffsetChange(coordinator.tab, offset) } + func canAttachHeaderToListHeader(for page: VideoDetailTabPage) -> Bool { + loadViewIfNeeded() + let pinnedVisibleHeight = max(page.headerGeometry.pinnedVisibleHeight, page.headerGeometry.pinHeaderHeight) + return listScrollView.contentOffset.y <= -pinnedVisibleHeight + 0.5 + } + private func setNormalizedContentOffsetY(_ offsetY: CGFloat) { guard let page = lastAppliedPage else { return } let rawTopOffsetY = page.headerGeometry.rawContentOffsetY(forNormalizedOffsetY: offsetY, in: listScrollView) @@ -1190,6 +1236,20 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle setRawContentOffsetYIfNeeded(rawTopOffsetY) } + private func nativeMaintainedInitialTargetOffsetY(_ initialOffsetY: CGFloat) -> CGFloat? { + guard isNativeListPage else { return nil } + if alignmentState.needsInitialHeaderOffsetReset { + return initialOffsetY + } + if alignmentState.maintainsNativeInitialAlignment, + !listScrollView.isTracking, + !listScrollView.isDragging, + !listScrollView.isDecelerating { + return alignmentState.nativeMaintainedAlignmentOffsetY ?? initialOffsetY + } + return nil + } + @discardableResult private func setNormalizedContentOffsetYForAlignment(_ offsetY: CGFloat) -> Bool { return setNormalizedContentOffsetYIfReachable(offsetY) @@ -1777,9 +1837,8 @@ private struct VideoDetailTabPager: UIViewControllerRepresentable { let offset = pageController.normalizedContentOffsetY let attachmentState = VideoDetailHeaderAttachmentState.state( isHorizontalPagingActive: pagerPosition.isPagingActive, - selectedOffset: offset, - syncState: page.headerGeometry.smoothHeaderSyncState(activeOffset: offset), - collapseDistance: page.headerGeometry.collapseDistance + canAttachToListHeader: pageController.canAttachHeaderToListHeader(for: page), + syncState: page.headerGeometry.smoothHeaderSyncState(activeOffset: offset) ) applyHeaderAttachment(attachmentState, pageController: pageController) } @@ -1832,9 +1891,8 @@ private struct VideoDetailTabPager: UIViewControllerRepresentable { headerSyncState = syncState let attachmentState = VideoDetailHeaderAttachmentState.state( isHorizontalPagingActive: true, - selectedOffset: offsetY, - syncState: syncState, - collapseDistance: page.headerGeometry.collapseDistance + canAttachToListHeader: pageController.canAttachHeaderToListHeader(for: page), + syncState: syncState ) applyHeaderAttachment(attachmentState, pageController: pageController) } else { From 2dc2c597a18ec83c3023643fe9286da57eac2aab Mon Sep 17 00:00:00 2001 From: pgs666 <74764545+pgs666@users.noreply.github.com> Date: Sun, 7 Jun 2026 18:04:06 +0800 Subject: [PATCH 164/216] Stabilize native comment list update ordering --- iosApp/CommentListTableView.swift | 37 ++++++++++++++++++++++++++----- iosApp/VideoDetailPager.swift | 5 ++++- 2 files changed, 35 insertions(+), 7 deletions(-) diff --git a/iosApp/CommentListTableView.swift b/iosApp/CommentListTableView.swift index 52570e2d..bc60ed6f 100644 --- a/iosApp/CommentListTableView.swift +++ b/iosApp/CommentListTableView.swift @@ -181,10 +181,7 @@ final class CommentListTableController: NSObject, UITableViewDataSource, UITable if rows.count != previousRowCount { reloadTablePreservingOffset() } else { - UIView.performWithoutAnimation { - tableView?.beginUpdates() - tableView?.endUpdates() - } + updateTableLayoutPreservingOffset() } } return @@ -391,10 +388,38 @@ final class CommentListTableController: NSObject, UITableViewDataSource, UITable let previousOffset = tableView.contentOffset UIView.performWithoutAnimation { tableView.reloadData() - tableView.layoutIfNeeded() - tableView.setContentOffset(previousOffset, animated: false) + if !tableView.isTracking, !tableView.isDragging, !tableView.isDecelerating { + tableView.layoutIfNeeded() + } + tableView.setContentOffset( + clampedContentOffset(previousOffset, in: tableView), + animated: false + ) + } + } + + private func updateTableLayoutPreservingOffset() { + guard let tableView else { return } + let previousOffset = tableView.contentOffset + UIView.performWithoutAnimation { + tableView.beginUpdates() + tableView.endUpdates() + tableView.setContentOffset( + clampedContentOffset(previousOffset, in: tableView), + animated: false + ) } } + + private func clampedContentOffset(_ offset: CGPoint, in tableView: UITableView) -> CGPoint { + let inset = tableView.adjustedContentInset + let minOffsetY = -inset.top + let maxOffsetY = max(minOffsetY, tableView.contentSize.height - tableView.bounds.height + inset.bottom) + return CGPoint( + x: offset.x, + y: min(max(offset.y, minOffsetY), maxOffsetY) + ) + } } private final class HostingCommentTableViewCell: UITableViewCell { diff --git a/iosApp/VideoDetailPager.swift b/iosApp/VideoDetailPager.swift index 66baa936..ab7e7b36 100644 --- a/iosApp/VideoDetailPager.swift +++ b/iosApp/VideoDetailPager.swift @@ -910,7 +910,6 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle nativeScrollDelegateAttachment = nativePage.attachScrollDelegate nativePage.attachScrollDelegate(coordinator) } - nativePage.update() case .swiftUI: break } @@ -963,6 +962,10 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle contentMinimumHeightConstraint?.constant = 1 hostMinimumHeightConstraint?.isActive = false applyNativeInitialOffsetIfNeeded(offsetContext.initialNormalizedOffsetY) + if case .nativeScrollView(let nativePage) = page.content { + nativePage.update() + applyCurrentPageGeometryRules() + } } } From 5f4a6293af22bc38976fc5abd562364f8d878fcd Mon Sep 17 00:00:00 2001 From: pgs666 <74764545+pgs666@users.noreply.github.com> Date: Sun, 7 Jun 2026 18:14:24 +0800 Subject: [PATCH 165/216] Cache measured comment row heights --- iosApp/CommentListTableView.swift | 62 +++++++++++++++++++++++++++++++ 1 file changed, 62 insertions(+) diff --git a/iosApp/CommentListTableView.swift b/iosApp/CommentListTableView.swift index bc60ed6f..327e2eec 100644 --- a/iosApp/CommentListTableView.swift +++ b/iosApp/CommentListTableView.swift @@ -81,6 +81,11 @@ final class CommentListTableController: NSObject, UITableViewDataSource, UITable } } + private struct MeasuredCommentHeight { + let signature: CommentSignature + let height: CGFloat + } + private enum RowHeightEstimate { static let controls: CGFloat = 61 static let loading: CGFloat = 120 @@ -128,6 +133,8 @@ final class CommentListTableController: NSObject, UITableViewDataSource, UITable private var commentActionSignatures: [CommentActionSignature] = [] private var previousRunningActionIDs: Set = [] private var hasRenderedRows = false + private var measuredCommentHeights: [String: MeasuredCommentHeight] = [:] + private var isUpdatingMeasuredHeights = false weak var scrollDelegate: UIScrollViewDelegate? func attach(_ tableView: UITableView) { @@ -151,6 +158,7 @@ final class CommentListTableController: NSObject, UITableViewDataSource, UITable let didChangeRunningActions = previousRunningActionIDs != model.runningActionIDs let resolvedBottomSpacerHeight = max(bottomSpacerHeight, 0) let didChangeBottomSpacerHeight = abs(self.bottomSpacerHeight - resolvedBottomSpacerHeight) > 0.5 + pruneMeasuredCommentHeights(for: model) modelSignature = nextSignature commentActionSignatures = nextCommentActionSignatures previousRunningActionIDs = model.runningActionIDs @@ -264,6 +272,9 @@ final class CommentListTableController: NSObject, UITableViewDataSource, UITable cell.configure( comment: comment, isRunningLike: runningActionIDs.contains("like-\(comment.id)"), + onMeasuredHeight: { [weak self, weak tableView] height in + self?.recordMeasuredCommentHeight(height, commentID: comment.id, tableView: tableView) + }, onReply: { [weak self] in self?.onReply(comment) }, onShowReplies: { [weak self] in self?.onShowReplies(comment) }, onLike: { [weak self] in self?.onLike(comment) }, @@ -279,6 +290,11 @@ final class CommentListTableController: NSObject, UITableViewDataSource, UITable if case .bottomSpacer(let height) = rows[indexPath.row] { return max(height, 0) } + if case .comment(let comment) = rows[indexPath.row], + let measuredHeight = measuredCommentHeights[comment.id], + measuredHeight.signature == CommentSignature(comment) { + return measuredHeight.height + } return UITableView.automaticDimension } @@ -368,6 +384,9 @@ final class CommentListTableController: NSObject, UITableViewDataSource, UITable cell.configure( comment: comment, isRunningLike: runningActionIDs.contains("like-\(comment.id)"), + onMeasuredHeight: { [weak self, weak tableView] height in + self?.recordMeasuredCommentHeight(height, commentID: comment.id, tableView: tableView) + }, onReply: { [weak self] in self?.onReply(comment) }, onShowReplies: { [weak self] in self?.onShowReplies(comment) }, onLike: { [weak self] in self?.onLike(comment) }, @@ -401,6 +420,8 @@ final class CommentListTableController: NSObject, UITableViewDataSource, UITable private func updateTableLayoutPreservingOffset() { guard let tableView else { return } let previousOffset = tableView.contentOffset + isUpdatingMeasuredHeights = true + defer { isUpdatingMeasuredHeights = false } UIView.performWithoutAnimation { tableView.beginUpdates() tableView.endUpdates() @@ -420,10 +441,42 @@ final class CommentListTableController: NSObject, UITableViewDataSource, UITable y: min(max(offset.y, minOffsetY), maxOffsetY) ) } + + private func pruneMeasuredCommentHeights(for model: CommentListTableModel) { + let validSignatures = Dictionary(uniqueKeysWithValues: model.comments.map { ($0.id, CommentSignature($0)) }) + measuredCommentHeights = measuredCommentHeights.filter { id, measured in + validSignatures[id] == measured.signature + } + } + + private func recordMeasuredCommentHeight( + _ height: CGFloat, + commentID: String, + tableView: UITableView? + ) { + guard height > 1 else { return } + let roundedHeight = ceil(height) + guard let comment = comments.first(where: { $0.id == commentID }) else { return } + let signature = CommentSignature(comment) + guard measuredCommentHeights[commentID].map({ + $0.signature != signature || abs($0.height - roundedHeight) > 0.5 + }) ?? true else { return } + measuredCommentHeights[commentID] = MeasuredCommentHeight( + signature: signature, + height: roundedHeight + ) + guard let tableView, + !isUpdatingMeasuredHeights, + !tableView.isTracking, + !tableView.isDragging, + !tableView.isDecelerating else { return } + updateTableLayoutPreservingOffset() + } } private final class HostingCommentTableViewCell: UITableViewCell { static let reuseIdentifier = "HostingCommentTableViewCell" + private var onMeasuredHeight: ((CGFloat) -> Void)? override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { super.init(style: style, reuseIdentifier: reuseIdentifier) @@ -444,9 +497,16 @@ private final class HostingCommentTableViewCell: UITableViewCell { override func prepareForReuse() { super.prepareForReuse() contentConfiguration = nil + onMeasuredHeight = nil + } + + override func layoutSubviews() { + super.layoutSubviews() + onMeasuredHeight?(bounds.height) } func configure(@ViewBuilder content: () -> Content) { + onMeasuredHeight = nil contentConfiguration = UIHostingConfiguration { content() } @@ -456,12 +516,14 @@ private final class HostingCommentTableViewCell: UITableViewCell { func configure( comment: CommentRow, isRunningLike: Bool, + onMeasuredHeight: @escaping (CGFloat) -> Void, onReply: @escaping () -> Void, onShowReplies: @escaping () -> Void, onLike: @escaping () -> Void, onDislike: @escaping () -> Void, onReport: @escaping () -> Void ) { + self.onMeasuredHeight = onMeasuredHeight let row = CommentRowView( comment: comment, isRunningLike: isRunningLike, From ff116947150193b656c772362d78b7d679c03d50 Mon Sep 17 00:00:00 2001 From: pgs666 <74764545+pgs666@users.noreply.github.com> Date: Sun, 7 Jun 2026 18:24:31 +0800 Subject: [PATCH 166/216] Coalesce measured comment height updates --- iosApp/CommentListTableView.swift | 28 +++++++++++++++++++++++++++- 1 file changed, 27 insertions(+), 1 deletion(-) diff --git a/iosApp/CommentListTableView.swift b/iosApp/CommentListTableView.swift index 327e2eec..793f1583 100644 --- a/iosApp/CommentListTableView.swift +++ b/iosApp/CommentListTableView.swift @@ -135,6 +135,8 @@ final class CommentListTableController: NSObject, UITableViewDataSource, UITable private var hasRenderedRows = false private var measuredCommentHeights: [String: MeasuredCommentHeight] = [:] private var isUpdatingMeasuredHeights = false + private var isReloadingRows = false + private var hasPendingMeasuredHeightLayoutUpdate = false weak var scrollDelegate: UIScrollViewDelegate? func attach(_ tableView: UITableView) { @@ -290,6 +292,9 @@ final class CommentListTableController: NSObject, UITableViewDataSource, UITable if case .bottomSpacer(let height) = rows[indexPath.row] { return max(height, 0) } + if case .footer = rows[indexPath.row] { + return RowHeightEstimate.footer + } if case .comment(let comment) = rows[indexPath.row], let measuredHeight = measuredCommentHeights[comment.id], measuredHeight.signature == CommentSignature(comment) { @@ -399,6 +404,8 @@ final class CommentListTableController: NSObject, UITableViewDataSource, UITable private func reloadTablePreservingOffset() { guard let tableView else { return } guard hasRenderedRows else { + isReloadingRows = true + defer { isReloadingRows = false } tableView.reloadData() tableView.layoutIfNeeded() hasRenderedRows = true @@ -406,6 +413,8 @@ final class CommentListTableController: NSObject, UITableViewDataSource, UITable } let previousOffset = tableView.contentOffset UIView.performWithoutAnimation { + isReloadingRows = true + defer { isReloadingRows = false } tableView.reloadData() if !tableView.isTracking, !tableView.isDragging, !tableView.isDecelerating { tableView.layoutIfNeeded() @@ -467,10 +476,27 @@ final class CommentListTableController: NSObject, UITableViewDataSource, UITable ) guard let tableView, !isUpdatingMeasuredHeights, + !isReloadingRows, !tableView.isTracking, !tableView.isDragging, !tableView.isDecelerating else { return } - updateTableLayoutPreservingOffset() + scheduleMeasuredHeightLayoutUpdate(tableView: tableView) + } + + private func scheduleMeasuredHeightLayoutUpdate(tableView: UITableView) { + guard !hasPendingMeasuredHeightLayoutUpdate else { return } + hasPendingMeasuredHeightLayoutUpdate = true + DispatchQueue.main.async { [weak self, weak tableView] in + guard let self else { return } + self.hasPendingMeasuredHeightLayoutUpdate = false + guard let tableView, + !self.isUpdatingMeasuredHeights, + !self.isReloadingRows, + !tableView.isTracking, + !tableView.isDragging, + !tableView.isDecelerating else { return } + self.updateTableLayoutPreservingOffset() + } } } From b13f8b7da0f2576e2d3b047c96093c66f0da47be Mon Sep 17 00:00:00 2001 From: pgs666 <74764545+pgs666@users.noreply.github.com> Date: Sun, 7 Jun 2026 18:33:40 +0800 Subject: [PATCH 167/216] Fix non-comment table row heights --- iosApp/CommentListTableView.swift | 52 +++++++++++++++++++++---------- 1 file changed, 35 insertions(+), 17 deletions(-) diff --git a/iosApp/CommentListTableView.swift b/iosApp/CommentListTableView.swift index 793f1583..3c8addd4 100644 --- a/iosApp/CommentListTableView.swift +++ b/iosApp/CommentListTableView.swift @@ -92,6 +92,9 @@ final class CommentListTableController: NSObject, UITableViewDataSource, UITable static let failed: CGFloat = 230 static let empty: CGFloat = 180 static let footer: CGFloat = 44 + static let failedMinimum: CGFloat = 230 + static let failedLineHeight: CGFloat = 20 + static let failedCharactersPerLine: CGFloat = 18 static let commentMinimum: CGFloat = 118 static let commentLineHeight: CGFloat = 22 static let commentCharactersPerLine: CGFloat = 22 @@ -103,6 +106,11 @@ final class CommentListTableController: NSObject, UITableViewDataSource, UITable let childReduction: CGFloat = comment.isChildComment ? 8 : 0 return max(commentMinimum - childReduction, 92 + estimatedLines * commentLineHeight + replyHeight) } + + static func failed(_ message: String) -> CGFloat { + let estimatedLines = ceil(CGFloat(max(message.count, 1)) / failedCharactersPerLine) + return max(failedMinimum, 188 + estimatedLines * failedLineHeight) + } } private enum Row { @@ -289,37 +297,47 @@ final class CommentListTableController: NSObject, UITableViewDataSource, UITable func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat { guard indexPath.row < rows.count else { return UITableView.automaticDimension } - if case .bottomSpacer(let height) = rows[indexPath.row] { - return max(height, 0) - } - if case .footer = rows[indexPath.row] { - return RowHeightEstimate.footer - } - if case .comment(let comment) = rows[indexPath.row], - let measuredHeight = measuredCommentHeights[comment.id], - measuredHeight.signature == CommentSignature(comment) { - return measuredHeight.height - } - return UITableView.automaticDimension + return height(for: rows[indexPath.row]) } func tableView(_ tableView: UITableView, estimatedHeightForRowAt indexPath: IndexPath) -> CGFloat { guard indexPath.row < rows.count else { return UITableView.automaticDimension } - switch rows[indexPath.row] { + return estimatedHeight(for: rows[indexPath.row]) + } + + private func height(for row: Row) -> CGFloat { + switch row { case .controls: return RowHeightEstimate.controls case .loading: return RowHeightEstimate.loading - case .failed: - return RowHeightEstimate.failed + case .failed(let message): + return RowHeightEstimate.failed(message) case .empty: return RowHeightEstimate.empty - case .comment(let comment): - return RowHeightEstimate.comment(comment) case .footer: return RowHeightEstimate.footer case .bottomSpacer(let height): return max(height, 0) + case .comment(let comment): + guard let measuredHeight = measuredCommentHeights[comment.id], + measuredHeight.signature == CommentSignature(comment) else { + return UITableView.automaticDimension + } + return measuredHeight.height + } + } + + private func estimatedHeight(for row: Row) -> CGFloat { + switch row { + case .comment(let comment): + if let measuredHeight = measuredCommentHeights[comment.id], + measuredHeight.signature == CommentSignature(comment) { + return measuredHeight.height + } + return RowHeightEstimate.comment(comment) + default: + return height(for: row) } } From 8fabffd26e08d3894638c052889b99347e772b43 Mon Sep 17 00:00:00 2001 From: pgs666 <74764545+pgs666@users.noreply.github.com> Date: Sun, 7 Jun 2026 18:45:05 +0800 Subject: [PATCH 168/216] Defer pager selection during horizontal scroll --- iosApp/VideoDetailPager.swift | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/iosApp/VideoDetailPager.swift b/iosApp/VideoDetailPager.swift index ab7e7b36..fe75e693 100644 --- a/iosApp/VideoDetailPager.swift +++ b/iosApp/VideoDetailPager.swift @@ -1584,6 +1584,9 @@ private struct VideoDetailTabPager: UIViewControllerRepresentable { lastLaidOutWidth = width layoutHeaderHosts() updateHeaderAttachmentForCurrentState() + if isHorizontalSelectionInProgress { + return + } if let pendingSelectedIndex { self.pendingSelectedIndex = nil setSelectedIndex(pendingSelectedIndex, animated: false) @@ -1604,7 +1607,11 @@ private struct VideoDetailTabPager: UIViewControllerRepresentable { animated: Bool ) { loadViewIfNeeded() - pagerPosition.setSelectedIndex(selectedIndex) + if isHorizontalSelectionInProgress { + pendingSelectedIndex = selectedIndex + } else { + pagerPosition.setSelectedIndex(selectedIndex) + } latestPages[.introduction] = introduction latestPages[.comments] = comments prepareCommentsPageIfNeeded(for: comments) @@ -1653,6 +1660,13 @@ private struct VideoDetailTabPager: UIViewControllerRepresentable { scrollView.setContentOffset(targetOffset, animated: animated) } + private var isHorizontalSelectionInProgress: Bool { + pagerPosition.isPagingActive + || scrollView.isTracking + || scrollView.isDragging + || scrollView.isDecelerating + } + private func settleSelectedPageIfNeeded(_ index: Int) { let clampedIndex = min(max(index, 0), VideoPageTab.allCases.count - 1) guard pagerPosition.settledIndex != clampedIndex else { @@ -1674,6 +1688,7 @@ private struct VideoDetailTabPager: UIViewControllerRepresentable { private func finishHorizontalPaging(at index: Int) { let clampedIndex = min(max(index, 0), VideoPageTab.allCases.count - 1) + pendingSelectedIndex = nil if let activePage = verticalPageController(for: VideoPageTab.page(at: pagerPosition.selectedIndex)) { updateHeaderSyncState(activeOffset: activePage.normalizedContentOffsetY) } From c0a1c50b28cb466f268b3a07c80bac5f339e5633 Mon Sep 17 00:00:00 2001 From: pgs666 <74764545+pgs666@users.noreply.github.com> Date: Sun, 7 Jun 2026 18:54:56 +0800 Subject: [PATCH 169/216] Avoid resync after horizontal pager settle --- iosApp/VideoDetailPager.swift | 1 - 1 file changed, 1 deletion(-) diff --git a/iosApp/VideoDetailPager.swift b/iosApp/VideoDetailPager.swift index fe75e693..1b8c873e 100644 --- a/iosApp/VideoDetailPager.swift +++ b/iosApp/VideoDetailPager.swift @@ -1709,7 +1709,6 @@ private struct VideoDetailTabPager: UIViewControllerRepresentable { commentsPage?.reportCurrentOffset() } updateHeaderAttachmentForCurrentState() - syncInactivePageHeaderOffset() } private func updateScrollsToTop(for activeTab: VideoPageTab) { From 03fe93f9b15a1352b07aca299cda798a4254c89c Mon Sep 17 00:00:00 2001 From: pgs666 <74764545+pgs666@users.noreply.github.com> Date: Sun, 7 Jun 2026 19:03:50 +0800 Subject: [PATCH 170/216] Limit native initial offset maintenance --- iosApp/VideoDetailPager.swift | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/iosApp/VideoDetailPager.swift b/iosApp/VideoDetailPager.swift index 1b8c873e..a51b3495 100644 --- a/iosApp/VideoDetailPager.swift +++ b/iosApp/VideoDetailPager.swift @@ -395,11 +395,6 @@ private struct VideoDetailListAlignmentState { } } - mutating func maintainNativeAlignment(offsetY: CGFloat) { - maintainsNativeInitialAlignment = true - nativeMaintainedAlignmentOffsetY = offsetY - } - mutating func stopMaintainingNativeAlignment() { maintainsNativeInitialAlignment = false nativeMaintainedAlignmentOffsetY = nil @@ -757,6 +752,7 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle private var listScrollViewContentSizeObservation: NSKeyValueObservation? private var listScrollViewBoundsObservation: NSKeyValueObservation? private var nativeScrollDelegateAttachment: ((UIScrollViewDelegate?) -> Void)? + private var isCurrentPageSelected = false var onHeaderOffsetChanged: (VideoPageTab, CGFloat) -> Void = { _, _ in } init(tab: VideoPageTab, listScrollView: UIScrollView? = nil) { @@ -891,6 +887,7 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle func update(page: VideoDetailTabPage) { loadViewIfNeeded() + isCurrentPageSelected = page.isSelected coordinator.tab = page.tab coordinator.onOffsetChange = page.onOffsetChange coordinator.onInteractionBegan = page.onInteractionBegan @@ -1166,9 +1163,9 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle func settleAfterHorizontalActivation(targetOffsetY: CGFloat) { loadViewIfNeeded() + isCurrentPageSelected = true if isNativeListPage { setNativeNormalizedContentOffsetY(targetOffsetY) - alignmentState.maintainNativeAlignment(offsetY: targetOffsetY) } else { setNormalizedContentOffsetY(targetOffsetY) } @@ -1245,6 +1242,7 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle return initialOffsetY } if alignmentState.maintainsNativeInitialAlignment, + !isCurrentPageSelected, !listScrollView.isTracking, !listScrollView.isDragging, !listScrollView.isDecelerating { From af225104ad13d76c9d343e8774f97656fd051ea4 Mon Sep 17 00:00:00 2001 From: pgs666 <74764545+pgs666@users.noreply.github.com> Date: Sun, 7 Jun 2026 19:12:57 +0800 Subject: [PATCH 171/216] Remove stale pager alignment state --- iosApp/VideoDetailPager.swift | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/iosApp/VideoDetailPager.swift b/iosApp/VideoDetailPager.swift index a51b3495..604d9888 100644 --- a/iosApp/VideoDetailPager.swift +++ b/iosApp/VideoDetailPager.swift @@ -366,9 +366,7 @@ private enum VideoDetailTabPageContent { private struct VideoDetailListAlignmentState { var pendingTopAlignment: VideoDetailPendingTopAlignment? var needsInitialHeaderOffsetReset = true - var hasAppliedInitialListOffset = false var maintainsNativeInitialAlignment = false - var nativeMaintainedAlignmentOffsetY: CGFloat? var hasExplicitPendingTopAlignment: Bool { guard let pendingTopAlignment else { return false } @@ -384,20 +382,17 @@ private struct VideoDetailListAlignmentState { mutating func markInitialOffsetApplied() { needsInitialHeaderOffsetReset = false - hasAppliedInitialListOffset = true } mutating func requestInitialOffsetReset(maintainNativeAlignment: Bool) { needsInitialHeaderOffsetReset = true if maintainNativeAlignment { maintainsNativeInitialAlignment = true - nativeMaintainedAlignmentOffsetY = nil } } mutating func stopMaintainingNativeAlignment() { maintainsNativeInitialAlignment = false - nativeMaintainedAlignmentOffsetY = nil } } @@ -1246,7 +1241,7 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle !listScrollView.isTracking, !listScrollView.isDragging, !listScrollView.isDecelerating { - return alignmentState.nativeMaintainedAlignmentOffsetY ?? initialOffsetY + return initialOffsetY } return nil } From b81288472b42e5d6b8e2344bf406fb622a74c318 Mon Sep 17 00:00:00 2001 From: pgs666 <74764545+pgs666@users.noreply.github.com> Date: Sun, 7 Jun 2026 19:21:09 +0800 Subject: [PATCH 172/216] Stop active comment offset relayout --- iosApp/CommentListTableView.swift | 35 +++--------------- iosApp/VideoDetailPager.swift | 61 ++----------------------------- 2 files changed, 9 insertions(+), 87 deletions(-) diff --git a/iosApp/CommentListTableView.swift b/iosApp/CommentListTableView.swift index 3c8addd4..3c98f220 100644 --- a/iosApp/CommentListTableView.swift +++ b/iosApp/CommentListTableView.swift @@ -144,7 +144,6 @@ final class CommentListTableController: NSObject, UITableViewDataSource, UITable private var measuredCommentHeights: [String: MeasuredCommentHeight] = [:] private var isUpdatingMeasuredHeights = false private var isReloadingRows = false - private var hasPendingMeasuredHeightLayoutUpdate = false weak var scrollDelegate: UIScrollViewDelegate? func attach(_ tableView: UITableView) { @@ -282,8 +281,8 @@ final class CommentListTableController: NSObject, UITableViewDataSource, UITable cell.configure( comment: comment, isRunningLike: runningActionIDs.contains("like-\(comment.id)"), - onMeasuredHeight: { [weak self, weak tableView] height in - self?.recordMeasuredCommentHeight(height, commentID: comment.id, tableView: tableView) + onMeasuredHeight: { [weak self] height in + self?.recordMeasuredCommentHeight(height, commentID: comment.id) }, onReply: { [weak self] in self?.onReply(comment) }, onShowReplies: { [weak self] in self?.onShowReplies(comment) }, @@ -407,8 +406,8 @@ final class CommentListTableController: NSObject, UITableViewDataSource, UITable cell.configure( comment: comment, isRunningLike: runningActionIDs.contains("like-\(comment.id)"), - onMeasuredHeight: { [weak self, weak tableView] height in - self?.recordMeasuredCommentHeight(height, commentID: comment.id, tableView: tableView) + onMeasuredHeight: { [weak self] height in + self?.recordMeasuredCommentHeight(height, commentID: comment.id) }, onReply: { [weak self] in self?.onReply(comment) }, onShowReplies: { [weak self] in self?.onShowReplies(comment) }, @@ -478,8 +477,7 @@ final class CommentListTableController: NSObject, UITableViewDataSource, UITable private func recordMeasuredCommentHeight( _ height: CGFloat, - commentID: String, - tableView: UITableView? + commentID: String ) { guard height > 1 else { return } let roundedHeight = ceil(height) @@ -492,29 +490,6 @@ final class CommentListTableController: NSObject, UITableViewDataSource, UITable signature: signature, height: roundedHeight ) - guard let tableView, - !isUpdatingMeasuredHeights, - !isReloadingRows, - !tableView.isTracking, - !tableView.isDragging, - !tableView.isDecelerating else { return } - scheduleMeasuredHeightLayoutUpdate(tableView: tableView) - } - - private func scheduleMeasuredHeightLayoutUpdate(tableView: UITableView) { - guard !hasPendingMeasuredHeightLayoutUpdate else { return } - hasPendingMeasuredHeightLayoutUpdate = true - DispatchQueue.main.async { [weak self, weak tableView] in - guard let self else { return } - self.hasPendingMeasuredHeightLayoutUpdate = false - guard let tableView, - !self.isUpdatingMeasuredHeights, - !self.isReloadingRows, - !tableView.isTracking, - !tableView.isDragging, - !tableView.isDecelerating else { return } - self.updateTableLayoutPreservingOffset() - } } } diff --git a/iosApp/VideoDetailPager.swift b/iosApp/VideoDetailPager.swift index 604d9888..d8337e72 100644 --- a/iosApp/VideoDetailPager.swift +++ b/iosApp/VideoDetailPager.swift @@ -366,7 +366,6 @@ private enum VideoDetailTabPageContent { private struct VideoDetailListAlignmentState { var pendingTopAlignment: VideoDetailPendingTopAlignment? var needsInitialHeaderOffsetReset = true - var maintainsNativeInitialAlignment = false var hasExplicitPendingTopAlignment: Bool { guard let pendingTopAlignment else { return false } @@ -384,15 +383,8 @@ private struct VideoDetailListAlignmentState { needsInitialHeaderOffsetReset = false } - mutating func requestInitialOffsetReset(maintainNativeAlignment: Bool) { + mutating func requestInitialOffsetReset() { needsInitialHeaderOffsetReset = true - if maintainNativeAlignment { - maintainsNativeInitialAlignment = true - } - } - - mutating func stopMaintainingNativeAlignment() { - maintainsNativeInitialAlignment = false } } @@ -918,13 +910,7 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle } } if !alignmentState.hasExplicitPendingTopAlignment { - let maintainsNativeAlignment: Bool - if case .nativeScrollView = page.content { - maintainsNativeAlignment = true - } else { - maintainsNativeAlignment = false - } - alignmentState.requestInitialOffsetReset(maintainNativeAlignment: maintainsNativeAlignment) + alignmentState.requestInitialOffsetReset() } view.setNeedsLayout() view.layoutIfNeeded() @@ -953,7 +939,6 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle collapseSpacerHeightConstraint?.constant = 0 contentMinimumHeightConstraint?.constant = 1 hostMinimumHeightConstraint?.isActive = false - applyNativeInitialOffsetIfNeeded(offsetContext.initialNormalizedOffsetY) if case .nativeScrollView(let nativePage) = page.content { nativePage.update() applyCurrentPageGeometryRules() @@ -1073,7 +1058,6 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle } private func handleVerticalInteractionBegan() { - alignmentState.stopMaintainingNativeAlignment() resolvePendingTopAlignmentIfPossible(allowDuringInteraction: true) } @@ -1130,30 +1114,9 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle private func applyNativeGeometryAlignment( initialOffsetY: CGFloat, - didApplyMinimumContentSize: Bool + didApplyMinimumContentSize _: Bool ) { - if didApplyMinimumContentSize { - applyNativeAlignmentAfterContentSizeChange(initialOffsetY) - } else { - applyNativeInitialOffsetIfNeeded(initialOffsetY) - applyNativeMaintainedInitialOffsetIfNeeded(initialOffsetY) - } - } - - private func applyNativeAlignmentAfterContentSizeChange(_ initialOffsetY: CGFloat) { - guard isNativeListPage else { return } - let targetOffsetY = pendingTopAlignmentTargetOffsetY() ?? nativeMaintainedInitialTargetOffsetY(initialOffsetY) - guard let targetOffsetY else { return } - setNativeNormalizedContentOffsetY(targetOffsetY) - if alignmentState.needsInitialHeaderOffsetReset { - alignmentState.markInitialOffsetApplied() - alignmentState.cancelPendingTopAlignment() - } - } - - private func applyNativeMaintainedInitialOffsetIfNeeded(_ initialOffsetY: CGFloat) { - guard let targetOffsetY = nativeMaintainedInitialTargetOffsetY(initialOffsetY) else { return } - setNativeNormalizedContentOffsetY(targetOffsetY) + applyNativeInitialOffsetIfNeeded(initialOffsetY) } func settleAfterHorizontalActivation(targetOffsetY: CGFloat) { @@ -1176,7 +1139,6 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle func syncHeaderOffsetFromActivePage(_ syncMode: VideoDetailPagerOffsetModel.InactiveSyncMode) { loadViewIfNeeded() cancelPendingTopAlignment() - alignmentState.stopMaintainingNativeAlignment() let syncOffsetY = syncMode.normalizedOffsetY if isNativeListPage { setNativeNormalizedContentOffsetY(syncOffsetY) @@ -1231,21 +1193,6 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle setRawContentOffsetYIfNeeded(rawTopOffsetY) } - private func nativeMaintainedInitialTargetOffsetY(_ initialOffsetY: CGFloat) -> CGFloat? { - guard isNativeListPage else { return nil } - if alignmentState.needsInitialHeaderOffsetReset { - return initialOffsetY - } - if alignmentState.maintainsNativeInitialAlignment, - !isCurrentPageSelected, - !listScrollView.isTracking, - !listScrollView.isDragging, - !listScrollView.isDecelerating { - return initialOffsetY - } - return nil - } - @discardableResult private func setNormalizedContentOffsetYForAlignment(_ offsetY: CGFloat) -> Bool { return setNormalizedContentOffsetYIfReachable(offsetY) From c8aeace8b40a75f6a664a99f7a81c51b8cf4d5b5 Mon Sep 17 00:00:00 2001 From: pgs666 <74764545+pgs666@users.noreply.github.com> Date: Sun, 7 Jun 2026 19:29:12 +0800 Subject: [PATCH 173/216] Simplify horizontal pager settlement --- iosApp/VideoDetailPager.swift | 44 +++++++++-------------------------- 1 file changed, 11 insertions(+), 33 deletions(-) diff --git a/iosApp/VideoDetailPager.swift b/iosApp/VideoDetailPager.swift index d8337e72..cb3d246b 100644 --- a/iosApp/VideoDetailPager.swift +++ b/iosApp/VideoDetailPager.swift @@ -390,7 +390,6 @@ private struct VideoDetailListAlignmentState { private struct VideoDetailHorizontalPagerPosition: Equatable { private(set) var selectedIndex = 0 - private(set) var settledIndex: Int? private(set) var isPagingActive = false var activeHeaderIndex: Int { @@ -407,14 +406,6 @@ private struct VideoDetailHorizontalPagerPosition: Equatable { return true } - mutating func markSettled(_ index: Int) -> Bool { - let nextIndex = clamped(index) - selectedIndex = nextIndex - guard settledIndex != nextIndex else { return false } - settledIndex = nextIndex - return true - } - private func clamped(_ index: Int) -> Int { min(max(index, 0), VideoPageTab.allCases.count - 1) } @@ -1434,7 +1425,6 @@ private struct VideoDetailTabPager: UIViewControllerRepresentable { activeOffset: 0, collapseDistance: 0 ) - private var isFinishingHorizontalSelection = false private var latestPages: [VideoPageTab: VideoDetailTabPage] = [:] init(coordinator: Coordinator) { @@ -1572,9 +1562,6 @@ private struct VideoDetailTabPager: UIViewControllerRepresentable { } guard !scrollView.isTracking, !scrollView.isDragging, !scrollView.isDecelerating else { return } setSelectedIndex(pagerPosition.selectedIndex, animated: animated) - if !animated { - settleSelectedPageIfNeeded(pagerPosition.selectedIndex) - } } private func addPage(_ page: UIViewController) { @@ -1607,22 +1594,11 @@ private struct VideoDetailTabPager: UIViewControllerRepresentable { || scrollView.isDecelerating } - private func settleSelectedPageIfNeeded(_ index: Int) { - let clampedIndex = min(max(index, 0), VideoPageTab.allCases.count - 1) - guard pagerPosition.settledIndex != clampedIndex else { - finishHorizontalPaging(at: clampedIndex) - return - } - finishHorizontalPaging(at: clampedIndex) - } - private func settlePageAfterHorizontalSelection(_ index: Int) { let clampedIndex = min(max(index, 0), VideoPageTab.allCases.count - 1) - isFinishingHorizontalSelection = true if pagerPosition.isPagingActive { - setHorizontalPagingActive(false) + deactivateHorizontalPagingForSettlement() } - isFinishingHorizontalSelection = false finishHorizontalPaging(at: clampedIndex) } @@ -1633,19 +1609,15 @@ private struct VideoDetailTabPager: UIViewControllerRepresentable { updateHeaderSyncState(activeOffset: activePage.normalizedContentOffsetY) } let activationOffset = headerSyncState.inactiveSyncMode.normalizedOffsetY - let didChangeSettledIndex = pagerPosition.markSettled(clampedIndex) + pagerPosition.setSelectedIndex(clampedIndex) updateScrollsToTop(for: VideoPageTab.page(at: clampedIndex)) layoutHeaderHosts() switch VideoPageTab.page(at: clampedIndex) { case .introduction: - if didChangeSettledIndex { - introductionPage.settleAfterHorizontalActivation(targetOffsetY: activationOffset) - } + introductionPage.settleAfterHorizontalActivation(targetOffsetY: activationOffset) introductionPage.reportCurrentOffset() case .comments: - if didChangeSettledIndex { - commentsPage?.settleAfterHorizontalActivation(targetOffsetY: activationOffset) - } + commentsPage?.settleAfterHorizontalActivation(targetOffsetY: activationOffset) commentsPage?.reportCurrentOffset() } updateHeaderAttachmentForCurrentState() @@ -1661,11 +1633,17 @@ private struct VideoDetailTabPager: UIViewControllerRepresentable { introductionPage.setHorizontalPagingActive(isActive) commentsPage?.setHorizontalPagingActive(isActive) updateHeaderAttachmentForCurrentState() - if !isActive, !isFinishingHorizontalSelection { + if !isActive { syncInactivePageHeaderOffset() } } + private func deactivateHorizontalPagingForSettlement() { + guard pagerPosition.setPagingActive(false) else { return } + introductionPage.setHorizontalPagingActive(false) + commentsPage?.setHorizontalPagingActive(false) + } + private func syncInactivePageHeaderOffset(activeOffset providedActiveOffset: CGFloat? = nil) { guard let activePage = verticalPageController(for: VideoPageTab.page(at: pagerPosition.selectedIndex)) else { return From f347121dddbec1e8b9153369184ce55614d6c19f Mon Sep 17 00:00:00 2001 From: pgs666 <74764545+pgs666@users.noreply.github.com> Date: Sun, 7 Jun 2026 19:36:23 +0800 Subject: [PATCH 174/216] Unify active header sync path --- iosApp/VideoDetailPager.swift | 29 ++++++++++++----------------- 1 file changed, 12 insertions(+), 17 deletions(-) diff --git a/iosApp/VideoDetailPager.swift b/iosApp/VideoDetailPager.swift index cb3d246b..ddd57337 100644 --- a/iosApp/VideoDetailPager.swift +++ b/iosApp/VideoDetailPager.swift @@ -1644,15 +1644,17 @@ private struct VideoDetailTabPager: UIViewControllerRepresentable { commentsPage?.setHorizontalPagingActive(false) } - private func syncInactivePageHeaderOffset(activeOffset providedActiveOffset: CGFloat? = nil) { + @discardableResult + private func syncInactivePageHeaderOffset(activeOffset providedActiveOffset: CGFloat? = nil) -> VideoDetailSmoothHeaderSyncState? { guard let activePage = verticalPageController(for: VideoPageTab.page(at: pagerPosition.selectedIndex)) else { - return + return nil } let activeOffset = providedActiveOffset ?? activePage.normalizedContentOffsetY let nextSyncState = updateHeaderSyncState(activeOffset: activeOffset) for tab in VideoPageTab.allCases where tab.pageIndex != pagerPosition.selectedIndex { verticalPageController(for: tab)?.syncHeaderOffsetFromActivePage(nextSyncState.inactiveSyncMode) } + return nextSyncState } @discardableResult @@ -1778,17 +1780,6 @@ private struct VideoDetailTabPager: UIViewControllerRepresentable { applyHeaderAttachment(attachmentState, pageController: pageController) } - private func attachActiveHeaderToPagerContainer() { - let headerTab = activeHeaderTab - guard let page = latestPages[headerTab], - let pageController = verticalPageController(for: headerTab) else { return } - layoutHeaderHosts() - let offset = pageController.normalizedContentOffsetY - let syncState = page.headerGeometry.smoothHeaderSyncState(activeOffset: offset) - headerSyncState = syncState - attachHeader(to: view, originY: syncState.headerContainerY) - } - private func applyHeaderAttachment( _ attachmentState: VideoDetailHeaderAttachmentState, pageController: VideoDetailVerticalScrollPageViewController @@ -1831,10 +1822,14 @@ private struct VideoDetailTabPager: UIViewControllerRepresentable { ) applyHeaderAttachment(attachmentState, pageController: pageController) } else { - if tab == VideoPageTab.page(at: pagerPosition.selectedIndex) { - syncInactivePageHeaderOffset(activeOffset: offsetY) - } - updateHeaderAttachmentForCurrentState() + let syncState = syncInactivePageHeaderOffset(activeOffset: offsetY) + ?? page.headerGeometry.smoothHeaderSyncState(activeOffset: offsetY) + let attachmentState = VideoDetailHeaderAttachmentState.state( + isHorizontalPagingActive: false, + canAttachToListHeader: pageController.canAttachHeaderToListHeader(for: page), + syncState: syncState + ) + applyHeaderAttachment(attachmentState, pageController: pageController) } } } From 46af748a69cee0f4dab786787846d5919987c9e4 Mon Sep 17 00:00:00 2001 From: pgs666 <74764545+pgs666@users.noreply.github.com> Date: Sun, 7 Jun 2026 19:45:36 +0800 Subject: [PATCH 175/216] Separate SwiftUI pending alignment --- iosApp/VideoDetailPager.swift | 70 ++++++++++++----------------------- 1 file changed, 24 insertions(+), 46 deletions(-) diff --git a/iosApp/VideoDetailPager.swift b/iosApp/VideoDetailPager.swift index ddd57337..3124a7dc 100644 --- a/iosApp/VideoDetailPager.swift +++ b/iosApp/VideoDetailPager.swift @@ -347,11 +347,6 @@ private struct VideoDetailListOffsetContext: Equatable { let minimumListContentHeight: CGFloat } -private enum VideoDetailPendingTopAlignment { - case initial - case explicit(CGFloat) -} - private struct VideoDetailNativeScrollPage { let listScrollView: UIScrollView let attachScrollDelegate: (UIScrollViewDelegate?) -> Void @@ -364,19 +359,15 @@ private enum VideoDetailTabPageContent { } private struct VideoDetailListAlignmentState { - var pendingTopAlignment: VideoDetailPendingTopAlignment? + var pendingSwiftUITopAlignmentOffsetY: CGFloat? var needsInitialHeaderOffsetReset = true - var hasExplicitPendingTopAlignment: Bool { - guard let pendingTopAlignment else { return false } - if case .explicit = pendingTopAlignment { - return true - } - return false + var hasPendingSwiftUITopAlignment: Bool { + pendingSwiftUITopAlignmentOffsetY != nil } mutating func cancelPendingTopAlignment() { - pendingTopAlignment = nil + pendingSwiftUITopAlignmentOffsetY = nil } mutating func markInitialOffsetApplied() { @@ -784,7 +775,7 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle self?.handleScrollGeometryChange() } defaultScrollView?.shouldBeginVerticalPan = { [weak self] panGestureRecognizer, view in - self?.resolvePendingTopAlignmentIfPossible(allowDuringInteraction: true) + self?.handleVerticalInteractionBegan() let velocity = panGestureRecognizer.velocity(in: view) return abs(velocity.x) <= abs(velocity.y) * 1.05 } @@ -876,7 +867,7 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle coordinator.onVisibleOffsetChange = { [weak self] tab, offset in self?.onHeaderOffsetChanged(tab, offset) } - if !page.isSelected, !hasExplicitPendingTopAlignment { + if !page.isSelected, !hasPendingSwiftUITopAlignment { cancelPendingTopAlignment() } switch page.content { @@ -900,7 +891,7 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle host.rootView = AnyView(EmptyView()) } } - if !alignmentState.hasExplicitPendingTopAlignment { + if !alignmentState.hasPendingSwiftUITopAlignment { alignmentState.requestInitialOffsetReset() } view.setNeedsLayout() @@ -922,8 +913,8 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle hostMinimumHeightConstraint?.isActive = true if alignmentState.needsInitialHeaderOffsetReset { applyInitialHeaderOffsetResetIfNeeded() - } else if alignmentState.pendingTopAlignment != nil { - resolvePendingTopAlignmentIfPossible() + } else if let pendingOffsetY = alignmentState.pendingSwiftUITopAlignmentOffsetY { + resolvePendingSwiftUITopAlignmentIfPossible(targetOffsetY: pendingOffsetY) } case .nativeScrollView: applyBottomContentSpacing(page.contentBottomPadding, usesContentSpacer: false) @@ -966,8 +957,8 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle ) return } - if alignmentState.pendingTopAlignment != nil { - resolvePendingTopAlignmentIfPossible() + if let pendingOffsetY = alignmentState.pendingSwiftUITopAlignmentOffsetY { + resolvePendingSwiftUITopAlignmentIfPossible(targetOffsetY: pendingOffsetY) return } if alignmentState.needsInitialHeaderOffsetReset { @@ -1030,14 +1021,11 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle listHeaderView.frame = nextFrame } - private func resolvePendingTopAlignmentIfPossible(allowDuringInteraction: Bool = false) { - guard let targetOffsetY = pendingTopAlignmentTargetOffsetY() else { return } - if isNativeListPage { - setNativeNormalizedContentOffsetY(targetOffsetY) - alignmentState.markInitialOffsetApplied() - alignmentState.cancelPendingTopAlignment() - return - } + private func resolvePendingSwiftUITopAlignmentIfPossible( + targetOffsetY: CGFloat, + allowDuringInteraction: Bool = false + ) { + guard !isNativeListPage else { return } if !allowDuringInteraction { guard !listScrollView.isTracking, !listScrollView.isDragging, !listScrollView.isDecelerating else { return } } @@ -1049,7 +1037,8 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle } private func handleVerticalInteractionBegan() { - resolvePendingTopAlignmentIfPossible(allowDuringInteraction: true) + guard let pendingOffsetY = alignmentState.pendingSwiftUITopAlignmentOffsetY else { return } + resolvePendingSwiftUITopAlignmentIfPossible(targetOffsetY: pendingOffsetY, allowDuringInteraction: true) } private func applyInitialHeaderOffsetResetIfNeeded(allowDuringInteraction: Bool = false) { @@ -1067,8 +1056,8 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle return } guard setNormalizedContentOffsetYForAlignment(targetOffsetY) else { - alignmentState.pendingTopAlignment = .initial - resolvePendingTopAlignmentIfPossible() + alignmentState.pendingSwiftUITopAlignmentOffsetY = targetOffsetY + resolvePendingSwiftUITopAlignmentIfPossible(targetOffsetY: targetOffsetY) return } if abs(listScrollView.verticalContentOffsetExcludingBounce - targetOffsetY) <= 0.5 { @@ -1081,19 +1070,8 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle alignmentState.cancelPendingTopAlignment() } - private func pendingTopAlignmentTargetOffsetY() -> CGFloat? { - guard let pendingTopAlignment = alignmentState.pendingTopAlignment, - let page = lastAppliedPage else { return nil } - switch pendingTopAlignment { - case .initial: - return page.headerGeometry.listOffsetContext(in: listScrollView.bounds.height).initialNormalizedOffsetY - case .explicit(let offsetY): - return offsetY - } - } - - private var hasExplicitPendingTopAlignment: Bool { - alignmentState.hasExplicitPendingTopAlignment + private var hasPendingSwiftUITopAlignment: Bool { + alignmentState.hasPendingSwiftUITopAlignment } private func applyNativeInitialOffsetIfNeeded(_ targetOffsetY: CGFloat) { @@ -1137,8 +1115,8 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle return } guard setNormalizedContentOffsetYForAlignment(syncOffsetY) else { - alignmentState.pendingTopAlignment = .explicit(syncOffsetY) - resolvePendingTopAlignmentIfPossible() + alignmentState.pendingSwiftUITopAlignmentOffsetY = syncOffsetY + resolvePendingSwiftUITopAlignmentIfPossible(targetOffsetY: syncOffsetY) return } alignmentState.markInitialOffsetApplied() From 1797a1efc8a0b8bcaf03cf0426ff87674bc93ac1 Mon Sep 17 00:00:00 2001 From: pgs666 <74764545+pgs666@users.noreply.github.com> Date: Sun, 7 Jun 2026 19:54:41 +0800 Subject: [PATCH 176/216] Silence internal offset alignment writes --- iosApp/VideoDetailPager.swift | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/iosApp/VideoDetailPager.swift b/iosApp/VideoDetailPager.swift index 3124a7dc..d647b925 100644 --- a/iosApp/VideoDetailPager.swift +++ b/iosApp/VideoDetailPager.swift @@ -1150,7 +1150,7 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle private func setNormalizedContentOffsetY(_ offsetY: CGFloat) { guard let page = lastAppliedPage else { return } let rawTopOffsetY = page.headerGeometry.rawContentOffsetY(forNormalizedOffsetY: offsetY, in: listScrollView) - setRawContentOffsetYIfNeeded(rawTopOffsetY) + setRawContentOffsetYSilentlyIfNeeded(rawTopOffsetY) } private func setNativeNormalizedContentOffsetY(_ offsetY: CGFloat) { @@ -1159,7 +1159,7 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle return } let rawTopOffsetY = offsetY - listScrollView.adjustedContentInset.top - setRawContentOffsetYIfNeeded(rawTopOffsetY) + setRawContentOffsetYSilentlyIfNeeded(rawTopOffsetY) } @discardableResult @@ -1173,17 +1173,16 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle guard abs(normalizedContentOffsetY(forRawOffsetY: rawTopOffsetY) - offsetY) <= 0.5 else { return false } - setRawContentOffsetYIfNeeded(rawTopOffsetY) + setRawContentOffsetYSilentlyIfNeeded(rawTopOffsetY) return true } - private func setRawContentOffsetYIfNeeded(_ rawTopOffsetY: CGFloat) { + private func setRawContentOffsetYSilentlyIfNeeded(_ rawTopOffsetY: CGFloat) { guard abs(listScrollView.contentOffset.y - rawTopOffsetY) > 0.5 else { return } coordinator.isApplyingExternalOffset = true defer { coordinator.isApplyingExternalOffset = false } listScrollView.setContentOffset(CGPoint(x: listScrollView.contentOffset.x, y: rawTopOffsetY), animated: false) coordinator.resetReportedOffset(listScrollView.verticalContentOffsetExcludingBounce) - onHeaderOffsetChanged(coordinator.tab, listScrollView.verticalContentOffsetExcludingBounce) } func headerAttachmentView() -> UIView { From e516a1ec54c41496ee39c6bd62c2fc3aee993167 Mon Sep 17 00:00:00 2001 From: pgs666 <74764545+pgs666@users.noreply.github.com> Date: Sun, 7 Jun 2026 20:06:01 +0800 Subject: [PATCH 177/216] Use inset for native comment clearance --- iosApp/CommentListTableView.swift | 70 ++----------------------------- iosApp/VideoDetailPager.swift | 2 +- iosApp/VideoDetailView.swift | 9 ++-- 3 files changed, 7 insertions(+), 74 deletions(-) diff --git a/iosApp/CommentListTableView.swift b/iosApp/CommentListTableView.swift index 3c98f220..fcc86ba9 100644 --- a/iosApp/CommentListTableView.swift +++ b/iosApp/CommentListTableView.swift @@ -120,14 +120,12 @@ final class CommentListTableController: NSObject, UITableViewDataSource, UITable case empty case comment(CommentRow) case footer - case bottomSpacer(CGFloat) } private(set) weak var tableView: UITableView? private var rows: [Row] = [] private var sortMode: CommentViewModel.SortMode = .mostLikes private var comments: [CommentRow] = [] - private var bottomSpacerHeight: CGFloat = 0 private var runningActionIDs: Set = [] private var onChangeSortMode: (CommentViewModel.SortMode) -> Void = { _ in } private var onRefresh: () -> Void = {} @@ -142,7 +140,6 @@ final class CommentListTableController: NSObject, UITableViewDataSource, UITable private var previousRunningActionIDs: Set = [] private var hasRenderedRows = false private var measuredCommentHeights: [String: MeasuredCommentHeight] = [:] - private var isUpdatingMeasuredHeights = false private var isReloadingRows = false weak var scrollDelegate: UIScrollViewDelegate? @@ -159,21 +156,18 @@ final class CommentListTableController: NSObject, UITableViewDataSource, UITable return tableView } - func update(_ model: CommentListTableModel, bottomSpacerHeight: CGFloat = 0) { + func update(_ model: CommentListTableModel) { let nextSignature = ModelSignature(model) let shouldReload = modelSignature != nextSignature let nextCommentActionSignatures = model.comments.map(CommentActionSignature.init) let didChangeCommentActions = commentActionSignatures != nextCommentActionSignatures let didChangeRunningActions = previousRunningActionIDs != model.runningActionIDs - let resolvedBottomSpacerHeight = max(bottomSpacerHeight, 0) - let didChangeBottomSpacerHeight = abs(self.bottomSpacerHeight - resolvedBottomSpacerHeight) > 0.5 pruneMeasuredCommentHeights(for: model) modelSignature = nextSignature commentActionSignatures = nextCommentActionSignatures previousRunningActionIDs = model.runningActionIDs sortMode = model.sortMode comments = model.comments - self.bottomSpacerHeight = resolvedBottomSpacerHeight runningActionIDs = model.runningActionIDs onChangeSortMode = model.onChangeSortMode onRefresh = model.onRefresh @@ -192,14 +186,6 @@ final class CommentListTableController: NSObject, UITableViewDataSource, UITable } else if didChangeRunningActions || didChangeCommentActions { rows = rows(for: model) updateVisibleCommentRows() - } else if didChangeBottomSpacerHeight { - let previousRowCount = rows.count - rows = rows(for: model) - if rows.count != previousRowCount { - reloadTablePreservingOffset() - } else { - updateTableLayoutPreservingOffset() - } } return } @@ -266,13 +252,6 @@ final class CommentListTableController: NSObject, UITableViewDataSource, UITable ) as? CommentListFooterCell ?? CommentListFooterCell(style: .default, reuseIdentifier: CommentListFooterCell.reuseIdentifier) cell.configure() return cell - case .bottomSpacer(let height): - let cell = tableView.dequeueReusableCell( - withIdentifier: CommentListBottomSpacerCell.reuseIdentifier, - for: indexPath - ) as? CommentListBottomSpacerCell ?? CommentListBottomSpacerCell(style: .default, reuseIdentifier: CommentListBottomSpacerCell.reuseIdentifier) - cell.configure(height: height) - return cell case .comment(let comment): let cell = tableView.dequeueReusableCell( withIdentifier: HostingCommentTableViewCell.reuseIdentifier, @@ -316,8 +295,6 @@ final class CommentListTableController: NSObject, UITableViewDataSource, UITable return RowHeightEstimate.empty case .footer: return RowHeightEstimate.footer - case .bottomSpacer(let height): - return max(height, 0) case .comment(let comment): guard let measuredHeight = measuredCommentHeights[comment.id], measuredHeight.signature == CommentSignature(comment) else { @@ -373,7 +350,6 @@ final class CommentListTableController: NSObject, UITableViewDataSource, UITable tableView.contentInsetAdjustmentBehavior = .never tableView.register(HostingCommentTableViewCell.self, forCellReuseIdentifier: HostingCommentTableViewCell.reuseIdentifier) tableView.register(CommentListFooterCell.self, forCellReuseIdentifier: CommentListFooterCell.reuseIdentifier) - tableView.register(CommentListBottomSpacerCell.self, forCellReuseIdentifier: CommentListBottomSpacerCell.reuseIdentifier) } private func rows(for model: CommentListTableModel) -> [Row] { @@ -384,17 +360,12 @@ final class CommentListTableController: NSObject, UITableViewDataSource, UITable return [.controls, .failed(message)] case .loaded: guard !model.comments.isEmpty else { - return rowsWithBottomSpacer([.controls, .empty]) + return [.controls, .empty] } - return rowsWithBottomSpacer([.controls] + model.comments.map { .comment($0) } + [.footer]) + return [.controls] + model.comments.map { .comment($0) } + [.footer] } } - private func rowsWithBottomSpacer(_ baseRows: [Row]) -> [Row] { - guard bottomSpacerHeight > 0.5 else { return baseRows } - return baseRows + [.bottomSpacer(bottomSpacerHeight)] - } - private func updateVisibleCommentRows() { guard let tableView else { return } for indexPath in tableView.indexPathsForVisibleRows ?? [] { @@ -443,21 +414,6 @@ final class CommentListTableController: NSObject, UITableViewDataSource, UITable } } - private func updateTableLayoutPreservingOffset() { - guard let tableView else { return } - let previousOffset = tableView.contentOffset - isUpdatingMeasuredHeights = true - defer { isUpdatingMeasuredHeights = false } - UIView.performWithoutAnimation { - tableView.beginUpdates() - tableView.endUpdates() - tableView.setContentOffset( - clampedContentOffset(previousOffset, in: tableView), - animated: false - ) - } - } - private func clampedContentOffset(_ offset: CGPoint, in tableView: UITableView) -> CGPoint { let inset = tableView.adjustedContentInset let minOffsetY = -inset.top @@ -688,23 +644,3 @@ private final class CommentListFooterCell: UITableViewCell { .margins(.all, 0) } } - -private final class CommentListBottomSpacerCell: UITableViewCell { - static let reuseIdentifier = "CommentListBottomSpacerCell" - - override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { - super.init(style: style, reuseIdentifier: reuseIdentifier) - backgroundColor = .clear - contentView.backgroundColor = .clear - selectionStyle = .none - } - - @available(*, unavailable) - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - func configure(height _: CGFloat) { - contentConfiguration = nil - } -} diff --git a/iosApp/VideoDetailPager.swift b/iosApp/VideoDetailPager.swift index d647b925..4acb9f4e 100644 --- a/iosApp/VideoDetailPager.swift +++ b/iosApp/VideoDetailPager.swift @@ -981,7 +981,7 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle abs(contentBottomSpacerHeightConstraint.constant - contentSpacerHeight) > 0.5 { contentBottomSpacerHeightConstraint.constant = contentSpacerHeight } - let bottomInset: CGFloat = 0 + let bottomInset = usesContentSpacer ? 0 : resolvedBottomSpacing if abs(listScrollView.contentInset.bottom - bottomInset) > 0.5 { listScrollView.contentInset.bottom = bottomInset } diff --git a/iosApp/VideoDetailView.swift b/iosApp/VideoDetailView.swift index 79bf78f9..5386e7d0 100644 --- a/iosApp/VideoDetailView.swift +++ b/iosApp/VideoDetailView.swift @@ -10,8 +10,8 @@ private final class NativeCommentListHolder: ObservableObject { tableView = controller.makeTableView() } - func update(_ model: CommentListTableModel, bottomSpacerHeight: CGFloat) { - controller.update(model, bottomSpacerHeight: bottomSpacerHeight) + func update(_ model: CommentListTableModel) { + controller.update(model) } func attachScrollDelegate(_ delegate: UIScrollViewDelegate?) { @@ -473,10 +473,7 @@ struct VideoDetailView: View { nativeCommentList.attachScrollDelegate(delegate) }, nativeCommentsUpdate: { - nativeCommentList.update( - commentTableModel, - bottomSpacerHeight: composerContentClearance - ) + nativeCommentList.update(commentTableModel) } ) } From 286a91c5dfe223fd672e1ae2dd35fec940fb8ff9 Mon Sep 17 00:00:00 2001 From: pgs666 <74764545+pgs666@users.noreply.github.com> Date: Sun, 7 Jun 2026 20:30:15 +0800 Subject: [PATCH 178/216] Use explicit inset for pager offsets --- iosApp/CommentListTableView.swift | 2 +- iosApp/VideoDetailPager.swift | 25 +++++++++++++++++++------ 2 files changed, 20 insertions(+), 7 deletions(-) diff --git a/iosApp/CommentListTableView.swift b/iosApp/CommentListTableView.swift index fcc86ba9..b71f8563 100644 --- a/iosApp/CommentListTableView.swift +++ b/iosApp/CommentListTableView.swift @@ -415,7 +415,7 @@ final class CommentListTableController: NSObject, UITableViewDataSource, UITable } private func clampedContentOffset(_ offset: CGPoint, in tableView: UITableView) -> CGPoint { - let inset = tableView.adjustedContentInset + let inset = tableView.contentInset let minOffsetY = -inset.top let maxOffsetY = max(minOffsetY, tableView.contentSize.height - tableView.bounds.height + inset.bottom) return CGPoint( diff --git a/iosApp/VideoDetailPager.swift b/iosApp/VideoDetailPager.swift index 4acb9f4e..6260e23c 100644 --- a/iosApp/VideoDetailPager.swift +++ b/iosApp/VideoDetailPager.swift @@ -325,7 +325,7 @@ private struct VideoDetailSmoothHeaderGeometry: Equatable { } func rawContentOffsetY(forNormalizedOffsetY offsetY: CGFloat, in listScrollView: UIScrollView) -> CGFloat { - let inset = listScrollView.adjustedContentInset + let inset = listScrollView.contentInset let minOffsetY = -inset.top let maxOffsetY = max(minOffsetY, listScrollView.contentSize.height - listScrollView.bounds.height + inset.bottom) let rawOffsetY = offsetY - inset.top @@ -333,7 +333,7 @@ private struct VideoDetailSmoothHeaderGeometry: Equatable { } func normalizedContentOffsetY(forRawOffsetY rawOffsetY: CGFloat, in listScrollView: UIScrollView) -> CGFloat { - rawOffsetY + listScrollView.adjustedContentInset.top + rawOffsetY + listScrollView.contentInset.top } } @@ -984,12 +984,19 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle let bottomInset = usesContentSpacer ? 0 : resolvedBottomSpacing if abs(listScrollView.contentInset.bottom - bottomInset) > 0.5 { listScrollView.contentInset.bottom = bottomInset + clampOffsetAfterInsetChangeIfNeeded() } if abs(listScrollView.verticalScrollIndicatorInsets.bottom - resolvedBottomSpacing) > 0.5 { listScrollView.verticalScrollIndicatorInsets.bottom = resolvedBottomSpacing } } + private func clampOffsetAfterInsetChangeIfNeeded() { + guard !listScrollView.isTracking, !listScrollView.isDragging, !listScrollView.isDecelerating else { return } + let clampedRawOffsetY = listScrollView.clampedRawVerticalContentOffsetY(listScrollView.contentOffset.y) + setRawContentOffsetYSilentlyIfNeeded(clampedRawOffsetY) + } + @discardableResult private func applyNativeMinimumContentSizeIfNeeded( page: VideoDetailTabPage, @@ -1158,7 +1165,9 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle setNormalizedContentOffsetY(offsetY) return } - let rawTopOffsetY = offsetY - listScrollView.adjustedContentInset.top + let rawTopOffsetY = listScrollView.clampedRawVerticalContentOffsetY( + offsetY - listScrollView.contentInset.top + ) setRawContentOffsetYSilentlyIfNeeded(rawTopOffsetY) } @@ -1196,7 +1205,7 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle } private func normalizedContentOffsetY(forRawOffsetY rawOffsetY: CGFloat) -> CGFloat { - guard let page = lastAppliedPage else { return rawOffsetY + listScrollView.adjustedContentInset.top } + guard let page = lastAppliedPage else { return rawOffsetY + listScrollView.contentInset.top } return page.headerGeometry.normalizedContentOffsetY(forRawOffsetY: rawOffsetY, in: listScrollView) } @@ -1240,10 +1249,14 @@ private final class VerticalScrollView: UIScrollView { private extension UIScrollView { var verticalContentOffsetExcludingBounce: CGFloat { - let inset = adjustedContentInset + clampedRawVerticalContentOffsetY(contentOffset.y) + contentInset.top + } + + func clampedRawVerticalContentOffsetY(_ rawOffsetY: CGFloat) -> CGFloat { + let inset = contentInset let minOffsetY = -inset.top let maxOffsetY = max(minOffsetY, contentSize.height - bounds.height + inset.bottom) - return min(max(contentOffset.y, minOffsetY), maxOffsetY) + inset.top + return min(max(rawOffsetY, minOffsetY), maxOffsetY) } } From 698d02da234514bd1f5822bb6f3f5e66fa34d655 Mon Sep 17 00:00:00 2001 From: pgs666 <74764545+pgs666@users.noreply.github.com> Date: Sun, 7 Jun 2026 20:50:09 +0800 Subject: [PATCH 179/216] Defer inactive native list alignment --- iosApp/VideoDetailPager.swift | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/iosApp/VideoDetailPager.swift b/iosApp/VideoDetailPager.swift index 6260e23c..75ff71de 100644 --- a/iosApp/VideoDetailPager.swift +++ b/iosApp/VideoDetailPager.swift @@ -953,7 +953,8 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle if isNativeScrollView { applyNativeGeometryAlignment( initialOffsetY: offsetContext.initialNormalizedOffsetY, - didApplyMinimumContentSize: didApplyNativeMinimumContentSize + didApplyMinimumContentSize: didApplyNativeMinimumContentSize, + isCurrentPageSelected: page.isSelected ) return } @@ -1090,8 +1091,10 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle private func applyNativeGeometryAlignment( initialOffsetY: CGFloat, - didApplyMinimumContentSize _: Bool + didApplyMinimumContentSize _: Bool, + isCurrentPageSelected: Bool ) { + guard isCurrentPageSelected else { return } applyNativeInitialOffsetIfNeeded(initialOffsetY) } From 745e0cdb6708ce192e9feb0d26bcb63d45a813b3 Mon Sep 17 00:00:00 2001 From: pgs666 <74764545+pgs666@users.noreply.github.com> Date: Sun, 7 Jun 2026 20:59:25 +0800 Subject: [PATCH 180/216] Keep inactive native reset pending --- iosApp/VideoDetailPager.swift | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/iosApp/VideoDetailPager.swift b/iosApp/VideoDetailPager.swift index 75ff71de..1133b9c9 100644 --- a/iosApp/VideoDetailPager.swift +++ b/iosApp/VideoDetailPager.swift @@ -1115,13 +1115,18 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle return listScrollView.verticalContentOffsetExcludingBounce } - func syncHeaderOffsetFromActivePage(_ syncMode: VideoDetailPagerOffsetModel.InactiveSyncMode) { + func syncHeaderOffsetFromActivePage( + _ syncMode: VideoDetailPagerOffsetModel.InactiveSyncMode, + consumeInitialReset: Bool = true + ) { loadViewIfNeeded() cancelPendingTopAlignment() let syncOffsetY = syncMode.normalizedOffsetY if isNativeListPage { setNativeNormalizedContentOffsetY(syncOffsetY) - alignmentState.markInitialOffsetApplied() + if consumeInitialReset { + alignmentState.markInitialOffsetApplied() + } return } guard setNormalizedContentOffsetYForAlignment(syncOffsetY) else { @@ -1645,7 +1650,10 @@ private struct VideoDetailTabPager: UIViewControllerRepresentable { let activeOffset = providedActiveOffset ?? activePage.normalizedContentOffsetY let nextSyncState = updateHeaderSyncState(activeOffset: activeOffset) for tab in VideoPageTab.allCases where tab.pageIndex != pagerPosition.selectedIndex { - verticalPageController(for: tab)?.syncHeaderOffsetFromActivePage(nextSyncState.inactiveSyncMode) + verticalPageController(for: tab)?.syncHeaderOffsetFromActivePage( + nextSyncState.inactiveSyncMode, + consumeInitialReset: false + ) } return nextSyncState } From 67855a4716cf16e44fa613f20904f9fe2ff28e37 Mon Sep 17 00:00:00 2001 From: pgs666 <74764545+pgs666@users.noreply.github.com> Date: Sun, 7 Jun 2026 21:12:56 +0800 Subject: [PATCH 181/216] Refresh selection during pager settlement --- iosApp/VideoDetailPager.swift | 41 +++++++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/iosApp/VideoDetailPager.swift b/iosApp/VideoDetailPager.swift index 1133b9c9..51c9df8a 100644 --- a/iosApp/VideoDetailPager.swift +++ b/iosApp/VideoDetailPager.swift @@ -478,6 +478,37 @@ private struct VideoDetailTabPage { } return nil } + + func withSelection(_ isSelected: Bool) -> VideoDetailTabPage { + switch content { + case .swiftUI(let content): + VideoDetailTabPage( + tab: tab, + contentBottomPadding: contentBottomPadding, + isSelected: isSelected, + headerGeometry: headerGeometry, + contentUpdateRevision: contentUpdateRevision, + onOffsetChange: onOffsetChange, + onInteractionBegan: onInteractionBegan, + onTopPullDelta: onTopPullDelta, + content: { content() } + ) + case .nativeScrollView(let nativePage): + VideoDetailTabPage( + tab: tab, + contentBottomPadding: contentBottomPadding, + isSelected: isSelected, + headerGeometry: headerGeometry, + contentUpdateRevision: contentUpdateRevision, + onOffsetChange: onOffsetChange, + onInteractionBegan: onInteractionBegan, + onTopPullDelta: onTopPullDelta, + listScrollView: nativePage.listScrollView, + attachScrollDelegate: nativePage.attachScrollDelegate, + nativeUpdate: nativePage.update + ) + } + } } struct VideoDetailPagerContainer: View { @@ -1608,6 +1639,7 @@ private struct VideoDetailTabPager: UIViewControllerRepresentable { } let activationOffset = headerSyncState.inactiveSyncMode.normalizedOffsetY pagerPosition.setSelectedIndex(clampedIndex) + refreshPageSelectionSnapshots(selectedTab: VideoPageTab.page(at: clampedIndex)) updateScrollsToTop(for: VideoPageTab.page(at: clampedIndex)) layoutHeaderHosts() switch VideoPageTab.page(at: clampedIndex) { @@ -1621,6 +1653,15 @@ private struct VideoDetailTabPager: UIViewControllerRepresentable { updateHeaderAttachmentForCurrentState() } + private func refreshPageSelectionSnapshots(selectedTab: VideoPageTab) { + for tab in VideoPageTab.allCases { + guard let page = latestPages[tab] else { continue } + let isSelected = tab == selectedTab + guard page.isSelected != isSelected else { continue } + latestPages[tab] = page.withSelection(isSelected) + } + } + private func updateScrollsToTop(for activeTab: VideoPageTab) { introductionPage.setScrollsToTop(activeTab == .introduction) commentsPage?.setScrollsToTop(activeTab == .comments) From eab528fe8b5e08001a48c81e230393650f4b75e5 Mon Sep 17 00:00:00 2001 From: pgs666 <74764545+pgs666@users.noreply.github.com> Date: Sun, 7 Jun 2026 21:25:33 +0800 Subject: [PATCH 182/216] Make pager settlement idempotent --- iosApp/VideoDetailPager.swift | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/iosApp/VideoDetailPager.swift b/iosApp/VideoDetailPager.swift index 51c9df8a..93e31e53 100644 --- a/iosApp/VideoDetailPager.swift +++ b/iosApp/VideoDetailPager.swift @@ -1625,14 +1625,19 @@ private struct VideoDetailTabPager: UIViewControllerRepresentable { private func settlePageAfterHorizontalSelection(_ index: Int) { let clampedIndex = min(max(index, 0), VideoPageTab.allCases.count - 1) + let wasPagingActive = pagerPosition.isPagingActive if pagerPosition.isPagingActive { deactivateHorizontalPagingForSettlement() } - finishHorizontalPaging(at: clampedIndex) + finishHorizontalPaging(at: clampedIndex, wasPagingActive: wasPagingActive) } - private func finishHorizontalPaging(at index: Int) { + private func finishHorizontalPaging(at index: Int, wasPagingActive: Bool) { let clampedIndex = min(max(index, 0), VideoPageTab.allCases.count - 1) + guard clampedIndex != pagerPosition.selectedIndex || wasPagingActive else { + pendingSelectedIndex = nil + return + } pendingSelectedIndex = nil if let activePage = verticalPageController(for: VideoPageTab.page(at: pagerPosition.selectedIndex)) { updateHeaderSyncState(activeOffset: activePage.normalizedContentOffsetY) @@ -1650,6 +1655,9 @@ private struct VideoDetailTabPager: UIViewControllerRepresentable { commentsPage?.settleAfterHorizontalActivation(targetOffsetY: activationOffset) commentsPage?.reportCurrentOffset() } + if let activePage = verticalPageController(for: VideoPageTab.page(at: clampedIndex)) { + syncInactivePageHeaderOffset(activeOffset: activePage.normalizedContentOffsetY) + } updateHeaderAttachmentForCurrentState() } From feb16a51efe1bebde730b8dfe80abf4103e922e2 Mon Sep 17 00:00:00 2001 From: pgs666 <74764545+pgs666@users.noreply.github.com> Date: Sun, 7 Jun 2026 21:42:05 +0800 Subject: [PATCH 183/216] Avoid redundant pending pager selection --- iosApp/VideoDetailPager.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/iosApp/VideoDetailPager.swift b/iosApp/VideoDetailPager.swift index 93e31e53..79f19dde 100644 --- a/iosApp/VideoDetailPager.swift +++ b/iosApp/VideoDetailPager.swift @@ -1567,7 +1567,7 @@ private struct VideoDetailTabPager: UIViewControllerRepresentable { ) { loadViewIfNeeded() if isHorizontalSelectionInProgress { - pendingSelectedIndex = selectedIndex + pendingSelectedIndex = selectedIndex == pagerPosition.selectedIndex ? nil : selectedIndex } else { pagerPosition.setSelectedIndex(selectedIndex) } From 5a79bc1c4525502769811cb4b991174d6b9b735b Mon Sep 17 00:00:00 2001 From: pgs666 <74764545+pgs666@users.noreply.github.com> Date: Sun, 7 Jun 2026 22:09:02 +0800 Subject: [PATCH 184/216] Stabilize comment pager activation --- iosApp/CommentListTableView.swift | 2 ++ iosApp/VideoDetailPager.swift | 30 ++++++++++++++++++++++++++++-- 2 files changed, 30 insertions(+), 2 deletions(-) diff --git a/iosApp/CommentListTableView.swift b/iosApp/CommentListTableView.swift index b71f8563..0759c925 100644 --- a/iosApp/CommentListTableView.swift +++ b/iosApp/CommentListTableView.swift @@ -435,6 +435,8 @@ final class CommentListTableController: NSObject, UITableViewDataSource, UITable _ height: CGFloat, commentID: String ) { + guard let tableView else { return } + guard !tableView.isTracking, !tableView.isDragging, !tableView.isDecelerating else { return } guard height > 1 else { return } let roundedHeight = ceil(height) guard let comment = comments.first(where: { $0.id == commentID }) else { return } diff --git a/iosApp/VideoDetailPager.swift b/iosApp/VideoDetailPager.swift index 79f19dde..59033aa9 100644 --- a/iosApp/VideoDetailPager.swift +++ b/iosApp/VideoDetailPager.swift @@ -753,6 +753,7 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle private var listScrollViewBoundsObservation: NSKeyValueObservation? private var nativeScrollDelegateAttachment: ((UIScrollViewDelegate?) -> Void)? private var isCurrentPageSelected = false + private var pendingNativePostLayoutAlignmentOffsetY: CGFloat? var onHeaderOffsetChanged: (VideoPageTab, CGFloat) -> Void = { _, _ in } init(tab: VideoPageTab, listScrollView: UIScrollView? = nil) { @@ -883,6 +884,7 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle super.viewDidLayoutSubviews() applyListHeaderFrame() handleScrollGeometryChange() + applyPendingNativePostLayoutAlignmentIfNeeded() } func update(page: VideoDetailTabPage) { @@ -1133,7 +1135,14 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle loadViewIfNeeded() isCurrentPageSelected = true if isNativeListPage { + pendingNativePostLayoutAlignmentOffsetY = targetOffsetY + listScrollView.setNeedsLayout() + listScrollView.layoutIfNeeded() setNativeNormalizedContentOffsetY(targetOffsetY) + applyPendingNativePostLayoutAlignmentIfNeeded() + DispatchQueue.main.async { [weak self] in + self?.applyPendingNativePostLayoutAlignmentIfNeeded() + } } else { setNormalizedContentOffsetY(targetOffsetY) } @@ -1204,12 +1213,22 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle setNormalizedContentOffsetY(offsetY) return } + listScrollView.layoutIfNeeded() let rawTopOffsetY = listScrollView.clampedRawVerticalContentOffsetY( offsetY - listScrollView.contentInset.top ) setRawContentOffsetYSilentlyIfNeeded(rawTopOffsetY) } + private func applyPendingNativePostLayoutAlignmentIfNeeded() { + guard isNativeListPage, let targetOffsetY = pendingNativePostLayoutAlignmentOffsetY else { return } + guard !listScrollView.isTracking, !listScrollView.isDragging, !listScrollView.isDecelerating else { return } + listScrollView.layoutIfNeeded() + setNativeNormalizedContentOffsetY(targetOffsetY) + guard abs(listScrollView.verticalContentOffsetExcludingBounce - targetOffsetY) <= 0.5 else { return } + pendingNativePostLayoutAlignmentOffsetY = nil + } + @discardableResult private func setNormalizedContentOffsetYForAlignment(_ offsetY: CGFloat) -> Bool { return setNormalizedContentOffsetYIfReachable(offsetY) @@ -1821,11 +1840,18 @@ private struct VideoDetailTabPager: UIViewControllerRepresentable { guard let page = latestPages[headerTab] else { return } layoutHeaderHosts() guard let pageController = verticalPageController(for: headerTab) else { return } - let offset = pageController.normalizedContentOffsetY + let syncState: VideoDetailSmoothHeaderSyncState + if pagerPosition.isPagingActive { + syncState = headerSyncState + } else { + let offset = pageController.normalizedContentOffsetY + syncState = page.headerGeometry.smoothHeaderSyncState(activeOffset: offset) + headerSyncState = syncState + } let attachmentState = VideoDetailHeaderAttachmentState.state( isHorizontalPagingActive: pagerPosition.isPagingActive, canAttachToListHeader: pageController.canAttachHeaderToListHeader(for: page), - syncState: page.headerGeometry.smoothHeaderSyncState(activeOffset: offset) + syncState: syncState ) applyHeaderAttachment(attachmentState, pageController: pageController) } From 0323e437bfb73e02b73afbe1175c527d4063b3ed Mon Sep 17 00:00:00 2001 From: pgs666 <74764545+pgs666@users.noreply.github.com> Date: Sun, 7 Jun 2026 22:26:04 +0800 Subject: [PATCH 185/216] Keep native pager alignment pending --- iosApp/VideoDetailPager.swift | 37 +++++++++++++++++++++++------------ 1 file changed, 25 insertions(+), 12 deletions(-) diff --git a/iosApp/VideoDetailPager.swift b/iosApp/VideoDetailPager.swift index 59033aa9..1fd3218b 100644 --- a/iosApp/VideoDetailPager.swift +++ b/iosApp/VideoDetailPager.swift @@ -963,6 +963,7 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle private func handleScrollGeometryChange() { applyCurrentPageGeometryRules() + applyPendingNativePostLayoutAlignmentIfNeeded() } private func applyCurrentPageGeometryRules() { @@ -1091,9 +1092,11 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle in: listScrollView.bounds.height ).initialNormalizedOffsetY if isNativeListPage { - setNativeNormalizedContentOffsetY(targetOffsetY) - alignmentState.markInitialOffsetApplied() - alignmentState.cancelPendingTopAlignment() + guard setNativeNormalizedContentOffsetY(targetOffsetY) else { + pendingNativePostLayoutAlignmentOffsetY = targetOffsetY + return + } + finishInitialOffsetAlignment() return } guard setNormalizedContentOffsetYForAlignment(targetOffsetY) else { @@ -1117,9 +1120,11 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle private func applyNativeInitialOffsetIfNeeded(_ targetOffsetY: CGFloat) { guard isNativeListPage, alignmentState.needsInitialHeaderOffsetReset else { return } - setNativeNormalizedContentOffsetY(targetOffsetY) - alignmentState.markInitialOffsetApplied() - alignmentState.cancelPendingTopAlignment() + guard setNativeNormalizedContentOffsetY(targetOffsetY) else { + pendingNativePostLayoutAlignmentOffsetY = targetOffsetY + return + } + finishInitialOffsetAlignment() } private func applyNativeGeometryAlignment( @@ -1146,8 +1151,9 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle } else { setNormalizedContentOffsetY(targetOffsetY) } - alignmentState.markInitialOffsetApplied() - cancelPendingTopAlignment() + if !isNativeListPage || abs(listScrollView.verticalContentOffsetExcludingBounce - targetOffsetY) <= 0.5 { + finishInitialOffsetAlignment() + } } var normalizedContentOffsetY: CGFloat { @@ -1208,25 +1214,32 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle setRawContentOffsetYSilentlyIfNeeded(rawTopOffsetY) } - private func setNativeNormalizedContentOffsetY(_ offsetY: CGFloat) { + @discardableResult + private func setNativeNormalizedContentOffsetY(_ offsetY: CGFloat) -> Bool { guard isNativeListPage else { setNormalizedContentOffsetY(offsetY) - return + return abs(listScrollView.verticalContentOffsetExcludingBounce - offsetY) <= 0.5 } listScrollView.layoutIfNeeded() let rawTopOffsetY = listScrollView.clampedRawVerticalContentOffsetY( offsetY - listScrollView.contentInset.top ) setRawContentOffsetYSilentlyIfNeeded(rawTopOffsetY) + return abs(listScrollView.verticalContentOffsetExcludingBounce - offsetY) <= 0.5 } private func applyPendingNativePostLayoutAlignmentIfNeeded() { guard isNativeListPage, let targetOffsetY = pendingNativePostLayoutAlignmentOffsetY else { return } guard !listScrollView.isTracking, !listScrollView.isDragging, !listScrollView.isDecelerating else { return } listScrollView.layoutIfNeeded() - setNativeNormalizedContentOffsetY(targetOffsetY) - guard abs(listScrollView.verticalContentOffsetExcludingBounce - targetOffsetY) <= 0.5 else { return } + guard setNativeNormalizedContentOffsetY(targetOffsetY) else { return } pendingNativePostLayoutAlignmentOffsetY = nil + finishInitialOffsetAlignment() + } + + private func finishInitialOffsetAlignment() { + alignmentState.markInitialOffsetApplied() + alignmentState.cancelPendingTopAlignment() } @discardableResult From 916a8560e1e270aff7bb317264e9e409d4940005 Mon Sep 17 00:00:00 2001 From: pgs666 <74764545+pgs666@users.noreply.github.com> Date: Sun, 7 Jun 2026 22:39:00 +0800 Subject: [PATCH 186/216] Track active header during paging --- iosApp/VideoDetailPager.swift | 29 +++++++++++++++++++++++++---- 1 file changed, 25 insertions(+), 4 deletions(-) diff --git a/iosApp/VideoDetailPager.swift b/iosApp/VideoDetailPager.swift index 1fd3218b..7d579533 100644 --- a/iosApp/VideoDetailPager.swift +++ b/iosApp/VideoDetailPager.swift @@ -382,18 +382,24 @@ private struct VideoDetailListAlignmentState { private struct VideoDetailHorizontalPagerPosition: Equatable { private(set) var selectedIndex = 0 private(set) var isPagingActive = false + private(set) var activeHeaderIndex = 0 - var activeHeaderIndex: Int { - selectedIndex + mutating func setSelectedIndex(_ index: Int) { + let resolvedIndex = clamped(index) + selectedIndex = resolvedIndex + activeHeaderIndex = resolvedIndex } - mutating func setSelectedIndex(_ index: Int) { - selectedIndex = clamped(index) + mutating func setActiveHeaderIndex(_ index: Int) { + activeHeaderIndex = clamped(index) } mutating func setPagingActive(_ isActive: Bool) -> Bool { guard isPagingActive != isActive else { return false } isPagingActive = isActive + if !isActive { + activeHeaderIndex = selectedIndex + } return true } @@ -1397,6 +1403,7 @@ private struct VideoDetailTabPager: UIViewControllerRepresentable { var selectedTab: Binding var onSelectedIndexSettled: ((Int) -> Void)? var onPagingActivityChanged: ((Bool) -> Void)? + var onHorizontalPageScrolled: ((Int) -> Void)? private var lastProgrammaticIndex: Int? init(selectedTab: Binding) { @@ -1420,6 +1427,8 @@ private struct VideoDetailTabPager: UIViewControllerRepresentable { return } onPagingActivityChanged?(true) + let activeIndex = Int(floor(listScrollView.contentOffset.x / width)) + onHorizontalPageScrolled?(activeIndex) } func scrollViewDidEndDecelerating(_ listScrollView: UIScrollView) { @@ -1525,6 +1534,9 @@ private struct VideoDetailTabPager: UIViewControllerRepresentable { coordinator.onPagingActivityChanged = { [weak self] isActive in self?.setHorizontalPagingActive(isActive) } + coordinator.onHorizontalPageScrolled = { [weak self] index in + self?.updateActiveHeaderIndexDuringHorizontalScroll(index) + } view.addSubview(scrollView) contentView.translatesAutoresizingMaskIntoConstraints = false @@ -1717,6 +1729,15 @@ private struct VideoDetailTabPager: UIViewControllerRepresentable { } } + private func updateActiveHeaderIndexDuringHorizontalScroll(_ index: Int) { + guard pagerPosition.isPagingActive || scrollView.isDragging || scrollView.isDecelerating else { return } + let previousHeaderTab = activeHeaderTab + pagerPosition.setActiveHeaderIndex(index) + guard activeHeaderTab != previousHeaderTab else { return } + layoutHeaderHosts() + updateHeaderAttachmentForCurrentState() + } + private func deactivateHorizontalPagingForSettlement() { guard pagerPosition.setPagingActive(false) else { return } introductionPage.setHorizontalPagingActive(false) From 3dff5d6ef2ac885089a2b3e2a9e8ea03a19fbd36 Mon Sep 17 00:00:00 2001 From: pgs666 <74764545+pgs666@users.noreply.github.com> Date: Sun, 7 Jun 2026 22:54:23 +0800 Subject: [PATCH 187/216] Defer inactive sync while paging --- iosApp/VideoDetailPager.swift | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/iosApp/VideoDetailPager.swift b/iosApp/VideoDetailPager.swift index 7d579533..0ceb54ff 100644 --- a/iosApp/VideoDetailPager.swift +++ b/iosApp/VideoDetailPager.swift @@ -1746,6 +1746,9 @@ private struct VideoDetailTabPager: UIViewControllerRepresentable { @discardableResult private func syncInactivePageHeaderOffset(activeOffset providedActiveOffset: CGFloat? = nil) -> VideoDetailSmoothHeaderSyncState? { + guard !pagerPosition.isPagingActive else { + return headerSyncState + } guard let activePage = verticalPageController(for: VideoPageTab.page(at: pagerPosition.selectedIndex)) else { return nil } @@ -1932,8 +1935,9 @@ private struct VideoDetailTabPager: UIViewControllerRepresentable { ) applyHeaderAttachment(attachmentState, pageController: pageController) } else { - let syncState = syncInactivePageHeaderOffset(activeOffset: offsetY) - ?? page.headerGeometry.smoothHeaderSyncState(activeOffset: offsetY) + let syncState = page.headerGeometry.smoothHeaderSyncState(activeOffset: offsetY) + headerSyncState = syncState + syncInactivePageHeaderOffset(activeOffset: offsetY) let attachmentState = VideoDetailHeaderAttachmentState.state( isHorizontalPagingActive: false, canAttachToListHeader: pageController.canAttachHeaderToListHeader(for: page), From d77ddaea8c92f51be947c2c46fa3e4503a42d486 Mon Sep 17 00:00:00 2001 From: pgs666 <74764545+pgs666@users.noreply.github.com> Date: Sun, 7 Jun 2026 23:06:19 +0800 Subject: [PATCH 188/216] Use controller selection for native alignment --- iosApp/VideoDetailPager.swift | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/iosApp/VideoDetailPager.swift b/iosApp/VideoDetailPager.swift index 0ceb54ff..81d6fb38 100644 --- a/iosApp/VideoDetailPager.swift +++ b/iosApp/VideoDetailPager.swift @@ -993,8 +993,7 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle if isNativeScrollView { applyNativeGeometryAlignment( initialOffsetY: offsetContext.initialNormalizedOffsetY, - didApplyMinimumContentSize: didApplyNativeMinimumContentSize, - isCurrentPageSelected: page.isSelected + didApplyMinimumContentSize: didApplyNativeMinimumContentSize ) return } @@ -1135,8 +1134,7 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle private func applyNativeGeometryAlignment( initialOffsetY: CGFloat, - didApplyMinimumContentSize _: Bool, - isCurrentPageSelected: Bool + didApplyMinimumContentSize _: Bool ) { guard isCurrentPageSelected else { return } applyNativeInitialOffsetIfNeeded(initialOffsetY) From 47c195c583129b72e348518c45984c4ab61ce4b0 Mon Sep 17 00:00:00 2001 From: pgs666 <74764545+pgs666@users.noreply.github.com> Date: Sun, 7 Jun 2026 23:32:16 +0800 Subject: [PATCH 189/216] Stabilize native comments pager alignment --- iosApp/CommentListTableView.swift | 55 ++++++++++++++++++++++++++----- iosApp/VideoDetailPager.swift | 42 ++++++++++++++++------- iosApp/VideoDetailView.swift | 5 +++ 3 files changed, 83 insertions(+), 19 deletions(-) diff --git a/iosApp/CommentListTableView.swift b/iosApp/CommentListTableView.swift index 0759c925..352ff1d8 100644 --- a/iosApp/CommentListTableView.swift +++ b/iosApp/CommentListTableView.swift @@ -141,6 +141,8 @@ final class CommentListTableController: NSObject, UITableViewDataSource, UITable private var hasRenderedRows = false private var measuredCommentHeights: [String: MeasuredCommentHeight] = [:] private var isReloadingRows = false + private var contentBottomPadding: CGFloat = 0 + private var pendingContentBottomPadding: CGFloat? weak var scrollDelegate: UIScrollViewDelegate? func attach(_ tableView: UITableView) { @@ -193,6 +195,29 @@ final class CommentListTableController: NSObject, UITableViewDataSource, UITable reloadTablePreservingOffset() } + func updateContentBottomPadding(_ bottomPadding: CGFloat) { + let nextPadding = max(bottomPadding, 0) + guard abs(contentBottomPadding - nextPadding) > 0.5 else { return } + guard let tableView, hasRenderedRows else { + contentBottomPadding = nextPadding + return + } + guard !tableView.isTracking, !tableView.isDragging, !tableView.isDecelerating else { + pendingContentBottomPadding = nextPadding + return + } + applyContentBottomPadding(nextPadding, in: tableView) + } + + private func applyContentBottomPadding(_ nextPadding: CGFloat, in tableView: UITableView) { + contentBottomPadding = nextPadding + pendingContentBottomPadding = nil + UIView.performWithoutAnimation { + tableView.beginUpdates() + tableView.endUpdates() + } + } + func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { rows.count } @@ -250,7 +275,7 @@ final class CommentListTableController: NSObject, UITableViewDataSource, UITable withIdentifier: CommentListFooterCell.reuseIdentifier, for: indexPath ) as? CommentListFooterCell ?? CommentListFooterCell(style: .default, reuseIdentifier: CommentListFooterCell.reuseIdentifier) - cell.configure() + cell.configure(bottomPadding: contentBottomPadding) return cell case .comment(let comment): let cell = tableView.dequeueReusableCell( @@ -294,7 +319,7 @@ final class CommentListTableController: NSObject, UITableViewDataSource, UITable case .empty: return RowHeightEstimate.empty case .footer: - return RowHeightEstimate.footer + return RowHeightEstimate.footer + contentBottomPadding case .comment(let comment): guard let measuredHeight = measuredCommentHeights[comment.id], measuredHeight.signature == CommentSignature(comment) else { @@ -327,14 +352,19 @@ final class CommentListTableController: NSObject, UITableViewDataSource, UITable func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) { scrollDelegate?.scrollViewDidEndDragging?(scrollView, willDecelerate: decelerate) + if !decelerate { + applyPendingContentBottomPaddingIfNeeded() + } } func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) { scrollDelegate?.scrollViewDidEndDecelerating?(scrollView) + applyPendingContentBottomPaddingIfNeeded() } func scrollViewDidEndScrollingAnimation(_ scrollView: UIScrollView) { scrollDelegate?.scrollViewDidEndScrollingAnimation?(scrollView) + applyPendingContentBottomPaddingIfNeeded() } private func configure(_ tableView: UITableView) { @@ -449,6 +479,11 @@ final class CommentListTableController: NSObject, UITableViewDataSource, UITable height: roundedHeight ) } + + private func applyPendingContentBottomPaddingIfNeeded() { + guard let tableView, let pendingContentBottomPadding else { return } + applyContentBottomPadding(pendingContentBottomPadding, in: tableView) + } } private final class HostingCommentTableViewCell: UITableViewCell { @@ -635,13 +670,17 @@ private final class CommentListFooterCell: UITableViewCell { fatalError("init(coder:) has not been implemented") } - func configure() { + func configure(bottomPadding: CGFloat) { contentConfiguration = UIHostingConfiguration { - Text("comment.no_more") - .font(.footnote) - .foregroundStyle(.secondary) - .frame(maxWidth: .infinity) - .frame(height: 44) + VStack(spacing: 0) { + Text("comment.no_more") + .font(.footnote) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity) + .frame(height: 44) + Color.clear + .frame(height: max(bottomPadding, 0)) + } } .margins(.all, 0) } diff --git a/iosApp/VideoDetailPager.swift b/iosApp/VideoDetailPager.swift index 81d6fb38..b4435463 100644 --- a/iosApp/VideoDetailPager.swift +++ b/iosApp/VideoDetailPager.swift @@ -760,6 +760,7 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle private var nativeScrollDelegateAttachment: ((UIScrollViewDelegate?) -> Void)? private var isCurrentPageSelected = false private var pendingNativePostLayoutAlignmentOffsetY: CGFloat? + private var didSetNativeInitialOffset = false var onHeaderOffsetChanged: (VideoPageTab, CGFloat) -> Void = { _, _ in } init(tab: VideoPageTab, listScrollView: UIScrollView? = nil) { @@ -956,7 +957,7 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle resolvePendingSwiftUITopAlignmentIfPossible(targetOffsetY: pendingOffsetY) } case .nativeScrollView: - applyBottomContentSpacing(page.contentBottomPadding, usesContentSpacer: false) + applyBottomContentSpacing(page.contentBottomPadding, usesContentSpacer: true) collapseSpacerHeightConstraint?.constant = 0 contentMinimumHeightConstraint?.constant = 1 hostMinimumHeightConstraint?.isActive = false @@ -1084,6 +1085,17 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle } private func handleVerticalInteractionBegan() { + if isNativeListPage { + if let pendingOffsetY = pendingNativePostLayoutAlignmentOffsetY { + applyNativePendingAlignment(targetOffsetY: pendingOffsetY, allowDuringInteraction: true) + } else if alignmentState.needsInitialHeaderOffsetReset, let page = lastAppliedPage { + let targetOffsetY = page.headerGeometry.listOffsetContext( + in: listScrollView.bounds.height + ).initialNormalizedOffsetY + applyNativePendingAlignment(targetOffsetY: targetOffsetY, allowDuringInteraction: true) + } + return + } guard let pendingOffsetY = alignmentState.pendingSwiftUITopAlignmentOffsetY else { return } resolvePendingSwiftUITopAlignmentIfPossible(targetOffsetY: pendingOffsetY, allowDuringInteraction: true) } @@ -1125,11 +1137,7 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle private func applyNativeInitialOffsetIfNeeded(_ targetOffsetY: CGFloat) { guard isNativeListPage, alignmentState.needsInitialHeaderOffsetReset else { return } - guard setNativeNormalizedContentOffsetY(targetOffsetY) else { - pendingNativePostLayoutAlignmentOffsetY = targetOffsetY - return - } - finishInitialOffsetAlignment() + applyNativePendingAlignment(targetOffsetY: targetOffsetY) } private func applyNativeGeometryAlignment( @@ -1144,10 +1152,8 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle loadViewIfNeeded() isCurrentPageSelected = true if isNativeListPage { - pendingNativePostLayoutAlignmentOffsetY = targetOffsetY - listScrollView.setNeedsLayout() - listScrollView.layoutIfNeeded() - setNativeNormalizedContentOffsetY(targetOffsetY) + alignmentState.requestInitialOffsetReset() + applyNativePendingAlignment(targetOffsetY: targetOffsetY) applyPendingNativePostLayoutAlignmentIfNeeded() DispatchQueue.main.async { [weak self] in self?.applyPendingNativePostLayoutAlignmentIfNeeded() @@ -1176,6 +1182,7 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle setNativeNormalizedContentOffsetY(syncOffsetY) if consumeInitialReset { alignmentState.markInitialOffsetApplied() + pendingNativePostLayoutAlignmentOffsetY = nil } return } @@ -1234,10 +1241,23 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle private func applyPendingNativePostLayoutAlignmentIfNeeded() { guard isNativeListPage, let targetOffsetY = pendingNativePostLayoutAlignmentOffsetY else { return } - guard !listScrollView.isTracking, !listScrollView.isDragging, !listScrollView.isDecelerating else { return } + applyNativePendingAlignment(targetOffsetY: targetOffsetY) + } + + private func applyNativePendingAlignment( + targetOffsetY: CGFloat, + allowDuringInteraction: Bool = false + ) { + guard isNativeListPage else { return } + pendingNativePostLayoutAlignmentOffsetY = targetOffsetY + if !allowDuringInteraction { + guard !listScrollView.isTracking, !listScrollView.isDragging, !listScrollView.isDecelerating else { return } + } + guard isCurrentPageSelected || didSetNativeInitialOffset else { return } listScrollView.layoutIfNeeded() guard setNativeNormalizedContentOffsetY(targetOffsetY) else { return } pendingNativePostLayoutAlignmentOffsetY = nil + didSetNativeInitialOffset = true finishInitialOffsetAlignment() } diff --git a/iosApp/VideoDetailView.swift b/iosApp/VideoDetailView.swift index 5386e7d0..b4879807 100644 --- a/iosApp/VideoDetailView.swift +++ b/iosApp/VideoDetailView.swift @@ -14,6 +14,10 @@ private final class NativeCommentListHolder: ObservableObject { controller.update(model) } + func updateContentBottomPadding(_ bottomPadding: CGFloat) { + controller.updateContentBottomPadding(bottomPadding) + } + func attachScrollDelegate(_ delegate: UIScrollViewDelegate?) { controller.scrollDelegate = delegate } @@ -473,6 +477,7 @@ struct VideoDetailView: View { nativeCommentList.attachScrollDelegate(delegate) }, nativeCommentsUpdate: { + nativeCommentList.updateContentBottomPadding(composerContentClearance) nativeCommentList.update(commentTableModel) } ) From 0ee1ad8cac3fc7f4816fb26bfb2825091ea25c6d Mon Sep 17 00:00:00 2001 From: pgs666 <74764545+pgs666@users.noreply.github.com> Date: Sun, 7 Jun 2026 23:45:14 +0800 Subject: [PATCH 190/216] Settle horizontal pager state atomically --- iosApp/VideoDetailPager.swift | 57 +++++++++++++++++++++-------------- 1 file changed, 35 insertions(+), 22 deletions(-) diff --git a/iosApp/VideoDetailPager.swift b/iosApp/VideoDetailPager.swift index b4435463..443d6446 100644 --- a/iosApp/VideoDetailPager.swift +++ b/iosApp/VideoDetailPager.swift @@ -403,6 +403,16 @@ private struct VideoDetailHorizontalPagerPosition: Equatable { return true } + mutating func settleHorizontalPaging(at index: Int) -> (previousSelectedIndex: Int, wasPagingActive: Bool) { + let previousSelectedIndex = selectedIndex + let wasPagingActive = isPagingActive + let resolvedIndex = clamped(index) + selectedIndex = resolvedIndex + activeHeaderIndex = resolvedIndex + isPagingActive = false + return (previousSelectedIndex, wasPagingActive) + } + private func clamped(_ index: Int) -> Int { min(max(index, 0), VideoPageTab.allCases.count - 1) } @@ -1109,11 +1119,10 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle in: listScrollView.bounds.height ).initialNormalizedOffsetY if isNativeListPage { - guard setNativeNormalizedContentOffsetY(targetOffsetY) else { - pendingNativePostLayoutAlignmentOffsetY = targetOffsetY - return - } - finishInitialOffsetAlignment() + applyNativePendingAlignment( + targetOffsetY: targetOffsetY, + allowDuringInteraction: allowDuringInteraction + ) return } guard setNormalizedContentOffsetYForAlignment(targetOffsetY) else { @@ -1687,25 +1696,24 @@ private struct VideoDetailTabPager: UIViewControllerRepresentable { private func settlePageAfterHorizontalSelection(_ index: Int) { let clampedIndex = min(max(index, 0), VideoPageTab.allCases.count - 1) - let wasPagingActive = pagerPosition.isPagingActive - if pagerPosition.isPagingActive { - deactivateHorizontalPagingForSettlement() - } - finishHorizontalPaging(at: clampedIndex, wasPagingActive: wasPagingActive) + finishHorizontalPaging(at: clampedIndex) } - private func finishHorizontalPaging(at index: Int, wasPagingActive: Bool) { + private func finishHorizontalPaging(at index: Int) { let clampedIndex = min(max(index, 0), VideoPageTab.allCases.count - 1) - guard clampedIndex != pagerPosition.selectedIndex || wasPagingActive else { + let settlement = pagerPosition.settleHorizontalPaging(at: clampedIndex) + introductionPage.setHorizontalPagingActive(false) + commentsPage?.setHorizontalPagingActive(false) + guard clampedIndex != settlement.previousSelectedIndex || settlement.wasPagingActive else { pendingSelectedIndex = nil return } pendingSelectedIndex = nil - if let activePage = verticalPageController(for: VideoPageTab.page(at: pagerPosition.selectedIndex)) { - updateHeaderSyncState(activeOffset: activePage.normalizedContentOffsetY) + let previousTab = VideoPageTab.page(at: settlement.previousSelectedIndex) + if let activePage = verticalPageController(for: previousTab) { + updateHeaderSyncState(for: previousTab, activeOffset: activePage.normalizedContentOffsetY) } let activationOffset = headerSyncState.inactiveSyncMode.normalizedOffsetY - pagerPosition.setSelectedIndex(clampedIndex) refreshPageSelectionSnapshots(selectedTab: VideoPageTab.page(at: clampedIndex)) updateScrollsToTop(for: VideoPageTab.page(at: clampedIndex)) layoutHeaderHosts() @@ -1756,12 +1764,6 @@ private struct VideoDetailTabPager: UIViewControllerRepresentable { updateHeaderAttachmentForCurrentState() } - private func deactivateHorizontalPagingForSettlement() { - guard pagerPosition.setPagingActive(false) else { return } - introductionPage.setHorizontalPagingActive(false) - commentsPage?.setHorizontalPagingActive(false) - } - @discardableResult private func syncInactivePageHeaderOffset(activeOffset providedActiveOffset: CGFloat? = nil) -> VideoDetailSmoothHeaderSyncState? { guard !pagerPosition.isPagingActive else { @@ -1783,7 +1785,18 @@ private struct VideoDetailTabPager: UIViewControllerRepresentable { @discardableResult private func updateHeaderSyncState(activeOffset: CGFloat) -> VideoDetailSmoothHeaderSyncState { - guard let page = latestPages[VideoPageTab.page(at: pagerPosition.selectedIndex)] else { + updateHeaderSyncState( + for: VideoPageTab.page(at: pagerPosition.selectedIndex), + activeOffset: activeOffset + ) + } + + @discardableResult + private func updateHeaderSyncState( + for tab: VideoPageTab, + activeOffset: CGFloat + ) -> VideoDetailSmoothHeaderSyncState { + guard let page = latestPages[tab] else { return headerSyncState } headerSyncState = page.headerGeometry.smoothHeaderSyncState(activeOffset: activeOffset) From 59351a3d420999695da2b302f4b0fc1584b482ba Mon Sep 17 00:00:00 2001 From: pgs666 <74764545+pgs666@users.noreply.github.com> Date: Sun, 7 Jun 2026 23:56:25 +0800 Subject: [PATCH 191/216] Keep inactive native sync pending until reachable --- iosApp/VideoDetailPager.swift | 22 ++++++++++++++++------ 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/iosApp/VideoDetailPager.swift b/iosApp/VideoDetailPager.swift index 443d6446..edc91d92 100644 --- a/iosApp/VideoDetailPager.swift +++ b/iosApp/VideoDetailPager.swift @@ -1180,27 +1180,32 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle return listScrollView.verticalContentOffsetExcludingBounce } + @discardableResult func syncHeaderOffsetFromActivePage( _ syncMode: VideoDetailPagerOffsetModel.InactiveSyncMode, consumeInitialReset: Bool = true - ) { + ) -> Bool { loadViewIfNeeded() cancelPendingTopAlignment() let syncOffsetY = syncMode.normalizedOffsetY if isNativeListPage { - setNativeNormalizedContentOffsetY(syncOffsetY) + guard setNativeNormalizedContentOffsetY(syncOffsetY) else { + pendingNativePostLayoutAlignmentOffsetY = syncOffsetY + return false + } if consumeInitialReset { alignmentState.markInitialOffsetApplied() pendingNativePostLayoutAlignmentOffsetY = nil } - return + return true } guard setNormalizedContentOffsetYForAlignment(syncOffsetY) else { alignmentState.pendingSwiftUITopAlignmentOffsetY = syncOffsetY resolvePendingSwiftUITopAlignmentIfPossible(targetOffsetY: syncOffsetY) - return + return false } alignmentState.markInitialOffsetApplied() + return true } func setHorizontalPagingActive(_ isActive: Bool) { @@ -1774,11 +1779,16 @@ private struct VideoDetailTabPager: UIViewControllerRepresentable { } let activeOffset = providedActiveOffset ?? activePage.normalizedContentOffsetY let nextSyncState = updateHeaderSyncState(activeOffset: activeOffset) + var didSyncAllInactivePages = true for tab in VideoPageTab.allCases where tab.pageIndex != pagerPosition.selectedIndex { - verticalPageController(for: tab)?.syncHeaderOffsetFromActivePage( + let didSync = verticalPageController(for: tab)?.syncHeaderOffsetFromActivePage( nextSyncState.inactiveSyncMode, consumeInitialReset: false - ) + ) ?? true + didSyncAllInactivePages = didSyncAllInactivePages && didSync + } + if !didSyncAllInactivePages { + updateHeaderAttachmentForCurrentState() } return nextSyncState } From 1afd62087b62b3cd0207e073b588fe741cf1f421 Mon Sep 17 00:00:00 2001 From: pgs666 <74764545+pgs666@users.noreply.github.com> Date: Mon, 8 Jun 2026 00:07:48 +0800 Subject: [PATCH 192/216] Decouple inactive sync from header attachment --- iosApp/VideoDetailPager.swift | 27 ++++++++++++++++++++------- 1 file changed, 20 insertions(+), 7 deletions(-) diff --git a/iosApp/VideoDetailPager.swift b/iosApp/VideoDetailPager.swift index edc91d92..bf658124 100644 --- a/iosApp/VideoDetailPager.swift +++ b/iosApp/VideoDetailPager.swift @@ -92,6 +92,11 @@ private struct VideoDetailSmoothHeaderSyncState: Equatable { } } +private struct VideoDetailInactiveHeaderSyncResult { + let state: VideoDetailSmoothHeaderSyncState + let didSyncAllInactivePages: Bool +} + private enum VideoDetailHeaderAttachmentState: Equatable { case listHeader case pagerContainer(CGFloat) @@ -1731,7 +1736,12 @@ private struct VideoDetailTabPager: UIViewControllerRepresentable { commentsPage?.reportCurrentOffset() } if let activePage = verticalPageController(for: VideoPageTab.page(at: clampedIndex)) { - syncInactivePageHeaderOffset(activeOffset: activePage.normalizedContentOffsetY) + let syncResult = syncInactivePageHeaderOffset(activeOffset: activePage.normalizedContentOffsetY) + if syncResult?.didSyncAllInactivePages == false { + DispatchQueue.main.async { [weak self] in + self?.syncInactivePageHeaderOffset() + } + } } updateHeaderAttachmentForCurrentState() } @@ -1770,9 +1780,12 @@ private struct VideoDetailTabPager: UIViewControllerRepresentable { } @discardableResult - private func syncInactivePageHeaderOffset(activeOffset providedActiveOffset: CGFloat? = nil) -> VideoDetailSmoothHeaderSyncState? { + private func syncInactivePageHeaderOffset(activeOffset providedActiveOffset: CGFloat? = nil) -> VideoDetailInactiveHeaderSyncResult? { guard !pagerPosition.isPagingActive else { - return headerSyncState + return VideoDetailInactiveHeaderSyncResult( + state: headerSyncState, + didSyncAllInactivePages: false + ) } guard let activePage = verticalPageController(for: VideoPageTab.page(at: pagerPosition.selectedIndex)) else { return nil @@ -1787,10 +1800,10 @@ private struct VideoDetailTabPager: UIViewControllerRepresentable { ) ?? true didSyncAllInactivePages = didSyncAllInactivePages && didSync } - if !didSyncAllInactivePages { - updateHeaderAttachmentForCurrentState() - } - return nextSyncState + return VideoDetailInactiveHeaderSyncResult( + state: nextSyncState, + didSyncAllInactivePages: didSyncAllInactivePages + ) } @discardableResult From 0c8ec12f049e21587f46ec5d45c6e58a53d64cf9 Mon Sep 17 00:00:00 2001 From: pgs666 <74764545+pgs666@users.noreply.github.com> Date: Mon, 8 Jun 2026 00:19:19 +0800 Subject: [PATCH 193/216] Unify list pending alignment state --- iosApp/VideoDetailPager.swift | 40 +++++++++++++++++------------------ 1 file changed, 19 insertions(+), 21 deletions(-) diff --git a/iosApp/VideoDetailPager.swift b/iosApp/VideoDetailPager.swift index bf658124..311d06f4 100644 --- a/iosApp/VideoDetailPager.swift +++ b/iosApp/VideoDetailPager.swift @@ -364,15 +364,15 @@ private enum VideoDetailTabPageContent { } private struct VideoDetailListAlignmentState { - var pendingSwiftUITopAlignmentOffsetY: CGFloat? + var pendingTopAlignmentOffsetY: CGFloat? var needsInitialHeaderOffsetReset = true - var hasPendingSwiftUITopAlignment: Bool { - pendingSwiftUITopAlignmentOffsetY != nil + var hasPendingTopAlignment: Bool { + pendingTopAlignmentOffsetY != nil } mutating func cancelPendingTopAlignment() { - pendingSwiftUITopAlignmentOffsetY = nil + pendingTopAlignmentOffsetY = nil } mutating func markInitialOffsetApplied() { @@ -774,7 +774,6 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle private var listScrollViewBoundsObservation: NSKeyValueObservation? private var nativeScrollDelegateAttachment: ((UIScrollViewDelegate?) -> Void)? private var isCurrentPageSelected = false - private var pendingNativePostLayoutAlignmentOffsetY: CGFloat? private var didSetNativeInitialOffset = false var onHeaderOffsetChanged: (VideoPageTab, CGFloat) -> Void = { _, _ in } @@ -922,7 +921,7 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle coordinator.onVisibleOffsetChange = { [weak self] tab, offset in self?.onHeaderOffsetChanged(tab, offset) } - if !page.isSelected, !hasPendingSwiftUITopAlignment { + if !page.isSelected, !hasPendingTopAlignment { cancelPendingTopAlignment() } switch page.content { @@ -946,7 +945,7 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle host.rootView = AnyView(EmptyView()) } } - if !alignmentState.hasPendingSwiftUITopAlignment { + if !alignmentState.hasPendingTopAlignment { alignmentState.requestInitialOffsetReset() } view.setNeedsLayout() @@ -968,7 +967,7 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle hostMinimumHeightConstraint?.isActive = true if alignmentState.needsInitialHeaderOffsetReset { applyInitialHeaderOffsetResetIfNeeded() - } else if let pendingOffsetY = alignmentState.pendingSwiftUITopAlignmentOffsetY { + } else if let pendingOffsetY = alignmentState.pendingTopAlignmentOffsetY { resolvePendingSwiftUITopAlignmentIfPossible(targetOffsetY: pendingOffsetY) } case .nativeScrollView: @@ -1013,7 +1012,7 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle ) return } - if let pendingOffsetY = alignmentState.pendingSwiftUITopAlignmentOffsetY { + if let pendingOffsetY = alignmentState.pendingTopAlignmentOffsetY { resolvePendingSwiftUITopAlignmentIfPossible(targetOffsetY: pendingOffsetY) return } @@ -1101,7 +1100,7 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle private func handleVerticalInteractionBegan() { if isNativeListPage { - if let pendingOffsetY = pendingNativePostLayoutAlignmentOffsetY { + if let pendingOffsetY = alignmentState.pendingTopAlignmentOffsetY { applyNativePendingAlignment(targetOffsetY: pendingOffsetY, allowDuringInteraction: true) } else if alignmentState.needsInitialHeaderOffsetReset, let page = lastAppliedPage { let targetOffsetY = page.headerGeometry.listOffsetContext( @@ -1111,7 +1110,7 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle } return } - guard let pendingOffsetY = alignmentState.pendingSwiftUITopAlignmentOffsetY else { return } + guard let pendingOffsetY = alignmentState.pendingTopAlignmentOffsetY else { return } resolvePendingSwiftUITopAlignmentIfPossible(targetOffsetY: pendingOffsetY, allowDuringInteraction: true) } @@ -1131,7 +1130,7 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle return } guard setNormalizedContentOffsetYForAlignment(targetOffsetY) else { - alignmentState.pendingSwiftUITopAlignmentOffsetY = targetOffsetY + alignmentState.pendingTopAlignmentOffsetY = targetOffsetY resolvePendingSwiftUITopAlignmentIfPossible(targetOffsetY: targetOffsetY) return } @@ -1145,8 +1144,8 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle alignmentState.cancelPendingTopAlignment() } - private var hasPendingSwiftUITopAlignment: Bool { - alignmentState.hasPendingSwiftUITopAlignment + private var hasPendingTopAlignment: Bool { + alignmentState.hasPendingTopAlignment } private func applyNativeInitialOffsetIfNeeded(_ targetOffsetY: CGFloat) { @@ -1191,21 +1190,21 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle consumeInitialReset: Bool = true ) -> Bool { loadViewIfNeeded() - cancelPendingTopAlignment() let syncOffsetY = syncMode.normalizedOffsetY if isNativeListPage { guard setNativeNormalizedContentOffsetY(syncOffsetY) else { - pendingNativePostLayoutAlignmentOffsetY = syncOffsetY + alignmentState.pendingTopAlignmentOffsetY = syncOffsetY return false } if consumeInitialReset { alignmentState.markInitialOffsetApplied() - pendingNativePostLayoutAlignmentOffsetY = nil + alignmentState.cancelPendingTopAlignment() } return true } + cancelPendingTopAlignment() guard setNormalizedContentOffsetYForAlignment(syncOffsetY) else { - alignmentState.pendingSwiftUITopAlignmentOffsetY = syncOffsetY + alignmentState.pendingTopAlignmentOffsetY = syncOffsetY resolvePendingSwiftUITopAlignmentIfPossible(targetOffsetY: syncOffsetY) return false } @@ -1259,7 +1258,7 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle } private func applyPendingNativePostLayoutAlignmentIfNeeded() { - guard isNativeListPage, let targetOffsetY = pendingNativePostLayoutAlignmentOffsetY else { return } + guard isNativeListPage, let targetOffsetY = alignmentState.pendingTopAlignmentOffsetY else { return } applyNativePendingAlignment(targetOffsetY: targetOffsetY) } @@ -1268,14 +1267,13 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle allowDuringInteraction: Bool = false ) { guard isNativeListPage else { return } - pendingNativePostLayoutAlignmentOffsetY = targetOffsetY + alignmentState.pendingTopAlignmentOffsetY = targetOffsetY if !allowDuringInteraction { guard !listScrollView.isTracking, !listScrollView.isDragging, !listScrollView.isDecelerating else { return } } guard isCurrentPageSelected || didSetNativeInitialOffset else { return } listScrollView.layoutIfNeeded() guard setNativeNormalizedContentOffsetY(targetOffsetY) else { return } - pendingNativePostLayoutAlignmentOffsetY = nil didSetNativeInitialOffset = true finishInitialOffsetAlignment() } From cbf0463435e52b9e6249f4ecd4287050e5d538ef Mon Sep 17 00:00:00 2001 From: pgs666 <74764545+pgs666@users.noreply.github.com> Date: Mon, 8 Jun 2026 00:32:18 +0800 Subject: [PATCH 194/216] Protect native pager initial alignment --- iosApp/VideoDetailPager.swift | 35 +++++++++++++++++++++++++++++++---- 1 file changed, 31 insertions(+), 4 deletions(-) diff --git a/iosApp/VideoDetailPager.swift b/iosApp/VideoDetailPager.swift index 311d06f4..f7b1843d 100644 --- a/iosApp/VideoDetailPager.swift +++ b/iosApp/VideoDetailPager.swift @@ -366,6 +366,7 @@ private enum VideoDetailTabPageContent { private struct VideoDetailListAlignmentState { var pendingTopAlignmentOffsetY: CGFloat? var needsInitialHeaderOffsetReset = true + var isProtectingInitialTopAlignment = false var hasPendingTopAlignment: Bool { pendingTopAlignmentOffsetY != nil @@ -382,6 +383,16 @@ private struct VideoDetailListAlignmentState { mutating func requestInitialOffsetReset() { needsInitialHeaderOffsetReset = true } + + mutating func protectInitialTopAlignment() { + isProtectingInitialTopAlignment = true + } + + mutating func finishInitialTopAlignment() { + needsInitialHeaderOffsetReset = false + isProtectingInitialTopAlignment = false + pendingTopAlignmentOffsetY = nil + } } private struct VideoDetailHorizontalPagerPosition: Equatable { @@ -1099,6 +1110,7 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle } private func handleVerticalInteractionBegan() { + alignmentState.isProtectingInitialTopAlignment = false if isNativeListPage { if let pendingOffsetY = alignmentState.pendingTopAlignmentOffsetY { applyNativePendingAlignment(targetOffsetY: pendingOffsetY, allowDuringInteraction: true) @@ -1166,6 +1178,7 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle isCurrentPageSelected = true if isNativeListPage { alignmentState.requestInitialOffsetReset() + alignmentState.protectInitialTopAlignment() applyNativePendingAlignment(targetOffsetY: targetOffsetY) applyPendingNativePostLayoutAlignmentIfNeeded() DispatchQueue.main.async { [weak self] in @@ -1258,7 +1271,19 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle } private func applyPendingNativePostLayoutAlignmentIfNeeded() { - guard isNativeListPage, let targetOffsetY = alignmentState.pendingTopAlignmentOffsetY else { return } + guard isNativeListPage else { return } + let targetOffsetY: CGFloat? + if let pendingOffsetY = alignmentState.pendingTopAlignmentOffsetY { + targetOffsetY = pendingOffsetY + } else if alignmentState.isProtectingInitialTopAlignment, + let page = lastAppliedPage { + targetOffsetY = page.headerGeometry.listOffsetContext( + in: listScrollView.bounds.height + ).initialNormalizedOffsetY + } else { + targetOffsetY = nil + } + guard let targetOffsetY else { return } applyNativePendingAlignment(targetOffsetY: targetOffsetY) } @@ -1275,12 +1300,14 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle listScrollView.layoutIfNeeded() guard setNativeNormalizedContentOffsetY(targetOffsetY) else { return } didSetNativeInitialOffset = true - finishInitialOffsetAlignment() + alignmentState.markInitialOffsetApplied() + if !alignmentState.isProtectingInitialTopAlignment { + alignmentState.cancelPendingTopAlignment() + } } private func finishInitialOffsetAlignment() { - alignmentState.markInitialOffsetApplied() - alignmentState.cancelPendingTopAlignment() + alignmentState.finishInitialTopAlignment() } @discardableResult From a366d58a33fe86ebc84a1aa3af6943fa56a4419a Mon Sep 17 00:00:00 2001 From: pgs666 <74764545+pgs666@users.noreply.github.com> Date: Mon, 8 Jun 2026 00:53:41 +0800 Subject: [PATCH 195/216] Move comment bottom clearance to table footer --- iosApp/CommentListTableView.swift | 45 ++++++++++++++++++++----------- 1 file changed, 29 insertions(+), 16 deletions(-) diff --git a/iosApp/CommentListTableView.swift b/iosApp/CommentListTableView.swift index 352ff1d8..2a1c8e1f 100644 --- a/iosApp/CommentListTableView.swift +++ b/iosApp/CommentListTableView.swift @@ -143,6 +143,7 @@ final class CommentListTableController: NSObject, UITableViewDataSource, UITable private var isReloadingRows = false private var contentBottomPadding: CGFloat = 0 private var pendingContentBottomPadding: CGFloat? + private let contentBottomPaddingFooterView = UIView(frame: .zero) weak var scrollDelegate: UIScrollViewDelegate? func attach(_ tableView: UITableView) { @@ -212,10 +213,7 @@ final class CommentListTableController: NSObject, UITableViewDataSource, UITable private func applyContentBottomPadding(_ nextPadding: CGFloat, in tableView: UITableView) { contentBottomPadding = nextPadding pendingContentBottomPadding = nil - UIView.performWithoutAnimation { - tableView.beginUpdates() - tableView.endUpdates() - } + updateTableFooterView(in: tableView) } func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { @@ -275,7 +273,7 @@ final class CommentListTableController: NSObject, UITableViewDataSource, UITable withIdentifier: CommentListFooterCell.reuseIdentifier, for: indexPath ) as? CommentListFooterCell ?? CommentListFooterCell(style: .default, reuseIdentifier: CommentListFooterCell.reuseIdentifier) - cell.configure(bottomPadding: contentBottomPadding) + cell.configure() return cell case .comment(let comment): let cell = tableView.dequeueReusableCell( @@ -319,7 +317,7 @@ final class CommentListTableController: NSObject, UITableViewDataSource, UITable case .empty: return RowHeightEstimate.empty case .footer: - return RowHeightEstimate.footer + contentBottomPadding + return RowHeightEstimate.footer case .comment(let comment): guard let measuredHeight = measuredCommentHeights[comment.id], measuredHeight.signature == CommentSignature(comment) else { @@ -378,6 +376,8 @@ final class CommentListTableController: NSObject, UITableViewDataSource, UITable tableView.sectionHeaderHeight = 0 tableView.sectionFooterHeight = 0 tableView.contentInsetAdjustmentBehavior = .never + contentBottomPaddingFooterView.backgroundColor = .clear + tableView.tableFooterView = contentBottomPaddingFooterView tableView.register(HostingCommentTableViewCell.self, forCellReuseIdentifier: HostingCommentTableViewCell.reuseIdentifier) tableView.register(CommentListFooterCell.self, forCellReuseIdentifier: CommentListFooterCell.reuseIdentifier) } @@ -427,6 +427,7 @@ final class CommentListTableController: NSObject, UITableViewDataSource, UITable tableView.reloadData() tableView.layoutIfNeeded() hasRenderedRows = true + updateTableFooterView(in: tableView) return } let previousOffset = tableView.contentOffset @@ -441,6 +442,22 @@ final class CommentListTableController: NSObject, UITableViewDataSource, UITable clampedContentOffset(previousOffset, in: tableView), animated: false ) + updateTableFooterView(in: tableView) + } + } + + private func updateTableFooterView(in tableView: UITableView) { + let nextHeight = max(contentBottomPadding, 0) + var nextFrame = contentBottomPaddingFooterView.frame + nextFrame.size = CGSize(width: tableView.bounds.width, height: nextHeight) + guard abs(contentBottomPaddingFooterView.frame.height - nextHeight) > 0.5 + || abs(contentBottomPaddingFooterView.frame.width - tableView.bounds.width) > 0.5 + || tableView.tableFooterView !== contentBottomPaddingFooterView else { + return + } + UIView.performWithoutAnimation { + contentBottomPaddingFooterView.frame = nextFrame + tableView.tableFooterView = contentBottomPaddingFooterView } } @@ -670,17 +687,13 @@ private final class CommentListFooterCell: UITableViewCell { fatalError("init(coder:) has not been implemented") } - func configure(bottomPadding: CGFloat) { + func configure() { contentConfiguration = UIHostingConfiguration { - VStack(spacing: 0) { - Text("comment.no_more") - .font(.footnote) - .foregroundStyle(.secondary) - .frame(maxWidth: .infinity) - .frame(height: 44) - Color.clear - .frame(height: max(bottomPadding, 0)) - } + Text("comment.no_more") + .font(.footnote) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity) + .frame(height: 44) } .margins(.all, 0) } From 22b61a3d58ba2781a75f5d85300510697c53d297 Mon Sep 17 00:00:00 2001 From: pgs666 <74764545+pgs666@users.noreply.github.com> Date: Mon, 8 Jun 2026 01:21:45 +0800 Subject: [PATCH 196/216] Finish native initial alignment after stable layout --- iosApp/VideoDetailPager.swift | 25 ++++++++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/iosApp/VideoDetailPager.swift b/iosApp/VideoDetailPager.swift index f7b1843d..5a794bdd 100644 --- a/iosApp/VideoDetailPager.swift +++ b/iosApp/VideoDetailPager.swift @@ -786,6 +786,7 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle private var nativeScrollDelegateAttachment: ((UIScrollViewDelegate?) -> Void)? private var isCurrentPageSelected = false private var didSetNativeInitialOffset = false + private var nativeAlignmentGeometryRevision = 0 var onHeaderOffsetChanged: (VideoPageTab, CGFloat) -> Void = { _, _ in } init(tab: VideoPageTab, listScrollView: UIScrollView? = nil) { @@ -994,6 +995,7 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle } private func handleScrollGeometryChange() { + nativeAlignmentGeometryRevision &+= 1 applyCurrentPageGeometryRules() applyPendingNativePostLayoutAlignmentIfNeeded() } @@ -1110,6 +1112,7 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle } private func handleVerticalInteractionBegan() { + finishProtectedNativeAlignmentIfReached() alignmentState.isProtectingInitialTopAlignment = false if isNativeListPage { if let pendingOffsetY = alignmentState.pendingTopAlignmentOffsetY { @@ -1301,7 +1304,9 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle guard setNativeNormalizedContentOffsetY(targetOffsetY) else { return } didSetNativeInitialOffset = true alignmentState.markInitialOffsetApplied() - if !alignmentState.isProtectingInitialTopAlignment { + if alignmentState.isProtectingInitialTopAlignment { + scheduleProtectedNativeAlignmentFinishIfStable(targetOffsetY: targetOffsetY) + } else { alignmentState.cancelPendingTopAlignment() } } @@ -1310,6 +1315,24 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle alignmentState.finishInitialTopAlignment() } + private func scheduleProtectedNativeAlignmentFinishIfStable(targetOffsetY: CGFloat) { + let capturedRevision = nativeAlignmentGeometryRevision + DispatchQueue.main.async { [weak self] in + guard let self else { return } + guard self.nativeAlignmentGeometryRevision == capturedRevision else { return } + self.finishProtectedNativeAlignmentIfReached(targetOffsetY: targetOffsetY) + } + } + + private func finishProtectedNativeAlignmentIfReached(targetOffsetY: CGFloat? = nil) { + guard isNativeListPage, alignmentState.isProtectingInitialTopAlignment else { return } + let resolvedTargetOffsetY = targetOffsetY ?? alignmentState.pendingTopAlignmentOffsetY + guard let resolvedTargetOffsetY else { return } + guard !listScrollView.isTracking, !listScrollView.isDragging, !listScrollView.isDecelerating else { return } + guard abs(listScrollView.verticalContentOffsetExcludingBounce - resolvedTargetOffsetY) <= 0.5 else { return } + finishInitialOffsetAlignment() + } + @discardableResult private func setNormalizedContentOffsetYForAlignment(_ offsetY: CGFloat) -> Bool { return setNormalizedContentOffsetYIfReachable(offsetY) From 39daf34f7d6e80702a5938bc0d68cd39d70d11c0 Mon Sep 17 00:00:00 2001 From: pgs666 <74764545+pgs666@users.noreply.github.com> Date: Mon, 8 Jun 2026 01:41:30 +0800 Subject: [PATCH 197/216] Stop native comment refresh resetting pager offset --- iosApp/VideoDetailPager.swift | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/iosApp/VideoDetailPager.swift b/iosApp/VideoDetailPager.swift index 5a794bdd..51a9ecf2 100644 --- a/iosApp/VideoDetailPager.swift +++ b/iosApp/VideoDetailPager.swift @@ -945,6 +945,12 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle case .swiftUI: break } + let isNativeScrollPage: Bool + if case .nativeScrollView = page.content { + isNativeScrollPage = true + } else { + isNativeScrollPage = false + } if contentUpdateRevision != page.contentUpdateRevision { contentUpdateRevision = page.contentUpdateRevision switch page.content { @@ -957,7 +963,7 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle host.rootView = AnyView(EmptyView()) } } - if !alignmentState.hasPendingTopAlignment { + if !isNativeScrollPage, !alignmentState.hasPendingTopAlignment { alignmentState.requestInitialOffsetReset() } view.setNeedsLayout() From f5b00325431d2c5048e0c12ecf57f214b40654ea Mon Sep 17 00:00:00 2001 From: pgs666 <74764545+pgs666@users.noreply.github.com> Date: Mon, 8 Jun 2026 01:58:04 +0800 Subject: [PATCH 198/216] Finish native alignment before first vertical drag --- iosApp/VideoDetailPager.swift | 25 +++++++++++++++++++------ 1 file changed, 19 insertions(+), 6 deletions(-) diff --git a/iosApp/VideoDetailPager.swift b/iosApp/VideoDetailPager.swift index 51a9ecf2..db50f28d 100644 --- a/iosApp/VideoDetailPager.swift +++ b/iosApp/VideoDetailPager.swift @@ -1118,7 +1118,9 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle } private func handleVerticalInteractionBegan() { - finishProtectedNativeAlignmentIfReached() + if finishProtectedNativeAlignmentIfReached(allowDuringInteraction: true) { + return + } alignmentState.isProtectingInitialTopAlignment = false if isNativeListPage { if let pendingOffsetY = alignmentState.pendingTopAlignmentOffsetY { @@ -1330,13 +1332,24 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle } } - private func finishProtectedNativeAlignmentIfReached(targetOffsetY: CGFloat? = nil) { - guard isNativeListPage, alignmentState.isProtectingInitialTopAlignment else { return } + @discardableResult + private func finishProtectedNativeAlignmentIfReached( + targetOffsetY: CGFloat? = nil, + allowDuringInteraction: Bool = false + ) -> Bool { + guard isNativeListPage, alignmentState.isProtectingInitialTopAlignment else { return false } let resolvedTargetOffsetY = targetOffsetY ?? alignmentState.pendingTopAlignmentOffsetY - guard let resolvedTargetOffsetY else { return } - guard !listScrollView.isTracking, !listScrollView.isDragging, !listScrollView.isDecelerating else { return } - guard abs(listScrollView.verticalContentOffsetExcludingBounce - resolvedTargetOffsetY) <= 0.5 else { return } + guard let resolvedTargetOffsetY else { return false } + if !allowDuringInteraction { + guard !listScrollView.isTracking, !listScrollView.isDragging, !listScrollView.isDecelerating else { + return false + } + } + guard abs(listScrollView.verticalContentOffsetExcludingBounce - resolvedTargetOffsetY) <= 0.5 else { + return false + } finishInitialOffsetAlignment() + return true } @discardableResult From ee26e7e47755190c870f1d4b64e69be5f88ba832 Mon Sep 17 00:00:00 2001 From: pgs666 <74764545+pgs666@users.noreply.github.com> Date: Mon, 8 Jun 2026 02:20:35 +0800 Subject: [PATCH 199/216] Keep pager header in root view hierarchy --- iosApp/VideoDetailPager.swift | 75 +++++------------------------------ 1 file changed, 9 insertions(+), 66 deletions(-) diff --git a/iosApp/VideoDetailPager.swift b/iosApp/VideoDetailPager.swift index db50f28d..7145ffc8 100644 --- a/iosApp/VideoDetailPager.swift +++ b/iosApp/VideoDetailPager.swift @@ -97,25 +97,6 @@ private struct VideoDetailInactiveHeaderSyncResult { let didSyncAllInactivePages: Bool } -private enum VideoDetailHeaderAttachmentState: Equatable { - case listHeader - case pagerContainer(CGFloat) - - static func state( - isHorizontalPagingActive: Bool, - canAttachToListHeader: Bool, - syncState: VideoDetailSmoothHeaderSyncState - ) -> VideoDetailHeaderAttachmentState { - if isHorizontalPagingActive { - return .pagerContainer(syncState.headerContainerY) - } - if canAttachToListHeader { - return .listHeader - } - return .pagerContainer(syncState.headerContainerY) - } -} - private extension VideoDetailPagerOffsetModel.InactiveSyncMode { var normalizedOffsetY: CGFloat { switch self { @@ -1255,12 +1236,6 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle coordinator.onOffsetChange(coordinator.tab, offset) } - func canAttachHeaderToListHeader(for page: VideoDetailTabPage) -> Bool { - loadViewIfNeeded() - let pinnedVisibleHeight = max(page.headerGeometry.pinnedVisibleHeight, page.headerGeometry.pinHeaderHeight) - return listScrollView.contentOffset.y <= -pinnedVisibleHeight + 0.5 - } - private func setNormalizedContentOffsetY(_ offsetY: CGFloat) { guard let page = lastAppliedPage else { return } let rawTopOffsetY = page.headerGeometry.rawContentOffsetY(forNormalizedOffsetY: offsetY, in: listScrollView) @@ -1375,11 +1350,6 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle coordinator.resetReportedOffset(listScrollView.verticalContentOffsetExcludingBounce) } - func headerAttachmentView() -> UIView { - loadViewIfNeeded() - return listHeaderView - } - private func clampedRawContentOffsetY(forNormalizedOffsetY offsetY: CGFloat) -> CGFloat { guard let page = lastAppliedPage else { return 0 } return page.headerGeometry.rawContentOffsetY(forNormalizedOffsetY: offsetY, in: listScrollView) @@ -1658,6 +1628,7 @@ private struct VideoDetailTabPager: UIViewControllerRepresentable { pinHeaderHost.view.backgroundColor = .clear headerContainerView.addSubview(pinHeaderHost.view) pinHeaderHost.didMove(toParent: self) + view.addSubview(headerContainerView) updateScrollsToTop(for: VideoPageTab.page(at: pagerPosition.selectedIndex)) introductionPage.onHeaderOffsetChanged = { [weak self] tab, offset in @@ -2006,65 +1977,37 @@ private struct VideoDetailTabPager: UIViewControllerRepresentable { syncState = page.headerGeometry.smoothHeaderSyncState(activeOffset: offset) headerSyncState = syncState } - let attachmentState = VideoDetailHeaderAttachmentState.state( - isHorizontalPagingActive: pagerPosition.isPagingActive, - canAttachToListHeader: pageController.canAttachHeaderToListHeader(for: page), - syncState: syncState - ) - applyHeaderAttachment(attachmentState, pageController: pageController) - } - - private func applyHeaderAttachment( - _ attachmentState: VideoDetailHeaderAttachmentState, - pageController: VideoDetailVerticalScrollPageViewController - ) { - switch attachmentState { - case .listHeader: - attachHeader(to: pageController.headerAttachmentView(), originY: 0) - case .pagerContainer(let originY): - attachHeader(to: view, originY: originY) - } + attachHeader(originY: syncState.headerContainerY) } private var activeHeaderTab: VideoPageTab { VideoPageTab.page(at: pagerPosition.activeHeaderIndex) } - private func attachHeader(to parentView: UIView, originY: CGFloat) { - if headerContainerView.superview !== parentView { + private func attachHeader(originY: CGFloat) { + if headerContainerView.superview !== view { headerContainerView.removeFromSuperview() - parentView.addSubview(headerContainerView) + view.addSubview(headerContainerView) } var frame = headerContainerView.frame frame.origin = CGPoint(x: 0, y: originY) - frame.size.width = parentView.bounds.width + frame.size.width = view.bounds.width headerContainerView.frame = frame - parentView.bringSubviewToFront(headerContainerView) + view.bringSubviewToFront(headerContainerView) } private func updateHeaderContainerPosition(for tab: VideoPageTab, offsetY: CGFloat) { guard tab == activeHeaderTab else { return } guard let page = latestPages[tab] else { return } - guard let pageController = verticalPageController(for: tab) else { return } if pagerPosition.isPagingActive { let syncState = page.headerGeometry.smoothHeaderSyncState(activeOffset: offsetY) headerSyncState = syncState - let attachmentState = VideoDetailHeaderAttachmentState.state( - isHorizontalPagingActive: true, - canAttachToListHeader: pageController.canAttachHeaderToListHeader(for: page), - syncState: syncState - ) - applyHeaderAttachment(attachmentState, pageController: pageController) + attachHeader(originY: syncState.headerContainerY) } else { let syncState = page.headerGeometry.smoothHeaderSyncState(activeOffset: offsetY) headerSyncState = syncState syncInactivePageHeaderOffset(activeOffset: offsetY) - let attachmentState = VideoDetailHeaderAttachmentState.state( - isHorizontalPagingActive: false, - canAttachToListHeader: pageController.canAttachHeaderToListHeader(for: page), - syncState: syncState - ) - applyHeaderAttachment(attachmentState, pageController: pageController) + attachHeader(originY: syncState.headerContainerY) } } } From 178fc554137b6b254d7ae9af3ecc75dfe0e87640 Mon Sep 17 00:00:00 2001 From: pgs666 <74764545+pgs666@users.noreply.github.com> Date: Mon, 8 Jun 2026 02:32:08 +0800 Subject: [PATCH 200/216] Guard pager header root attachment in CI --- .github/workflows/ios-app-build.yml | 3 ++ .github/workflows/release.yml | 3 ++ iosApp/VideoDetailPager.swift | 3 +- scripts/check_video_pager_structure.sh | 40 ++++++++++++++++++++++++++ 4 files changed, 47 insertions(+), 2 deletions(-) create mode 100644 scripts/check_video_pager_structure.sh diff --git a/.github/workflows/ios-app-build.yml b/.github/workflows/ios-app-build.yml index 560b9e19..01bb973d 100644 --- a/.github/workflows/ios-app-build.yml +++ b/.github/workflows/ios-app-build.yml @@ -58,6 +58,9 @@ jobs: - name: Run shared JVM tests run: ./gradlew :shared:jvmTest --no-daemon + - name: Check video pager structure + run: bash scripts/check_video_pager_structure.sh + - name: Generate Xcode project run: xcodegen generate diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7bfcd340..6b8673e1 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -89,6 +89,9 @@ jobs: - name: Run shared JVM tests run: ./gradlew :shared:jvmTest --no-daemon + - name: Check video pager structure + run: bash scripts/check_video_pager_structure.sh + - name: Generate Xcode project run: xcodegen generate diff --git a/iosApp/VideoDetailPager.swift b/iosApp/VideoDetailPager.swift index 7145ffc8..15e1ac9e 100644 --- a/iosApp/VideoDetailPager.swift +++ b/iosApp/VideoDetailPager.swift @@ -1985,8 +1985,7 @@ private struct VideoDetailTabPager: UIViewControllerRepresentable { } private func attachHeader(originY: CGFloat) { - if headerContainerView.superview !== view { - headerContainerView.removeFromSuperview() + if headerContainerView.superview == nil { view.addSubview(headerContainerView) } var frame = headerContainerView.frame diff --git a/scripts/check_video_pager_structure.sh b/scripts/check_video_pager_structure.sh new file mode 100644 index 00000000..70490fc8 --- /dev/null +++ b/scripts/check_video_pager_structure.sh @@ -0,0 +1,40 @@ +#!/usr/bin/env bash +set -euo pipefail + +PAGER_FILE="${1:-iosApp/VideoDetailPager.swift}" + +if [ ! -f "$PAGER_FILE" ]; then + echo "Missing pager file: $PAGER_FILE" >&2 + exit 1 +fi + +forbidden_patterns=( + "VideoDetailHeaderAttachmentState" + "headerAttachmentView" + "canAttachHeaderToListHeader" + "applyHeaderAttachment" +) + +for pattern in "${forbidden_patterns[@]}"; do + if grep -n "$pattern" "$PAGER_FILE"; then + echo "Forbidden pager header attachment pattern found: $pattern" >&2 + exit 1 + fi +done + +if ! grep -q "view.addSubview(headerContainerView)" "$PAGER_FILE"; then + echo "Pager header must be attached to PagingViewController.view." >&2 + exit 1 +fi + +if grep -nE "[A-Za-z0-9_]+\\.addSubview\\(headerContainerView\\)" "$PAGER_FILE" \ + | grep -v "view.addSubview(headerContainerView)"; then + echo "Pager header must not be attached to a list/header child view." >&2 + exit 1 +fi + +if grep -n "headerContainerView.removeFromSuperview()" "$PAGER_FILE"; then + echo "Pager header should remain in the root view hierarchy." >&2 + exit 1 +fi + From b4d3acbb011bac5e2a7812ee79e539e0615539d8 Mon Sep 17 00:00:00 2001 From: pgs666 <74764545+pgs666@users.noreply.github.com> Date: Mon, 8 Jun 2026 02:43:33 +0800 Subject: [PATCH 201/216] Reduce comment pager scroll intervention --- iosApp/CommentListTableView.swift | 45 ++++++------------------------- iosApp/VideoDetailPager.swift | 29 ++++++++++---------- 2 files changed, 23 insertions(+), 51 deletions(-) diff --git a/iosApp/CommentListTableView.swift b/iosApp/CommentListTableView.swift index 2a1c8e1f..0a188cfc 100644 --- a/iosApp/CommentListTableView.swift +++ b/iosApp/CommentListTableView.swift @@ -142,8 +142,6 @@ final class CommentListTableController: NSObject, UITableViewDataSource, UITable private var measuredCommentHeights: [String: MeasuredCommentHeight] = [:] private var isReloadingRows = false private var contentBottomPadding: CGFloat = 0 - private var pendingContentBottomPadding: CGFloat? - private let contentBottomPaddingFooterView = UIView(frame: .zero) weak var scrollDelegate: UIScrollViewDelegate? func attach(_ tableView: UITableView) { @@ -199,21 +197,22 @@ final class CommentListTableController: NSObject, UITableViewDataSource, UITable func updateContentBottomPadding(_ bottomPadding: CGFloat) { let nextPadding = max(bottomPadding, 0) guard abs(contentBottomPadding - nextPadding) > 0.5 else { return } - guard let tableView, hasRenderedRows else { + guard let tableView else { contentBottomPadding = nextPadding return } - guard !tableView.isTracking, !tableView.isDragging, !tableView.isDecelerating else { - pendingContentBottomPadding = nextPadding - return - } applyContentBottomPadding(nextPadding, in: tableView) } private func applyContentBottomPadding(_ nextPadding: CGFloat, in tableView: UITableView) { contentBottomPadding = nextPadding - pendingContentBottomPadding = nil - updateTableFooterView(in: tableView) + let bottomInset = max(nextPadding, 0) + if abs(tableView.contentInset.bottom - bottomInset) > 0.5 { + tableView.contentInset.bottom = bottomInset + } + if abs(tableView.verticalScrollIndicatorInsets.bottom - bottomInset) > 0.5 { + tableView.verticalScrollIndicatorInsets.bottom = bottomInset + } } func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { @@ -350,19 +349,14 @@ final class CommentListTableController: NSObject, UITableViewDataSource, UITable func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) { scrollDelegate?.scrollViewDidEndDragging?(scrollView, willDecelerate: decelerate) - if !decelerate { - applyPendingContentBottomPaddingIfNeeded() - } } func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) { scrollDelegate?.scrollViewDidEndDecelerating?(scrollView) - applyPendingContentBottomPaddingIfNeeded() } func scrollViewDidEndScrollingAnimation(_ scrollView: UIScrollView) { scrollDelegate?.scrollViewDidEndScrollingAnimation?(scrollView) - applyPendingContentBottomPaddingIfNeeded() } private func configure(_ tableView: UITableView) { @@ -376,8 +370,6 @@ final class CommentListTableController: NSObject, UITableViewDataSource, UITable tableView.sectionHeaderHeight = 0 tableView.sectionFooterHeight = 0 tableView.contentInsetAdjustmentBehavior = .never - contentBottomPaddingFooterView.backgroundColor = .clear - tableView.tableFooterView = contentBottomPaddingFooterView tableView.register(HostingCommentTableViewCell.self, forCellReuseIdentifier: HostingCommentTableViewCell.reuseIdentifier) tableView.register(CommentListFooterCell.self, forCellReuseIdentifier: CommentListFooterCell.reuseIdentifier) } @@ -427,7 +419,6 @@ final class CommentListTableController: NSObject, UITableViewDataSource, UITable tableView.reloadData() tableView.layoutIfNeeded() hasRenderedRows = true - updateTableFooterView(in: tableView) return } let previousOffset = tableView.contentOffset @@ -442,22 +433,6 @@ final class CommentListTableController: NSObject, UITableViewDataSource, UITable clampedContentOffset(previousOffset, in: tableView), animated: false ) - updateTableFooterView(in: tableView) - } - } - - private func updateTableFooterView(in tableView: UITableView) { - let nextHeight = max(contentBottomPadding, 0) - var nextFrame = contentBottomPaddingFooterView.frame - nextFrame.size = CGSize(width: tableView.bounds.width, height: nextHeight) - guard abs(contentBottomPaddingFooterView.frame.height - nextHeight) > 0.5 - || abs(contentBottomPaddingFooterView.frame.width - tableView.bounds.width) > 0.5 - || tableView.tableFooterView !== contentBottomPaddingFooterView else { - return - } - UIView.performWithoutAnimation { - contentBottomPaddingFooterView.frame = nextFrame - tableView.tableFooterView = contentBottomPaddingFooterView } } @@ -497,10 +472,6 @@ final class CommentListTableController: NSObject, UITableViewDataSource, UITable ) } - private func applyPendingContentBottomPaddingIfNeeded() { - guard let tableView, let pendingContentBottomPadding else { return } - applyContentBottomPadding(pendingContentBottomPadding, in: tableView) - } } private final class HostingCommentTableViewCell: UITableViewCell { diff --git a/iosApp/VideoDetailPager.swift b/iosApp/VideoDetailPager.swift index 15e1ac9e..7f9c93bb 100644 --- a/iosApp/VideoDetailPager.swift +++ b/iosApp/VideoDetailPager.swift @@ -1102,18 +1102,11 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle if finishProtectedNativeAlignmentIfReached(allowDuringInteraction: true) { return } - alignmentState.isProtectingInitialTopAlignment = false if isNativeListPage { - if let pendingOffsetY = alignmentState.pendingTopAlignmentOffsetY { - applyNativePendingAlignment(targetOffsetY: pendingOffsetY, allowDuringInteraction: true) - } else if alignmentState.needsInitialHeaderOffsetReset, let page = lastAppliedPage { - let targetOffsetY = page.headerGeometry.listOffsetContext( - in: listScrollView.bounds.height - ).initialNormalizedOffsetY - applyNativePendingAlignment(targetOffsetY: targetOffsetY, allowDuringInteraction: true) - } + alignmentState.finishInitialTopAlignment() return } + alignmentState.isProtectingInitialTopAlignment = false guard let pendingOffsetY = alignmentState.pendingTopAlignmentOffsetY else { return } resolvePendingSwiftUITopAlignmentIfPossible(targetOffsetY: pendingOffsetY, allowDuringInteraction: true) } @@ -2028,7 +2021,18 @@ private struct VideoDetailTabPager: UIViewControllerRepresentable { var continuationHeaderHeight: CGFloat = 0 var isContinuationHeaderInteractive = false + override func point(inside point: CGPoint, with event: UIEvent?) -> Bool { + containsInteractiveHeaderPoint(point) + } + override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { + guard containsInteractiveHeaderPoint(point) else { + return nil + } + return super.hitTest(point, with: event) + } + + private func containsInteractiveHeaderPoint(_ point: CGPoint) -> Bool { let continuationHeaderFrame = CGRect( x: 0, y: max(bounds.height - pinHeaderHeight - continuationHeaderHeight, 0), @@ -2041,11 +2045,8 @@ private struct VideoDetailTabPager: UIViewControllerRepresentable { width: bounds.width, height: pinHeaderHeight ) - guard pinHeaderFrame.contains(point) - || (isContinuationHeaderInteractive && continuationHeaderFrame.contains(point)) else { - return nil - } - return super.hitTest(point, with: event) + return pinHeaderFrame.contains(point) + || (isContinuationHeaderInteractive && continuationHeaderFrame.contains(point)) } } } From 1b338bc7cacdc35fdddd4a4eb1598d6bbca17265 Mon Sep 17 00:00:00 2001 From: pgs666 <74764545+pgs666@users.noreply.github.com> Date: Mon, 8 Jun 2026 02:57:25 +0800 Subject: [PATCH 202/216] Simplify video pager scroll alignment --- iosApp/VideoDetailPager.swift | 325 ++-------------------------------- 1 file changed, 13 insertions(+), 312 deletions(-) diff --git a/iosApp/VideoDetailPager.swift b/iosApp/VideoDetailPager.swift index 7f9c93bb..7e66ab10 100644 --- a/iosApp/VideoDetailPager.swift +++ b/iosApp/VideoDetailPager.swift @@ -92,11 +92,6 @@ private struct VideoDetailSmoothHeaderSyncState: Equatable { } } -private struct VideoDetailInactiveHeaderSyncResult { - let state: VideoDetailSmoothHeaderSyncState - let didSyncAllInactivePages: Bool -} - private extension VideoDetailPagerOffsetModel.InactiveSyncMode { var normalizedOffsetY: CGFloat { switch self { @@ -344,38 +339,6 @@ private enum VideoDetailTabPageContent { case nativeScrollView(VideoDetailNativeScrollPage) } -private struct VideoDetailListAlignmentState { - var pendingTopAlignmentOffsetY: CGFloat? - var needsInitialHeaderOffsetReset = true - var isProtectingInitialTopAlignment = false - - var hasPendingTopAlignment: Bool { - pendingTopAlignmentOffsetY != nil - } - - mutating func cancelPendingTopAlignment() { - pendingTopAlignmentOffsetY = nil - } - - mutating func markInitialOffsetApplied() { - needsInitialHeaderOffsetReset = false - } - - mutating func requestInitialOffsetReset() { - needsInitialHeaderOffsetReset = true - } - - mutating func protectInitialTopAlignment() { - isProtectingInitialTopAlignment = true - } - - mutating func finishInitialTopAlignment() { - needsInitialHeaderOffsetReset = false - isProtectingInitialTopAlignment = false - pendingTopAlignmentOffsetY = nil - } -} - private struct VideoDetailHorizontalPagerPosition: Equatable { private(set) var selectedIndex = 0 private(set) var isPagingActive = false @@ -761,13 +724,9 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle private var contentBottomSpacerHeightConstraint: NSLayoutConstraint? private var contentUpdateRevision: Int? private var lastAppliedPage: VideoDetailTabPage? - private var alignmentState = VideoDetailListAlignmentState() private var listScrollViewContentSizeObservation: NSKeyValueObservation? private var listScrollViewBoundsObservation: NSKeyValueObservation? private var nativeScrollDelegateAttachment: ((UIScrollViewDelegate?) -> Void)? - private var isCurrentPageSelected = false - private var didSetNativeInitialOffset = false - private var nativeAlignmentGeometryRevision = 0 var onHeaderOffsetChanged: (VideoPageTab, CGFloat) -> Void = { _, _ in } init(tab: VideoPageTab, listScrollView: UIScrollView? = nil) { @@ -898,12 +857,10 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle super.viewDidLayoutSubviews() applyListHeaderFrame() handleScrollGeometryChange() - applyPendingNativePostLayoutAlignmentIfNeeded() } func update(page: VideoDetailTabPage) { loadViewIfNeeded() - isCurrentPageSelected = page.isSelected coordinator.tab = page.tab coordinator.onOffsetChange = page.onOffsetChange coordinator.onInteractionBegan = page.onInteractionBegan @@ -914,9 +871,6 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle coordinator.onVisibleOffsetChange = { [weak self] tab, offset in self?.onHeaderOffsetChanged(tab, offset) } - if !page.isSelected, !hasPendingTopAlignment { - cancelPendingTopAlignment() - } switch page.content { case .nativeScrollView(let nativePage): if nativeScrollDelegateAttachment == nil { @@ -926,12 +880,6 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle case .swiftUI: break } - let isNativeScrollPage: Bool - if case .nativeScrollView = page.content { - isNativeScrollPage = true - } else { - isNativeScrollPage = false - } if contentUpdateRevision != page.contentUpdateRevision { contentUpdateRevision = page.contentUpdateRevision switch page.content { @@ -944,9 +892,6 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle host.rootView = AnyView(EmptyView()) } } - if !isNativeScrollPage, !alignmentState.hasPendingTopAlignment { - alignmentState.requestInitialOffsetReset() - } view.setNeedsLayout() view.layoutIfNeeded() } @@ -964,11 +909,6 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle collapseSpacerHeightConstraint?.constant = offsetContext.collapseSpacerHeight contentMinimumHeightConstraint?.constant = offsetContext.minimumContentHeight hostMinimumHeightConstraint?.isActive = true - if alignmentState.needsInitialHeaderOffsetReset { - applyInitialHeaderOffsetResetIfNeeded() - } else if let pendingOffsetY = alignmentState.pendingTopAlignmentOffsetY { - resolvePendingSwiftUITopAlignmentIfPossible(targetOffsetY: pendingOffsetY) - } case .nativeScrollView: applyBottomContentSpacing(page.contentBottomPadding, usesContentSpacer: true) collapseSpacerHeightConstraint?.constant = 0 @@ -982,9 +922,7 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle } private func handleScrollGeometryChange() { - nativeAlignmentGeometryRevision &+= 1 applyCurrentPageGeometryRules() - applyPendingNativePostLayoutAlignmentIfNeeded() } private func applyCurrentPageGeometryRules() { @@ -1001,24 +939,10 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle abs(contentMinimumHeightConstraint.constant - offsetContext.minimumContentHeight) > 0.5 { contentMinimumHeightConstraint.constant = isNativeScrollView ? 1 : offsetContext.minimumContentHeight } - let didApplyNativeMinimumContentSize = applyNativeMinimumContentSizeIfNeeded( + applyNativeMinimumContentSizeIfNeeded( page: page, offsetContext: offsetContext ) - if isNativeScrollView { - applyNativeGeometryAlignment( - initialOffsetY: offsetContext.initialNormalizedOffsetY, - didApplyMinimumContentSize: didApplyNativeMinimumContentSize - ) - return - } - if let pendingOffsetY = alignmentState.pendingTopAlignmentOffsetY { - resolvePendingSwiftUITopAlignmentIfPossible(targetOffsetY: pendingOffsetY) - return - } - if alignmentState.needsInitialHeaderOffsetReset { - applyInitialHeaderOffsetResetIfNeeded() - } } private func applyTopContentInset(_ topInset: CGFloat) { @@ -1083,98 +1007,14 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle listHeaderView.frame = nextFrame } - private func resolvePendingSwiftUITopAlignmentIfPossible( - targetOffsetY: CGFloat, - allowDuringInteraction: Bool = false - ) { - guard !isNativeListPage else { return } - if !allowDuringInteraction { - guard !listScrollView.isTracking, !listScrollView.isDragging, !listScrollView.isDecelerating else { return } - } - guard setNormalizedContentOffsetYForAlignment(targetOffsetY) else { return } - if abs(listScrollView.verticalContentOffsetExcludingBounce - targetOffsetY) <= 0.5 { - alignmentState.markInitialOffsetApplied() - alignmentState.cancelPendingTopAlignment() - } - } - private func handleVerticalInteractionBegan() { - if finishProtectedNativeAlignmentIfReached(allowDuringInteraction: true) { - return - } - if isNativeListPage { - alignmentState.finishInitialTopAlignment() - return - } - alignmentState.isProtectingInitialTopAlignment = false - guard let pendingOffsetY = alignmentState.pendingTopAlignmentOffsetY else { return } - resolvePendingSwiftUITopAlignmentIfPossible(targetOffsetY: pendingOffsetY, allowDuringInteraction: true) - } - - private func applyInitialHeaderOffsetResetIfNeeded(allowDuringInteraction: Bool = false) { - guard alignmentState.needsInitialHeaderOffsetReset, let page = lastAppliedPage else { return } - if !allowDuringInteraction { - guard !listScrollView.isTracking, !listScrollView.isDragging, !listScrollView.isDecelerating else { return } - } - let targetOffsetY = page.headerGeometry.listOffsetContext( - in: listScrollView.bounds.height - ).initialNormalizedOffsetY - if isNativeListPage { - applyNativePendingAlignment( - targetOffsetY: targetOffsetY, - allowDuringInteraction: allowDuringInteraction - ) - return - } - guard setNormalizedContentOffsetYForAlignment(targetOffsetY) else { - alignmentState.pendingTopAlignmentOffsetY = targetOffsetY - resolvePendingSwiftUITopAlignmentIfPossible(targetOffsetY: targetOffsetY) - return - } - if abs(listScrollView.verticalContentOffsetExcludingBounce - targetOffsetY) <= 0.5 { - alignmentState.markInitialOffsetApplied() - alignmentState.cancelPendingTopAlignment() - } - } - - private func cancelPendingTopAlignment() { - alignmentState.cancelPendingTopAlignment() - } - - private var hasPendingTopAlignment: Bool { - alignmentState.hasPendingTopAlignment - } - - private func applyNativeInitialOffsetIfNeeded(_ targetOffsetY: CGFloat) { - guard isNativeListPage, alignmentState.needsInitialHeaderOffsetReset else { return } - applyNativePendingAlignment(targetOffsetY: targetOffsetY) - } - - private func applyNativeGeometryAlignment( - initialOffsetY: CGFloat, - didApplyMinimumContentSize _: Bool - ) { - guard isCurrentPageSelected else { return } - applyNativeInitialOffsetIfNeeded(initialOffsetY) + guard let page = lastAppliedPage else { return } + coordinator.visualTopContentOffsetY = page.headerGeometry.resolvedVisualTopOffset } func settleAfterHorizontalActivation(targetOffsetY: CGFloat) { loadViewIfNeeded() - isCurrentPageSelected = true - if isNativeListPage { - alignmentState.requestInitialOffsetReset() - alignmentState.protectInitialTopAlignment() - applyNativePendingAlignment(targetOffsetY: targetOffsetY) - applyPendingNativePostLayoutAlignmentIfNeeded() - DispatchQueue.main.async { [weak self] in - self?.applyPendingNativePostLayoutAlignmentIfNeeded() - } - } else { - setNormalizedContentOffsetY(targetOffsetY) - } - if !isNativeListPage || abs(listScrollView.verticalContentOffsetExcludingBounce - targetOffsetY) <= 0.5 { - finishInitialOffsetAlignment() - } + syncHeaderOffsetFromActivePage(.visualTop(targetOffsetY)) } var normalizedContentOffsetY: CGFloat { @@ -1183,31 +1023,13 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle } @discardableResult - func syncHeaderOffsetFromActivePage( - _ syncMode: VideoDetailPagerOffsetModel.InactiveSyncMode, - consumeInitialReset: Bool = true - ) -> Bool { + func syncHeaderOffsetFromActivePage(_ syncMode: VideoDetailPagerOffsetModel.InactiveSyncMode) -> Bool { loadViewIfNeeded() - let syncOffsetY = syncMode.normalizedOffsetY - if isNativeListPage { - guard setNativeNormalizedContentOffsetY(syncOffsetY) else { - alignmentState.pendingTopAlignmentOffsetY = syncOffsetY - return false - } - if consumeInitialReset { - alignmentState.markInitialOffsetApplied() - alignmentState.cancelPendingTopAlignment() - } - return true - } - cancelPendingTopAlignment() - guard setNormalizedContentOffsetYForAlignment(syncOffsetY) else { - alignmentState.pendingTopAlignmentOffsetY = syncOffsetY - resolvePendingSwiftUITopAlignmentIfPossible(targetOffsetY: syncOffsetY) + guard !listScrollView.isTracking, !listScrollView.isDragging, !listScrollView.isDecelerating else { return false } - alignmentState.markInitialOffsetApplied() - return true + let syncOffsetY = syncMode.normalizedOffsetY + return setNormalizedContentOffsetYIfReachable(syncOffsetY) } func setHorizontalPagingActive(_ isActive: Bool) { @@ -1229,102 +1051,6 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle coordinator.onOffsetChange(coordinator.tab, offset) } - private func setNormalizedContentOffsetY(_ offsetY: CGFloat) { - guard let page = lastAppliedPage else { return } - let rawTopOffsetY = page.headerGeometry.rawContentOffsetY(forNormalizedOffsetY: offsetY, in: listScrollView) - setRawContentOffsetYSilentlyIfNeeded(rawTopOffsetY) - } - - @discardableResult - private func setNativeNormalizedContentOffsetY(_ offsetY: CGFloat) -> Bool { - guard isNativeListPage else { - setNormalizedContentOffsetY(offsetY) - return abs(listScrollView.verticalContentOffsetExcludingBounce - offsetY) <= 0.5 - } - listScrollView.layoutIfNeeded() - let rawTopOffsetY = listScrollView.clampedRawVerticalContentOffsetY( - offsetY - listScrollView.contentInset.top - ) - setRawContentOffsetYSilentlyIfNeeded(rawTopOffsetY) - return abs(listScrollView.verticalContentOffsetExcludingBounce - offsetY) <= 0.5 - } - - private func applyPendingNativePostLayoutAlignmentIfNeeded() { - guard isNativeListPage else { return } - let targetOffsetY: CGFloat? - if let pendingOffsetY = alignmentState.pendingTopAlignmentOffsetY { - targetOffsetY = pendingOffsetY - } else if alignmentState.isProtectingInitialTopAlignment, - let page = lastAppliedPage { - targetOffsetY = page.headerGeometry.listOffsetContext( - in: listScrollView.bounds.height - ).initialNormalizedOffsetY - } else { - targetOffsetY = nil - } - guard let targetOffsetY else { return } - applyNativePendingAlignment(targetOffsetY: targetOffsetY) - } - - private func applyNativePendingAlignment( - targetOffsetY: CGFloat, - allowDuringInteraction: Bool = false - ) { - guard isNativeListPage else { return } - alignmentState.pendingTopAlignmentOffsetY = targetOffsetY - if !allowDuringInteraction { - guard !listScrollView.isTracking, !listScrollView.isDragging, !listScrollView.isDecelerating else { return } - } - guard isCurrentPageSelected || didSetNativeInitialOffset else { return } - listScrollView.layoutIfNeeded() - guard setNativeNormalizedContentOffsetY(targetOffsetY) else { return } - didSetNativeInitialOffset = true - alignmentState.markInitialOffsetApplied() - if alignmentState.isProtectingInitialTopAlignment { - scheduleProtectedNativeAlignmentFinishIfStable(targetOffsetY: targetOffsetY) - } else { - alignmentState.cancelPendingTopAlignment() - } - } - - private func finishInitialOffsetAlignment() { - alignmentState.finishInitialTopAlignment() - } - - private func scheduleProtectedNativeAlignmentFinishIfStable(targetOffsetY: CGFloat) { - let capturedRevision = nativeAlignmentGeometryRevision - DispatchQueue.main.async { [weak self] in - guard let self else { return } - guard self.nativeAlignmentGeometryRevision == capturedRevision else { return } - self.finishProtectedNativeAlignmentIfReached(targetOffsetY: targetOffsetY) - } - } - - @discardableResult - private func finishProtectedNativeAlignmentIfReached( - targetOffsetY: CGFloat? = nil, - allowDuringInteraction: Bool = false - ) -> Bool { - guard isNativeListPage, alignmentState.isProtectingInitialTopAlignment else { return false } - let resolvedTargetOffsetY = targetOffsetY ?? alignmentState.pendingTopAlignmentOffsetY - guard let resolvedTargetOffsetY else { return false } - if !allowDuringInteraction { - guard !listScrollView.isTracking, !listScrollView.isDragging, !listScrollView.isDecelerating else { - return false - } - } - guard abs(listScrollView.verticalContentOffsetExcludingBounce - resolvedTargetOffsetY) <= 0.5 else { - return false - } - finishInitialOffsetAlignment() - return true - } - - @discardableResult - private func setNormalizedContentOffsetYForAlignment(_ offsetY: CGFloat) -> Bool { - return setNormalizedContentOffsetYIfReachable(offsetY) - } - @discardableResult private func setNormalizedContentOffsetYIfReachable(_ offsetY: CGFloat) -> Bool { let rawTopOffsetY = clampedRawContentOffsetY(forNormalizedOffsetY: offsetY) @@ -1353,13 +1079,6 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle return page.headerGeometry.normalizedContentOffsetY(forRawOffsetY: rawOffsetY, in: listScrollView) } - private var isNativeListPage: Bool { - guard let page = lastAppliedPage else { return false } - if case .nativeScrollView = page.content { - return true - } - return false - } } private final class VerticalScrollView: UIScrollView { @@ -1767,12 +1486,7 @@ private struct VideoDetailTabPager: UIViewControllerRepresentable { commentsPage?.reportCurrentOffset() } if let activePage = verticalPageController(for: VideoPageTab.page(at: clampedIndex)) { - let syncResult = syncInactivePageHeaderOffset(activeOffset: activePage.normalizedContentOffsetY) - if syncResult?.didSyncAllInactivePages == false { - DispatchQueue.main.async { [weak self] in - self?.syncInactivePageHeaderOffset() - } - } + syncInactivePageHeaderOffset(activeOffset: activePage.normalizedContentOffsetY) } updateHeaderAttachmentForCurrentState() } @@ -1810,31 +1524,18 @@ private struct VideoDetailTabPager: UIViewControllerRepresentable { updateHeaderAttachmentForCurrentState() } - @discardableResult - private func syncInactivePageHeaderOffset(activeOffset providedActiveOffset: CGFloat? = nil) -> VideoDetailInactiveHeaderSyncResult? { + private func syncInactivePageHeaderOffset(activeOffset providedActiveOffset: CGFloat? = nil) { guard !pagerPosition.isPagingActive else { - return VideoDetailInactiveHeaderSyncResult( - state: headerSyncState, - didSyncAllInactivePages: false - ) + return } guard let activePage = verticalPageController(for: VideoPageTab.page(at: pagerPosition.selectedIndex)) else { - return nil + return } let activeOffset = providedActiveOffset ?? activePage.normalizedContentOffsetY let nextSyncState = updateHeaderSyncState(activeOffset: activeOffset) - var didSyncAllInactivePages = true for tab in VideoPageTab.allCases where tab.pageIndex != pagerPosition.selectedIndex { - let didSync = verticalPageController(for: tab)?.syncHeaderOffsetFromActivePage( - nextSyncState.inactiveSyncMode, - consumeInitialReset: false - ) ?? true - didSyncAllInactivePages = didSyncAllInactivePages && didSync + verticalPageController(for: tab)?.syncHeaderOffsetFromActivePage(nextSyncState.inactiveSyncMode) } - return VideoDetailInactiveHeaderSyncResult( - state: nextSyncState, - didSyncAllInactivePages: didSyncAllInactivePages - ) } @discardableResult From c9e427a1805368ff68fedab9613e7e675ba757f8 Mon Sep 17 00:00:00 2001 From: pgs666 <74764545+pgs666@users.noreply.github.com> Date: Mon, 8 Jun 2026 03:09:43 +0800 Subject: [PATCH 203/216] Use self sizing comment cells --- iosApp/CommentListTableView.swift | 92 ++++--------------------------- 1 file changed, 10 insertions(+), 82 deletions(-) diff --git a/iosApp/CommentListTableView.swift b/iosApp/CommentListTableView.swift index 0a188cfc..bfbf37e0 100644 --- a/iosApp/CommentListTableView.swift +++ b/iosApp/CommentListTableView.swift @@ -81,11 +81,6 @@ final class CommentListTableController: NSObject, UITableViewDataSource, UITable } } - private struct MeasuredCommentHeight { - let signature: CommentSignature - let height: CGFloat - } - private enum RowHeightEstimate { static let controls: CGFloat = 61 static let loading: CGFloat = 120 @@ -139,8 +134,6 @@ final class CommentListTableController: NSObject, UITableViewDataSource, UITable private var commentActionSignatures: [CommentActionSignature] = [] private var previousRunningActionIDs: Set = [] private var hasRenderedRows = false - private var measuredCommentHeights: [String: MeasuredCommentHeight] = [:] - private var isReloadingRows = false private var contentBottomPadding: CGFloat = 0 weak var scrollDelegate: UIScrollViewDelegate? @@ -163,7 +156,6 @@ final class CommentListTableController: NSObject, UITableViewDataSource, UITable let nextCommentActionSignatures = model.comments.map(CommentActionSignature.init) let didChangeCommentActions = commentActionSignatures != nextCommentActionSignatures let didChangeRunningActions = previousRunningActionIDs != model.runningActionIDs - pruneMeasuredCommentHeights(for: model) modelSignature = nextSignature commentActionSignatures = nextCommentActionSignatures previousRunningActionIDs = model.runningActionIDs @@ -282,9 +274,6 @@ final class CommentListTableController: NSObject, UITableViewDataSource, UITable cell.configure( comment: comment, isRunningLike: runningActionIDs.contains("like-\(comment.id)"), - onMeasuredHeight: { [weak self] height in - self?.recordMeasuredCommentHeight(height, commentID: comment.id) - }, onReply: { [weak self] in self?.onReply(comment) }, onShowReplies: { [weak self] in self?.onShowReplies(comment) }, onLike: { [weak self] in self?.onLike(comment) }, @@ -297,7 +286,12 @@ final class CommentListTableController: NSObject, UITableViewDataSource, UITable func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat { guard indexPath.row < rows.count else { return UITableView.automaticDimension } - return height(for: rows[indexPath.row]) + switch rows[indexPath.row] { + case .comment: + return UITableView.automaticDimension + default: + return fixedHeight(for: rows[indexPath.row]) + } } func tableView(_ tableView: UITableView, estimatedHeightForRowAt indexPath: IndexPath) -> CGFloat { @@ -305,7 +299,7 @@ final class CommentListTableController: NSObject, UITableViewDataSource, UITable return estimatedHeight(for: rows[indexPath.row]) } - private func height(for row: Row) -> CGFloat { + private func fixedHeight(for row: Row) -> CGFloat { switch row { case .controls: return RowHeightEstimate.controls @@ -317,25 +311,17 @@ final class CommentListTableController: NSObject, UITableViewDataSource, UITable return RowHeightEstimate.empty case .footer: return RowHeightEstimate.footer - case .comment(let comment): - guard let measuredHeight = measuredCommentHeights[comment.id], - measuredHeight.signature == CommentSignature(comment) else { - return UITableView.automaticDimension - } - return measuredHeight.height + case .comment: + return UITableView.automaticDimension } } private func estimatedHeight(for row: Row) -> CGFloat { switch row { case .comment(let comment): - if let measuredHeight = measuredCommentHeights[comment.id], - measuredHeight.signature == CommentSignature(comment) { - return measuredHeight.height - } return RowHeightEstimate.comment(comment) default: - return height(for: row) + return fixedHeight(for: row) } } @@ -399,9 +385,6 @@ final class CommentListTableController: NSObject, UITableViewDataSource, UITable cell.configure( comment: comment, isRunningLike: runningActionIDs.contains("like-\(comment.id)"), - onMeasuredHeight: { [weak self] height in - self?.recordMeasuredCommentHeight(height, commentID: comment.id) - }, onReply: { [weak self] in self?.onReply(comment) }, onShowReplies: { [weak self] in self?.onShowReplies(comment) }, onLike: { [weak self] in self?.onLike(comment) }, @@ -414,69 +397,23 @@ final class CommentListTableController: NSObject, UITableViewDataSource, UITable private func reloadTablePreservingOffset() { guard let tableView else { return } guard hasRenderedRows else { - isReloadingRows = true - defer { isReloadingRows = false } tableView.reloadData() tableView.layoutIfNeeded() hasRenderedRows = true return } - let previousOffset = tableView.contentOffset UIView.performWithoutAnimation { - isReloadingRows = true - defer { isReloadingRows = false } tableView.reloadData() if !tableView.isTracking, !tableView.isDragging, !tableView.isDecelerating { tableView.layoutIfNeeded() } - tableView.setContentOffset( - clampedContentOffset(previousOffset, in: tableView), - animated: false - ) - } - } - - private func clampedContentOffset(_ offset: CGPoint, in tableView: UITableView) -> CGPoint { - let inset = tableView.contentInset - let minOffsetY = -inset.top - let maxOffsetY = max(minOffsetY, tableView.contentSize.height - tableView.bounds.height + inset.bottom) - return CGPoint( - x: offset.x, - y: min(max(offset.y, minOffsetY), maxOffsetY) - ) - } - - private func pruneMeasuredCommentHeights(for model: CommentListTableModel) { - let validSignatures = Dictionary(uniqueKeysWithValues: model.comments.map { ($0.id, CommentSignature($0)) }) - measuredCommentHeights = measuredCommentHeights.filter { id, measured in - validSignatures[id] == measured.signature } } - private func recordMeasuredCommentHeight( - _ height: CGFloat, - commentID: String - ) { - guard let tableView else { return } - guard !tableView.isTracking, !tableView.isDragging, !tableView.isDecelerating else { return } - guard height > 1 else { return } - let roundedHeight = ceil(height) - guard let comment = comments.first(where: { $0.id == commentID }) else { return } - let signature = CommentSignature(comment) - guard measuredCommentHeights[commentID].map({ - $0.signature != signature || abs($0.height - roundedHeight) > 0.5 - }) ?? true else { return } - measuredCommentHeights[commentID] = MeasuredCommentHeight( - signature: signature, - height: roundedHeight - ) - } - } private final class HostingCommentTableViewCell: UITableViewCell { static let reuseIdentifier = "HostingCommentTableViewCell" - private var onMeasuredHeight: ((CGFloat) -> Void)? override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { super.init(style: style, reuseIdentifier: reuseIdentifier) @@ -497,16 +434,9 @@ private final class HostingCommentTableViewCell: UITableViewCell { override func prepareForReuse() { super.prepareForReuse() contentConfiguration = nil - onMeasuredHeight = nil - } - - override func layoutSubviews() { - super.layoutSubviews() - onMeasuredHeight?(bounds.height) } func configure(@ViewBuilder content: () -> Content) { - onMeasuredHeight = nil contentConfiguration = UIHostingConfiguration { content() } @@ -516,14 +446,12 @@ private final class HostingCommentTableViewCell: UITableViewCell { func configure( comment: CommentRow, isRunningLike: Bool, - onMeasuredHeight: @escaping (CGFloat) -> Void, onReply: @escaping () -> Void, onShowReplies: @escaping () -> Void, onLike: @escaping () -> Void, onDislike: @escaping () -> Void, onReport: @escaping () -> Void ) { - self.onMeasuredHeight = onMeasuredHeight let row = CommentRowView( comment: comment, isRunningLike: isRunningLike, From d94b513cf214010d384a823209ac4f30e8442a99 Mon Sep 17 00:00:00 2001 From: pgs666 <74764545+pgs666@users.noreply.github.com> Date: Mon, 8 Jun 2026 03:21:01 +0800 Subject: [PATCH 204/216] Set comment table estimated row height --- iosApp/CommentListTableView.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/iosApp/CommentListTableView.swift b/iosApp/CommentListTableView.swift index bfbf37e0..d6c9f874 100644 --- a/iosApp/CommentListTableView.swift +++ b/iosApp/CommentListTableView.swift @@ -349,7 +349,7 @@ final class CommentListTableController: NSObject, UITableViewDataSource, UITable tableView.backgroundColor = .clear tableView.separatorStyle = .none tableView.showsVerticalScrollIndicator = false - tableView.estimatedRowHeight = 0 + tableView.estimatedRowHeight = RowHeightEstimate.commentMinimum tableView.estimatedSectionHeaderHeight = 0 tableView.estimatedSectionFooterHeight = 0 tableView.rowHeight = UITableView.automaticDimension From dea507bb5a9f9bd230bba9c97ead271eb4542583 Mon Sep 17 00:00:00 2001 From: pgs666 <74764545+pgs666@users.noreply.github.com> Date: Mon, 8 Jun 2026 03:33:35 +0800 Subject: [PATCH 205/216] Simplify pager header page state --- iosApp/VideoDetailPager.swift | 118 ++++------------------------------ 1 file changed, 13 insertions(+), 105 deletions(-) diff --git a/iosApp/VideoDetailPager.swift b/iosApp/VideoDetailPager.swift index 7e66ab10..c5f8486e 100644 --- a/iosApp/VideoDetailPager.swift +++ b/iosApp/VideoDetailPager.swift @@ -342,33 +342,21 @@ private enum VideoDetailTabPageContent { private struct VideoDetailHorizontalPagerPosition: Equatable { private(set) var selectedIndex = 0 private(set) var isPagingActive = false - private(set) var activeHeaderIndex = 0 mutating func setSelectedIndex(_ index: Int) { - let resolvedIndex = clamped(index) - selectedIndex = resolvedIndex - activeHeaderIndex = resolvedIndex - } - - mutating func setActiveHeaderIndex(_ index: Int) { - activeHeaderIndex = clamped(index) + selectedIndex = clamped(index) } mutating func setPagingActive(_ isActive: Bool) -> Bool { guard isPagingActive != isActive else { return false } isPagingActive = isActive - if !isActive { - activeHeaderIndex = selectedIndex - } return true } mutating func settleHorizontalPaging(at index: Int) -> (previousSelectedIndex: Int, wasPagingActive: Bool) { let previousSelectedIndex = selectedIndex let wasPagingActive = isPagingActive - let resolvedIndex = clamped(index) - selectedIndex = resolvedIndex - activeHeaderIndex = resolvedIndex + selectedIndex = clamped(index) isPagingActive = false return (previousSelectedIndex, wasPagingActive) } @@ -655,7 +643,6 @@ private final class VideoDetailVerticalScrollPageCoordinator: NSObject, UIScroll var onVisibleOffsetChange: (VideoPageTab, CGFloat) -> Void = { _, _ in } var visualTopContentOffsetY: CGFloat = 0 var isApplyingExternalOffset = false - var isHorizontalPagingActive = false private var lastReportedOffset: CGFloat? private var lastTopPullTranslationY: CGFloat = 0 @@ -672,7 +659,7 @@ private final class VideoDetailVerticalScrollPageCoordinator: NSObject, UIScroll } func scrollViewDidScroll(_ listScrollView: UIScrollView) { - guard !isApplyingExternalOffset, !isHorizontalPagingActive else { return } + guard !isApplyingExternalOffset else { return } let offset = listScrollView.verticalContentOffsetExcludingBounce onVisibleOffsetChange(tab, offset) guard lastReportedOffset.map({ abs($0 - offset) > 0.5 }) ?? true else { return } @@ -1012,11 +999,6 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle coordinator.visualTopContentOffsetY = page.headerGeometry.resolvedVisualTopOffset } - func settleAfterHorizontalActivation(targetOffsetY: CGFloat) { - loadViewIfNeeded() - syncHeaderOffsetFromActivePage(.visualTop(targetOffsetY)) - } - var normalizedContentOffsetY: CGFloat { loadViewIfNeeded() return listScrollView.verticalContentOffsetExcludingBounce @@ -1032,13 +1014,6 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle return setNormalizedContentOffsetYIfReachable(syncOffsetY) } - func setHorizontalPagingActive(_ isActive: Bool) { - coordinator.isHorizontalPagingActive = isActive - if !isActive { - coordinator.resetReportedOffset(listScrollView.verticalContentOffsetExcludingBounce) - } - } - func setScrollsToTop(_ scrollsToTop: Bool) { loadViewIfNeeded() listScrollView.scrollsToTop = scrollsToTop @@ -1189,7 +1164,6 @@ private struct VideoDetailTabPager: UIViewControllerRepresentable { var selectedTab: Binding var onSelectedIndexSettled: ((Int) -> Void)? var onPagingActivityChanged: ((Bool) -> Void)? - var onHorizontalPageScrolled: ((Int) -> Void)? private var lastProgrammaticIndex: Int? init(selectedTab: Binding) { @@ -1213,8 +1187,6 @@ private struct VideoDetailTabPager: UIViewControllerRepresentable { return } onPagingActivityChanged?(true) - let activeIndex = Int(floor(listScrollView.contentOffset.x / width)) - onHorizontalPageScrolled?(activeIndex) } func scrollViewDidEndDecelerating(_ listScrollView: UIScrollView) { @@ -1277,10 +1249,6 @@ private struct VideoDetailTabPager: UIViewControllerRepresentable { private var pendingSelectedIndex: Int? private var lastLaidOutWidth: CGFloat = 0 private var headerContentRevision: Int? - private var headerSyncState = VideoDetailSmoothHeaderSyncState.state( - activeOffset: 0, - collapseDistance: 0 - ) private var latestPages: [VideoPageTab: VideoDetailTabPage] = [:] init(coordinator: Coordinator) { @@ -1320,9 +1288,6 @@ private struct VideoDetailTabPager: UIViewControllerRepresentable { coordinator.onPagingActivityChanged = { [weak self] isActive in self?.setHorizontalPagingActive(isActive) } - coordinator.onHorizontalPageScrolled = { [weak self] index in - self?.updateActiveHeaderIndexDuringHorizontalScroll(index) - } view.addSubview(scrollView) contentView.translatesAutoresizingMaskIntoConstraints = false @@ -1462,32 +1427,16 @@ private struct VideoDetailTabPager: UIViewControllerRepresentable { private func finishHorizontalPaging(at index: Int) { let clampedIndex = min(max(index, 0), VideoPageTab.allCases.count - 1) let settlement = pagerPosition.settleHorizontalPaging(at: clampedIndex) - introductionPage.setHorizontalPagingActive(false) - commentsPage?.setHorizontalPagingActive(false) guard clampedIndex != settlement.previousSelectedIndex || settlement.wasPagingActive else { pendingSelectedIndex = nil return } pendingSelectedIndex = nil - let previousTab = VideoPageTab.page(at: settlement.previousSelectedIndex) - if let activePage = verticalPageController(for: previousTab) { - updateHeaderSyncState(for: previousTab, activeOffset: activePage.normalizedContentOffsetY) - } - let activationOffset = headerSyncState.inactiveSyncMode.normalizedOffsetY refreshPageSelectionSnapshots(selectedTab: VideoPageTab.page(at: clampedIndex)) updateScrollsToTop(for: VideoPageTab.page(at: clampedIndex)) layoutHeaderHosts() - switch VideoPageTab.page(at: clampedIndex) { - case .introduction: - introductionPage.settleAfterHorizontalActivation(targetOffsetY: activationOffset) - introductionPage.reportCurrentOffset() - case .comments: - commentsPage?.settleAfterHorizontalActivation(targetOffsetY: activationOffset) - commentsPage?.reportCurrentOffset() - } - if let activePage = verticalPageController(for: VideoPageTab.page(at: clampedIndex)) { - syncInactivePageHeaderOffset(activeOffset: activePage.normalizedContentOffsetY) - } + verticalPageController(for: VideoPageTab.page(at: clampedIndex))?.reportCurrentOffset() + syncInactivePageHeaderOffset() updateHeaderAttachmentForCurrentState() } @@ -1507,23 +1456,12 @@ private struct VideoDetailTabPager: UIViewControllerRepresentable { private func setHorizontalPagingActive(_ isActive: Bool) { guard pagerPosition.setPagingActive(isActive) else { return } - introductionPage.setHorizontalPagingActive(isActive) - commentsPage?.setHorizontalPagingActive(isActive) updateHeaderAttachmentForCurrentState() if !isActive { syncInactivePageHeaderOffset() } } - private func updateActiveHeaderIndexDuringHorizontalScroll(_ index: Int) { - guard pagerPosition.isPagingActive || scrollView.isDragging || scrollView.isDecelerating else { return } - let previousHeaderTab = activeHeaderTab - pagerPosition.setActiveHeaderIndex(index) - guard activeHeaderTab != previousHeaderTab else { return } - layoutHeaderHosts() - updateHeaderAttachmentForCurrentState() - } - private func syncInactivePageHeaderOffset(activeOffset providedActiveOffset: CGFloat? = nil) { guard !pagerPosition.isPagingActive else { return @@ -1532,32 +1470,13 @@ private struct VideoDetailTabPager: UIViewControllerRepresentable { return } let activeOffset = providedActiveOffset ?? activePage.normalizedContentOffsetY - let nextSyncState = updateHeaderSyncState(activeOffset: activeOffset) + guard let page = latestPages[VideoPageTab.page(at: pagerPosition.selectedIndex)] else { return } + let nextSyncState = page.headerGeometry.smoothHeaderSyncState(activeOffset: activeOffset) for tab in VideoPageTab.allCases where tab.pageIndex != pagerPosition.selectedIndex { verticalPageController(for: tab)?.syncHeaderOffsetFromActivePage(nextSyncState.inactiveSyncMode) } } - @discardableResult - private func updateHeaderSyncState(activeOffset: CGFloat) -> VideoDetailSmoothHeaderSyncState { - updateHeaderSyncState( - for: VideoPageTab.page(at: pagerPosition.selectedIndex), - activeOffset: activeOffset - ) - } - - @discardableResult - private func updateHeaderSyncState( - for tab: VideoPageTab, - activeOffset: CGFloat - ) -> VideoDetailSmoothHeaderSyncState { - guard let page = latestPages[tab] else { - return headerSyncState - } - headerSyncState = page.headerGeometry.smoothHeaderSyncState(activeOffset: activeOffset) - return headerSyncState - } - private func verticalPageController(for tab: VideoPageTab) -> VideoDetailVerticalScrollPageViewController? { switch tab { case .introduction: @@ -1663,19 +1582,13 @@ private struct VideoDetailTabPager: UIViewControllerRepresentable { guard let page = latestPages[headerTab] else { return } layoutHeaderHosts() guard let pageController = verticalPageController(for: headerTab) else { return } - let syncState: VideoDetailSmoothHeaderSyncState - if pagerPosition.isPagingActive { - syncState = headerSyncState - } else { - let offset = pageController.normalizedContentOffsetY - syncState = page.headerGeometry.smoothHeaderSyncState(activeOffset: offset) - headerSyncState = syncState - } + let offset = pageController.normalizedContentOffsetY + let syncState = page.headerGeometry.smoothHeaderSyncState(activeOffset: offset) attachHeader(originY: syncState.headerContainerY) } private var activeHeaderTab: VideoPageTab { - VideoPageTab.page(at: pagerPosition.activeHeaderIndex) + VideoPageTab.page(at: pagerPosition.selectedIndex) } private func attachHeader(originY: CGFloat) { @@ -1692,16 +1605,11 @@ private struct VideoDetailTabPager: UIViewControllerRepresentable { private func updateHeaderContainerPosition(for tab: VideoPageTab, offsetY: CGFloat) { guard tab == activeHeaderTab else { return } guard let page = latestPages[tab] else { return } - if pagerPosition.isPagingActive { - let syncState = page.headerGeometry.smoothHeaderSyncState(activeOffset: offsetY) - headerSyncState = syncState - attachHeader(originY: syncState.headerContainerY) - } else { - let syncState = page.headerGeometry.smoothHeaderSyncState(activeOffset: offsetY) - headerSyncState = syncState + let syncState = page.headerGeometry.smoothHeaderSyncState(activeOffset: offsetY) + if !pagerPosition.isPagingActive { syncInactivePageHeaderOffset(activeOffset: offsetY) - attachHeader(originY: syncState.headerContainerY) } + attachHeader(originY: syncState.headerContainerY) } } From c67e5def23230fa87b1fe968e469e73b8d1fe747 Mon Sep 17 00:00:00 2001 From: pgs666 <74764545+pgs666@users.noreply.github.com> Date: Mon, 8 Jun 2026 03:44:52 +0800 Subject: [PATCH 206/216] Apply initial native pager offset --- iosApp/VideoDetailPager.swift | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/iosApp/VideoDetailPager.swift b/iosApp/VideoDetailPager.swift index c5f8486e..8e31e2de 100644 --- a/iosApp/VideoDetailPager.swift +++ b/iosApp/VideoDetailPager.swift @@ -711,6 +711,7 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle private var contentBottomSpacerHeightConstraint: NSLayoutConstraint? private var contentUpdateRevision: Int? private var lastAppliedPage: VideoDetailTabPage? + private var didApplyInitialContentOffset = false private var listScrollViewContentSizeObservation: NSKeyValueObservation? private var listScrollViewBoundsObservation: NSKeyValueObservation? private var nativeScrollDelegateAttachment: ((UIScrollViewDelegate?) -> Void)? @@ -904,6 +905,7 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle if case .nativeScrollView(let nativePage) = page.content { nativePage.update() applyCurrentPageGeometryRules() + applyInitialContentOffsetIfNeeded(offsetContext.initialNormalizedOffsetY) } } } @@ -999,6 +1001,13 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle coordinator.visualTopContentOffsetY = page.headerGeometry.resolvedVisualTopOffset } + private func applyInitialContentOffsetIfNeeded(_ offsetY: CGFloat) { + guard !didApplyInitialContentOffset else { return } + guard !listScrollView.isTracking, !listScrollView.isDragging, !listScrollView.isDecelerating else { return } + guard setNormalizedContentOffsetYIfReachable(offsetY) else { return } + didApplyInitialContentOffset = true + } + var normalizedContentOffsetY: CGFloat { loadViewIfNeeded() return listScrollView.verticalContentOffsetExcludingBounce From aa31bf58969d1828f2174d2a374c02e4c383309a Mon Sep 17 00:00:00 2001 From: pgs666 <74764545+pgs666@users.noreply.github.com> Date: Mon, 8 Jun 2026 03:53:19 +0800 Subject: [PATCH 207/216] Stabilize comment row sizing --- iosApp/CommentListTableView.swift | 8 +++++--- iosApp/CommentView.swift | 1 - 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/iosApp/CommentListTableView.swift b/iosApp/CommentListTableView.swift index d6c9f874..1a813280 100644 --- a/iosApp/CommentListTableView.swift +++ b/iosApp/CommentListTableView.swift @@ -92,11 +92,13 @@ final class CommentListTableController: NSObject, UITableViewDataSource, UITable static let failedCharactersPerLine: CGFloat = 18 static let commentMinimum: CGFloat = 118 static let commentLineHeight: CGFloat = 22 - static let commentCharactersPerLine: CGFloat = 22 + static let commentCharactersPerLine: CGFloat = 18 static func comment(_ comment: CommentRow) -> CGFloat { - let normalizedLength = max(comment.content.count, 1) - let estimatedLines = ceil(CGFloat(normalizedLength) / commentCharactersPerLine) + let estimatedLines = comment.content + .split(separator: "\n", omittingEmptySubsequences: false) + .map { ceil(CGFloat(max($0.count, 1)) / commentCharactersPerLine) } + .reduce(CGFloat(0), +) let replyHeight: CGFloat = comment.hasMoreReplies ? 18 : 0 let childReduction: CGFloat = comment.isChildComment ? 8 : 0 return max(commentMinimum - childReduction, 92 + estimatedLines * commentLineHeight + replyHeight) diff --git a/iosApp/CommentView.swift b/iosApp/CommentView.swift index b450a99e..2998c624 100644 --- a/iosApp/CommentView.swift +++ b/iosApp/CommentView.swift @@ -175,7 +175,6 @@ struct CommentRowView: View { Text(comment.content) .font(.body) - .textSelection(.enabled) HStack(spacing: 14) { TapOnlyControl(isDisabled: isRunningLike) { From 7ec7db0bf12e3bc514715ba4e56f3c6412a7e9ca Mon Sep 17 00:00:00 2001 From: pgs666 <74764545+pgs666@users.noreply.github.com> Date: Mon, 8 Jun 2026 04:01:47 +0800 Subject: [PATCH 208/216] Keep native comment bottom inset owned by table --- iosApp/CommentListTableView.swift | 6 +++++- iosApp/VideoDetailPager.swift | 1 - 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/iosApp/CommentListTableView.swift b/iosApp/CommentListTableView.swift index 1a813280..e4b8a1e4 100644 --- a/iosApp/CommentListTableView.swift +++ b/iosApp/CommentListTableView.swift @@ -190,11 +190,15 @@ final class CommentListTableController: NSObject, UITableViewDataSource, UITable func updateContentBottomPadding(_ bottomPadding: CGFloat) { let nextPadding = max(bottomPadding, 0) - guard abs(contentBottomPadding - nextPadding) > 0.5 else { return } guard let tableView else { contentBottomPadding = nextPadding return } + guard abs(contentBottomPadding - nextPadding) > 0.5 + || abs(tableView.contentInset.bottom - nextPadding) > 0.5 + || abs(tableView.verticalScrollIndicatorInsets.bottom - nextPadding) > 0.5 else { + return + } applyContentBottomPadding(nextPadding, in: tableView) } diff --git a/iosApp/VideoDetailPager.swift b/iosApp/VideoDetailPager.swift index 8e31e2de..d23d8d98 100644 --- a/iosApp/VideoDetailPager.swift +++ b/iosApp/VideoDetailPager.swift @@ -898,7 +898,6 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle contentMinimumHeightConstraint?.constant = offsetContext.minimumContentHeight hostMinimumHeightConstraint?.isActive = true case .nativeScrollView: - applyBottomContentSpacing(page.contentBottomPadding, usesContentSpacer: true) collapseSpacerHeightConstraint?.constant = 0 contentMinimumHeightConstraint?.constant = 1 hostMinimumHeightConstraint?.isActive = false From b488a4e29d9817f97d3c862f879a122e01ff4f2c Mon Sep 17 00:00:00 2001 From: pgs666 <74764545+pgs666@users.noreply.github.com> Date: Mon, 8 Jun 2026 04:09:44 +0800 Subject: [PATCH 209/216] Flatten pager inactive offset sync --- iosApp/VideoDetailPager.swift | 39 +++++++---------------------------- 1 file changed, 7 insertions(+), 32 deletions(-) diff --git a/iosApp/VideoDetailPager.swift b/iosApp/VideoDetailPager.swift index d23d8d98..f74f6a71 100644 --- a/iosApp/VideoDetailPager.swift +++ b/iosApp/VideoDetailPager.swift @@ -29,12 +29,6 @@ enum VideoPlayerCollapseModel { } enum VideoDetailPagerOffsetModel { - enum InactiveSyncMode: Equatable { - case visualTop(CGFloat) - case scrollingHeader(CGFloat) - case pinned(CGFloat) - } - static func initialNormalizedOffsetY( visualTopOffset: CGFloat, collapseDistance: CGFloat @@ -67,9 +61,8 @@ enum VideoDetailPagerOffsetModel { } private struct VideoDetailSmoothHeaderSyncState: Equatable { - let isSyncingListOffsets: Bool let headerContainerY: CGFloat - let inactiveSyncMode: VideoDetailPagerOffsetModel.InactiveSyncMode + let inactiveNormalizedOffsetY: CGFloat static func state( activeOffset: CGFloat, @@ -79,28 +72,17 @@ private struct VideoDetailSmoothHeaderSyncState: Equatable { if activeOffset < resolvedCollapseDistance { let scrollingOffset = max(activeOffset, 0) return VideoDetailSmoothHeaderSyncState( - isSyncingListOffsets: true, headerContainerY: -scrollingOffset, - inactiveSyncMode: .scrollingHeader(scrollingOffset) + inactiveNormalizedOffsetY: scrollingOffset ) } return VideoDetailSmoothHeaderSyncState( - isSyncingListOffsets: false, headerContainerY: -resolvedCollapseDistance, - inactiveSyncMode: .pinned(resolvedCollapseDistance) + inactiveNormalizedOffsetY: resolvedCollapseDistance ) } } -private extension VideoDetailPagerOffsetModel.InactiveSyncMode { - var normalizedOffsetY: CGFloat { - switch self { - case .visualTop(let offsetY), .scrollingHeader(let offsetY), .pinned(let offsetY): - return offsetY - } - } -} - struct VideoDetailPagerLayoutMetrics { let collapseDistance: CGFloat let playerScrollAway: CGFloat @@ -266,17 +248,12 @@ private struct VideoDetailSmoothHeaderGeometry: Equatable { } func listOffsetContext( - in scrollBoundsHeight: CGFloat, - activeOffset: CGFloat? = nil + in scrollBoundsHeight: CGFloat ) -> VideoDetailListOffsetContext { let visualTopOffset = resolvedVisualTopOffset - let inactiveSyncMode = activeOffset.map { - smoothHeaderSyncState(activeOffset: $0).inactiveSyncMode - } ?? .visualTop(visualTopOffset) return VideoDetailListOffsetContext( contentTopInset: contentTopInset, initialNormalizedOffsetY: visualTopOffset, - inactiveSyncMode: inactiveSyncMode, collapseSpacerHeight: collapseSpacerHeight, minimumContentHeight: minimumContentHeight(in: scrollBoundsHeight), minimumListContentHeight: minimumListContentHeight(in: scrollBoundsHeight) @@ -322,7 +299,6 @@ private struct VideoDetailSmoothHeaderGeometry: Equatable { private struct VideoDetailListOffsetContext: Equatable { let contentTopInset: CGFloat let initialNormalizedOffsetY: CGFloat - let inactiveSyncMode: VideoDetailPagerOffsetModel.InactiveSyncMode let collapseSpacerHeight: CGFloat let minimumContentHeight: CGFloat let minimumListContentHeight: CGFloat @@ -1013,13 +989,12 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle } @discardableResult - func syncHeaderOffsetFromActivePage(_ syncMode: VideoDetailPagerOffsetModel.InactiveSyncMode) -> Bool { + func syncHeaderOffsetFromActivePage(_ normalizedOffsetY: CGFloat) -> Bool { loadViewIfNeeded() guard !listScrollView.isTracking, !listScrollView.isDragging, !listScrollView.isDecelerating else { return false } - let syncOffsetY = syncMode.normalizedOffsetY - return setNormalizedContentOffsetYIfReachable(syncOffsetY) + return setNormalizedContentOffsetYIfReachable(normalizedOffsetY) } func setScrollsToTop(_ scrollsToTop: Bool) { @@ -1481,7 +1456,7 @@ private struct VideoDetailTabPager: UIViewControllerRepresentable { guard let page = latestPages[VideoPageTab.page(at: pagerPosition.selectedIndex)] else { return } let nextSyncState = page.headerGeometry.smoothHeaderSyncState(activeOffset: activeOffset) for tab in VideoPageTab.allCases where tab.pageIndex != pagerPosition.selectedIndex { - verticalPageController(for: tab)?.syncHeaderOffsetFromActivePage(nextSyncState.inactiveSyncMode) + verticalPageController(for: tab)?.syncHeaderOffsetFromActivePage(nextSyncState.inactiveNormalizedOffsetY) } } From e706840ad4c6da54cd2edbfbd80aa4559cc23c71 Mon Sep 17 00:00:00 2001 From: pgs666 <74764545+pgs666@users.noreply.github.com> Date: Mon, 8 Jun 2026 04:21:58 +0800 Subject: [PATCH 210/216] Stop mutating native comment content size --- iosApp/VideoDetailPager.swift | 72 ++++------------------------------- 1 file changed, 7 insertions(+), 65 deletions(-) diff --git a/iosApp/VideoDetailPager.swift b/iosApp/VideoDetailPager.swift index f74f6a71..9d00c432 100644 --- a/iosApp/VideoDetailPager.swift +++ b/iosApp/VideoDetailPager.swift @@ -48,13 +48,6 @@ enum VideoDetailPagerOffsetModel { ) } - static func minimumListContentHeight( - scrollBoundsHeight: CGFloat, - pinnedVisibleHeight: CGFloat - ) -> CGFloat { - max(scrollBoundsHeight - max(pinnedVisibleHeight, 0), 1) - } - private static func clamp(_ value: CGFloat, upperBound: CGFloat) -> CGFloat { min(max(value, 0), max(upperBound, 0)) } @@ -255,8 +248,7 @@ private struct VideoDetailSmoothHeaderGeometry: Equatable { contentTopInset: contentTopInset, initialNormalizedOffsetY: visualTopOffset, collapseSpacerHeight: collapseSpacerHeight, - minimumContentHeight: minimumContentHeight(in: scrollBoundsHeight), - minimumListContentHeight: minimumListContentHeight(in: scrollBoundsHeight) + minimumContentHeight: minimumContentHeight(in: scrollBoundsHeight) ) } @@ -275,13 +267,6 @@ private struct VideoDetailSmoothHeaderGeometry: Equatable { ) } - func minimumListContentHeight(in scrollBoundsHeight: CGFloat) -> CGFloat { - VideoDetailPagerOffsetModel.minimumListContentHeight( - scrollBoundsHeight: scrollBoundsHeight, - pinnedVisibleHeight: pinnedVisibleHeight - ) - } - func rawContentOffsetY(forNormalizedOffsetY offsetY: CGFloat, in listScrollView: UIScrollView) -> CGFloat { let inset = listScrollView.contentInset let minOffsetY = -inset.top @@ -301,7 +286,6 @@ private struct VideoDetailListOffsetContext: Equatable { let initialNormalizedOffsetY: CGFloat let collapseSpacerHeight: CGFloat let minimumContentHeight: CGFloat - let minimumListContentHeight: CGFloat } private struct VideoDetailNativeScrollPage { @@ -688,8 +672,6 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle private var contentUpdateRevision: Int? private var lastAppliedPage: VideoDetailTabPage? private var didApplyInitialContentOffset = false - private var listScrollViewContentSizeObservation: NSKeyValueObservation? - private var listScrollViewBoundsObservation: NSKeyValueObservation? private var nativeScrollDelegateAttachment: ((UIScrollViewDelegate?) -> Void)? var onHeaderOffsetChanged: (VideoPageTab, CGFloat) -> Void = { _, _ in } @@ -735,13 +717,7 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle } listScrollView.panGestureRecognizer.addTarget(coordinator, action: #selector(VideoDetailVerticalScrollPageCoordinator.handlePan(_:))) defaultScrollView?.onGeometryChange = { [weak self] in - self?.handleScrollGeometryChange() - } - listScrollViewContentSizeObservation = listScrollView.observe(\.contentSize, options: [.new]) { [weak self] _, _ in - self?.handleScrollGeometryChange() - } - listScrollViewBoundsObservation = listScrollView.observe(\.bounds, options: [.new]) { [weak self] _, _ in - self?.handleScrollGeometryChange() + self?.applySwiftUIContentMinimumHeight() } defaultScrollView?.shouldBeginVerticalPan = { [weak self] panGestureRecognizer, view in self?.handleVerticalInteractionBegan() @@ -820,7 +796,7 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle override func viewDidLayoutSubviews() { super.viewDidLayoutSubviews() applyListHeaderFrame() - handleScrollGeometryChange() + applySwiftUIContentMinimumHeight() } func update(page: VideoDetailTabPage) { @@ -879,34 +855,19 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle hostMinimumHeightConstraint?.isActive = false if case .nativeScrollView(let nativePage) = page.content { nativePage.update() - applyCurrentPageGeometryRules() applyInitialContentOffsetIfNeeded(offsetContext.initialNormalizedOffsetY) } } } - private func handleScrollGeometryChange() { - applyCurrentPageGeometryRules() - } - - private func applyCurrentPageGeometryRules() { + private func applySwiftUIContentMinimumHeight() { guard let page = lastAppliedPage else { return } - let geometry = page.headerGeometry - let offsetContext = geometry.listOffsetContext(in: listScrollView.bounds.height) - let isNativeScrollView: Bool - if case .nativeScrollView = page.content { - isNativeScrollView = true - } else { - isNativeScrollView = false - } + guard case .swiftUI = page.content else { return } + let offsetContext = page.headerGeometry.listOffsetContext(in: listScrollView.bounds.height) if let contentMinimumHeightConstraint, abs(contentMinimumHeightConstraint.constant - offsetContext.minimumContentHeight) > 0.5 { - contentMinimumHeightConstraint.constant = isNativeScrollView ? 1 : offsetContext.minimumContentHeight + contentMinimumHeightConstraint.constant = offsetContext.minimumContentHeight } - applyNativeMinimumContentSizeIfNeeded( - page: page, - offsetContext: offsetContext - ) } private func applyTopContentInset(_ topInset: CGFloat) { @@ -940,25 +901,6 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle setRawContentOffsetYSilentlyIfNeeded(clampedRawOffsetY) } - @discardableResult - private func applyNativeMinimumContentSizeIfNeeded( - page: VideoDetailTabPage, - offsetContext: VideoDetailListOffsetContext - ) -> Bool { - guard case .nativeScrollView = page.content else { - return false - } - let requiredContentHeight = offsetContext.minimumListContentHeight - guard listScrollView.contentSize.height < requiredContentHeight - 0.5 else { - return false - } - listScrollView.contentSize = CGSize( - width: listScrollView.contentSize.width, - height: requiredContentHeight - ) - return true - } - private func applyListHeaderFrame() { let topInset = max(listScrollView.contentInset.top, 0) let nextFrame = CGRect( From a568d1af5bd65253417868f3389f71f080aad528 Mon Sep 17 00:00:00 2001 From: pgs666 <74764545+pgs666@users.noreply.github.com> Date: Mon, 8 Jun 2026 04:29:12 +0800 Subject: [PATCH 211/216] Apply pager initial offset on selection --- iosApp/VideoDetailPager.swift | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/iosApp/VideoDetailPager.swift b/iosApp/VideoDetailPager.swift index 9d00c432..bbaa7aec 100644 --- a/iosApp/VideoDetailPager.swift +++ b/iosApp/VideoDetailPager.swift @@ -855,7 +855,7 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle hostMinimumHeightConstraint?.isActive = false if case .nativeScrollView(let nativePage) = page.content { nativePage.update() - applyInitialContentOffsetIfNeeded(offsetContext.initialNormalizedOffsetY) + applyInitialContentOffsetIfNeeded(offsetContext.initialNormalizedOffsetY, isSelected: page.isSelected) } } } @@ -918,13 +918,23 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle coordinator.visualTopContentOffsetY = page.headerGeometry.resolvedVisualTopOffset } - private func applyInitialContentOffsetIfNeeded(_ offsetY: CGFloat) { + private func applyInitialContentOffsetIfNeeded(_ offsetY: CGFloat, isSelected: Bool) { + guard isSelected else { return } guard !didApplyInitialContentOffset else { return } guard !listScrollView.isTracking, !listScrollView.isDragging, !listScrollView.isDecelerating else { return } guard setNormalizedContentOffsetYIfReachable(offsetY) else { return } didApplyInitialContentOffset = true } + func applyInitialContentOffsetForSelectionIfNeeded() { + loadViewIfNeeded() + guard let page = lastAppliedPage else { return } + applyInitialContentOffsetIfNeeded( + page.headerGeometry.resolvedVisualTopOffset, + isSelected: page.isSelected + ) + } + var normalizedContentOffsetY: CGFloat { loadViewIfNeeded() return listScrollView.verticalContentOffsetExcludingBounce @@ -1360,7 +1370,9 @@ private struct VideoDetailTabPager: UIViewControllerRepresentable { refreshPageSelectionSnapshots(selectedTab: VideoPageTab.page(at: clampedIndex)) updateScrollsToTop(for: VideoPageTab.page(at: clampedIndex)) layoutHeaderHosts() - verticalPageController(for: VideoPageTab.page(at: clampedIndex))?.reportCurrentOffset() + let selectedPage = verticalPageController(for: VideoPageTab.page(at: clampedIndex)) + selectedPage?.applyInitialContentOffsetForSelectionIfNeeded() + selectedPage?.reportCurrentOffset() syncInactivePageHeaderOffset() updateHeaderAttachmentForCurrentState() } From b8a77b37d3e94647a3cb82d1f32f458761d600bc Mon Sep 17 00:00:00 2001 From: pgs666 <74764545+pgs666@users.noreply.github.com> Date: Mon, 8 Jun 2026 04:39:13 +0800 Subject: [PATCH 212/216] Remove comment row height estimates --- iosApp/CommentListTableView.swift | 72 +------------------------------ 1 file changed, 1 insertion(+), 71 deletions(-) diff --git a/iosApp/CommentListTableView.swift b/iosApp/CommentListTableView.swift index e4b8a1e4..5f54f703 100644 --- a/iosApp/CommentListTableView.swift +++ b/iosApp/CommentListTableView.swift @@ -81,35 +81,6 @@ final class CommentListTableController: NSObject, UITableViewDataSource, UITable } } - private enum RowHeightEstimate { - static let controls: CGFloat = 61 - static let loading: CGFloat = 120 - static let failed: CGFloat = 230 - static let empty: CGFloat = 180 - static let footer: CGFloat = 44 - static let failedMinimum: CGFloat = 230 - static let failedLineHeight: CGFloat = 20 - static let failedCharactersPerLine: CGFloat = 18 - static let commentMinimum: CGFloat = 118 - static let commentLineHeight: CGFloat = 22 - static let commentCharactersPerLine: CGFloat = 18 - - static func comment(_ comment: CommentRow) -> CGFloat { - let estimatedLines = comment.content - .split(separator: "\n", omittingEmptySubsequences: false) - .map { ceil(CGFloat(max($0.count, 1)) / commentCharactersPerLine) } - .reduce(CGFloat(0), +) - let replyHeight: CGFloat = comment.hasMoreReplies ? 18 : 0 - let childReduction: CGFloat = comment.isChildComment ? 8 : 0 - return max(commentMinimum - childReduction, 92 + estimatedLines * commentLineHeight + replyHeight) - } - - static func failed(_ message: String) -> CGFloat { - let estimatedLines = ceil(CGFloat(max(message.count, 1)) / failedCharactersPerLine) - return max(failedMinimum, 188 + estimatedLines * failedLineHeight) - } - } - private enum Row { case controls case loading @@ -290,47 +261,6 @@ final class CommentListTableController: NSObject, UITableViewDataSource, UITable } } - func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat { - guard indexPath.row < rows.count else { return UITableView.automaticDimension } - switch rows[indexPath.row] { - case .comment: - return UITableView.automaticDimension - default: - return fixedHeight(for: rows[indexPath.row]) - } - } - - func tableView(_ tableView: UITableView, estimatedHeightForRowAt indexPath: IndexPath) -> CGFloat { - guard indexPath.row < rows.count else { return UITableView.automaticDimension } - return estimatedHeight(for: rows[indexPath.row]) - } - - private func fixedHeight(for row: Row) -> CGFloat { - switch row { - case .controls: - return RowHeightEstimate.controls - case .loading: - return RowHeightEstimate.loading - case .failed(let message): - return RowHeightEstimate.failed(message) - case .empty: - return RowHeightEstimate.empty - case .footer: - return RowHeightEstimate.footer - case .comment: - return UITableView.automaticDimension - } - } - - private func estimatedHeight(for row: Row) -> CGFloat { - switch row { - case .comment(let comment): - return RowHeightEstimate.comment(comment) - default: - return fixedHeight(for: row) - } - } - func scrollViewDidScroll(_ scrollView: UIScrollView) { scrollDelegate?.scrollViewDidScroll?(scrollView) } @@ -355,7 +285,7 @@ final class CommentListTableController: NSObject, UITableViewDataSource, UITable tableView.backgroundColor = .clear tableView.separatorStyle = .none tableView.showsVerticalScrollIndicator = false - tableView.estimatedRowHeight = RowHeightEstimate.commentMinimum + tableView.estimatedRowHeight = 0 tableView.estimatedSectionHeaderHeight = 0 tableView.estimatedSectionFooterHeight = 0 tableView.rowHeight = UITableView.automaticDimension From 87f45d2e96313fb06fd04e35f5050ebbb32242a3 Mon Sep 17 00:00:00 2001 From: pgs666 <74764545+pgs666@users.noreply.github.com> Date: Mon, 8 Jun 2026 04:49:15 +0800 Subject: [PATCH 213/216] Gate native comment updates by revision --- iosApp/VideoDetailPager.swift | 15 ++++++++++++++- iosApp/VideoDetailView.swift | 11 +++++++++++ 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/iosApp/VideoDetailPager.swift b/iosApp/VideoDetailPager.swift index bbaa7aec..183dbec2 100644 --- a/iosApp/VideoDetailPager.swift +++ b/iosApp/VideoDetailPager.swift @@ -670,6 +670,8 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle private var collapseSpacerHeightConstraint: NSLayoutConstraint? private var contentBottomSpacerHeightConstraint: NSLayoutConstraint? private var contentUpdateRevision: Int? + private var nativeUpdateRevision: Int? + private var nativeContentBottomPadding: CGFloat? private var lastAppliedPage: VideoDetailTabPage? private var didApplyInitialContentOffset = false private var nativeScrollDelegateAttachment: ((UIScrollViewDelegate?) -> Void)? @@ -824,6 +826,8 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle contentUpdateRevision = page.contentUpdateRevision switch page.content { case .swiftUI(let content): + nativeUpdateRevision = nil + nativeContentBottomPadding = nil host.view.isHidden = false host.rootView = content() case .nativeScrollView: @@ -854,12 +858,21 @@ private final class VideoDetailVerticalScrollPageViewController: UIViewControlle contentMinimumHeightConstraint?.constant = 1 hostMinimumHeightConstraint?.isActive = false if case .nativeScrollView(let nativePage) = page.content { - nativePage.update() + applyNativeUpdateIfNeeded(nativePage, page: page) applyInitialContentOffsetIfNeeded(offsetContext.initialNormalizedOffsetY, isSelected: page.isSelected) } } } + private func applyNativeUpdateIfNeeded(_ nativePage: VideoDetailNativeScrollPage, page: VideoDetailTabPage) { + let shouldUpdate = nativeUpdateRevision != page.contentUpdateRevision + || nativeContentBottomPadding.map { abs($0 - page.contentBottomPadding) > 0.5 } ?? true + guard shouldUpdate else { return } + nativeUpdateRevision = page.contentUpdateRevision + nativeContentBottomPadding = page.contentBottomPadding + nativePage.update() + } + private func applySwiftUIContentMinimumHeight() { guard let page = lastAppliedPage else { return } guard case .swiftUI = page.content else { return } diff --git a/iosApp/VideoDetailView.swift b/iosApp/VideoDetailView.swift index b4879807..31633381 100644 --- a/iosApp/VideoDetailView.swift +++ b/iosApp/VideoDetailView.swift @@ -549,9 +549,20 @@ struct VideoDetailView: View { var commentsHasher = Hasher() commentsHasher.combine(ObjectIdentifier(commentViewModel)) commentsHasher.combine(commentViewModel.sortMode.id) + for actionID in commentViewModel.runningActionIDs.sorted() { + commentsHasher.combine(actionID) + } commentsHasher.combine(commentViewModel.sortedComments.count) for comment in commentViewModel.sortedComments { commentsHasher.combine(comment.id) + commentsHasher.combine(comment.username) + commentsHasher.combine(comment.date) + commentsHasher.combine(comment.content) + commentsHasher.combine(comment.hasMoreReplies) + commentsHasher.combine(comment.replyCount) + commentsHasher.combine(comment.thumbUp) + commentsHasher.combine(comment.likeCommentStatus) + commentsHasher.combine(comment.unlikeCommentStatus) } switch commentViewModel.state { case .idle: From 2d36b89769afa7f057df8b21e5513baf6e7e18c6 Mon Sep 17 00:00:00 2001 From: pgs666 <74764545+pgs666@users.noreply.github.com> Date: Mon, 8 Jun 2026 04:57:38 +0800 Subject: [PATCH 214/216] Let active vertical lists keep touch priority --- iosApp/VideoDetailPager.swift | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/iosApp/VideoDetailPager.swift b/iosApp/VideoDetailPager.swift index 183dbec2..e9b81bf0 100644 --- a/iosApp/VideoDetailPager.swift +++ b/iosApp/VideoDetailPager.swift @@ -1160,6 +1160,9 @@ private struct VideoDetailTabPager: UIViewControllerRepresentable { ) -> Bool { let startLocation = panGestureRecognizer.location(in: view) guard startLocation.x > 24 else { return false } + guard !view.hasActiveVerticalScrollDescendant(at: startLocation, excluding: view) else { + return false + } guard !view.hasScrollableHorizontalDescendant(at: startLocation, excluding: view) else { return false } @@ -1611,6 +1614,23 @@ private struct VideoDetailTabPager: UIViewControllerRepresentable { } private extension UIView { + func hasActiveVerticalScrollDescendant(at location: CGPoint, excluding excludedView: UIView) -> Bool { + guard let hitView = hitTest(location, with: nil) else { return false } + var current: UIView? = hitView + while let view = current, view !== excludedView { + if let listScrollView = view as? UIScrollView, + listScrollView.isScrollEnabled, + listScrollView.panGestureRecognizer.isEnabled, + listScrollView.contentSize.height > listScrollView.bounds.height + 1, + listScrollView.contentSize.height >= listScrollView.contentSize.width, + listScrollView.isTracking || listScrollView.isDragging || listScrollView.isDecelerating { + return true + } + current = view.superview + } + return false + } + func hasScrollableHorizontalDescendant(at location: CGPoint, excluding excludedView: UIView) -> Bool { guard let hitView = hitTest(location, with: nil) else { return false } var current: UIView? = hitView From 9c57f378a5b15f21ff5f3e379462e29ed99482b8 Mon Sep 17 00:00:00 2001 From: pgs666 <74764545+pgs666@users.noreply.github.com> Date: Mon, 8 Jun 2026 05:06:04 +0800 Subject: [PATCH 215/216] Reduce tablet intro bottom clearance --- iosApp/VideoDetailView.swift | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/iosApp/VideoDetailView.swift b/iosApp/VideoDetailView.swift index 31633381..ac72acad 100644 --- a/iosApp/VideoDetailView.swift +++ b/iosApp/VideoDetailView.swift @@ -294,7 +294,9 @@ struct VideoDetailView: View { pinnedVisibleHeight: playerContinuationStripHeight + pagerPinHeaderHeight, playerScrollAway: pagerMetrics.playerScrollAway, continuationProgress: pagerMetrics.continuationProgress, - introductionContentClearance: introductionContentClearance(), + introductionContentClearance: introductionContentClearance( + usesTabletRelatedSidebar: isWide + ), composerContentClearance: commentComposerContentClearance( safeAreaBottom: proxy.safeAreaInsets.bottom ) @@ -515,8 +517,8 @@ struct VideoDetailView: View { + commentComposerBottomSlack } - private func introductionContentClearance() -> CGFloat { - currentWindowBottomSafeAreaInset() + 24 + private func introductionContentClearance(usesTabletRelatedSidebar: Bool) -> CGFloat { + currentWindowBottomSafeAreaInset() + (usesTabletRelatedSidebar ? 0 : 24) } private func currentWindowBottomSafeAreaInset() -> CGFloat { From 4656d2bd669db7e8635d13c6dc438305f831252f Mon Sep 17 00:00:00 2001 From: pgs666 <74764545+pgs666@users.noreply.github.com> Date: Mon, 8 Jun 2026 05:17:07 +0800 Subject: [PATCH 216/216] Fix comment row self sizing --- iosApp/CommentListTableView.swift | 2 +- iosApp/CommentView.swift | 1 + iosApp/VideoDetailPager.swift | 20 -------------------- 3 files changed, 2 insertions(+), 21 deletions(-) diff --git a/iosApp/CommentListTableView.swift b/iosApp/CommentListTableView.swift index 5f54f703..3fe46f59 100644 --- a/iosApp/CommentListTableView.swift +++ b/iosApp/CommentListTableView.swift @@ -285,7 +285,7 @@ final class CommentListTableController: NSObject, UITableViewDataSource, UITable tableView.backgroundColor = .clear tableView.separatorStyle = .none tableView.showsVerticalScrollIndicator = false - tableView.estimatedRowHeight = 0 + tableView.estimatedRowHeight = 118 tableView.estimatedSectionHeaderHeight = 0 tableView.estimatedSectionFooterHeight = 0 tableView.rowHeight = UITableView.automaticDimension diff --git a/iosApp/CommentView.swift b/iosApp/CommentView.swift index 2998c624..5d26f9e9 100644 --- a/iosApp/CommentView.swift +++ b/iosApp/CommentView.swift @@ -175,6 +175,7 @@ struct CommentRowView: View { Text(comment.content) .font(.body) + .fixedSize(horizontal: false, vertical: true) HStack(spacing: 14) { TapOnlyControl(isDisabled: isRunningLike) { diff --git a/iosApp/VideoDetailPager.swift b/iosApp/VideoDetailPager.swift index e9b81bf0..183dbec2 100644 --- a/iosApp/VideoDetailPager.swift +++ b/iosApp/VideoDetailPager.swift @@ -1160,9 +1160,6 @@ private struct VideoDetailTabPager: UIViewControllerRepresentable { ) -> Bool { let startLocation = panGestureRecognizer.location(in: view) guard startLocation.x > 24 else { return false } - guard !view.hasActiveVerticalScrollDescendant(at: startLocation, excluding: view) else { - return false - } guard !view.hasScrollableHorizontalDescendant(at: startLocation, excluding: view) else { return false } @@ -1614,23 +1611,6 @@ private struct VideoDetailTabPager: UIViewControllerRepresentable { } private extension UIView { - func hasActiveVerticalScrollDescendant(at location: CGPoint, excluding excludedView: UIView) -> Bool { - guard let hitView = hitTest(location, with: nil) else { return false } - var current: UIView? = hitView - while let view = current, view !== excludedView { - if let listScrollView = view as? UIScrollView, - listScrollView.isScrollEnabled, - listScrollView.panGestureRecognizer.isEnabled, - listScrollView.contentSize.height > listScrollView.bounds.height + 1, - listScrollView.contentSize.height >= listScrollView.contentSize.width, - listScrollView.isTracking || listScrollView.isDragging || listScrollView.isDecelerating { - return true - } - current = view.superview - } - return false - } - func hasScrollableHorizontalDescendant(at location: CGPoint, excluding excludedView: UIView) -> Bool { guard let hitView = hitTest(location, with: nil) else { return false } var current: UIView? = hitView