diff --git a/apps/mobile/modules/t3-review-diff/ios/T3ReviewDiffView.swift b/apps/mobile/modules/t3-review-diff/ios/T3ReviewDiffView.swift index dfddaf7016c3..d781cc7c0d85 100644 --- a/apps/mobile/modules/t3-review-diff/ios/T3ReviewDiffView.swift +++ b/apps/mobile/modules/t3-review-diff/ios/T3ReviewDiffView.swift @@ -563,7 +563,7 @@ public final class T3ReviewDiffView: ExpoView, UIScrollViewDelegate { ]) } - // Source files fill the screen so UIKit can sample content below the glass bar. + // Code surfaces fill the screen so UIKit can sample content below the glass bar. // Keep the same visible row when header or safe-area reservations change. func setContentInsetTop(_ inset: CGFloat) { scrollView.automaticallyAdjustsScrollIndicatorInsets = false @@ -571,6 +571,7 @@ public final class T3ReviewDiffView: ExpoView, UIScrollViewDelegate { let delta = inset - scrollView.contentInset.top guard delta != 0 else { return } scrollView.contentInset.top = inset + contentView.topContentInset = inset scrollView.verticalScrollIndicatorInsets.top = inset scrollView.contentOffset.y -= delta updateViewportFrame() @@ -618,8 +619,12 @@ public final class T3ReviewDiffView: ExpoView, UIScrollViewDelegate { if let scrollAnchor, let headerOffset = contentView.fileHeaderOffset(forFileId: scrollAnchor.fileId) { let targetOffset = headerOffset - scrollAnchor.screenY - let maxOffset = max(scrollView.contentSize.height - scrollView.bounds.height, 0) - let clampedOffset = min(max(targetOffset, 0), maxOffset) + let minOffset = -scrollView.contentInset.top + let maxOffset = max( + scrollView.contentSize.height - scrollView.bounds.height + scrollView.contentInset.bottom, + minOffset + ) + let clampedOffset = min(max(targetOffset, minOffset), maxOffset) scrollView.setContentOffset(CGPoint(x: 0, y: clampedOffset), animated: false) updateViewportFrame() } @@ -735,7 +740,8 @@ public final class T3ReviewDiffView: ExpoView, UIScrollViewDelegate { // The top of the combined diff is the explicit "All files" destination. // Treat it as a first-class selection instead of immediately resolving the // first file header and undoing the navigator's optimistic selection. - if scrollView.contentOffset.y <= 0.5 { + let visibleTop = scrollView.contentOffset.y + scrollView.contentInset.top + if visibleTop <= 0.5 { guard lastVisibleFileId != nil else { return } @@ -744,7 +750,7 @@ public final class T3ReviewDiffView: ExpoView, UIScrollViewDelegate { return } - guard let fileId = contentView.visibleFileId(atVerticalOffset: scrollView.contentOffset.y), + guard let fileId = contentView.visibleFileId(atVerticalOffset: visibleTop), fileId != lastVisibleFileId else { return } @@ -1025,6 +1031,7 @@ private final class ReviewDiffContentView: UIView, UIGestureRecognizerDelegate { } } var verticalOffset: CGFloat = 0 + var topContentInset: CGFloat = 0 var theme = ReviewDiffNativeTheme.resolve("light") { didSet { tokenColorsByHex.removeAll() @@ -1495,7 +1502,7 @@ private final class ReviewDiffContentView: UIView, UIGestureRecognizerDelegate { let rowIndex = stickyHeader.rowIndex let headerTop = rowOffsets[rowIndex] - guard headerTop < verticalOffset else { + guard headerTop < verticalOffset + topContentInset else { return nil } @@ -1505,12 +1512,12 @@ private final class ReviewDiffContentView: UIView, UIGestureRecognizerDelegate { : nil let pushedY: CGFloat if let nextHeaderRowIndex { - pushedY = min(0, rowOffsets[nextHeaderRowIndex] - verticalOffset - style.fileHeaderHeight) + pushedY = min(topContentInset, rowOffsets[nextHeaderRowIndex] - verticalOffset - style.fileHeaderHeight) } else { - pushedY = 0 + pushedY = topContentInset } - guard pushedY > -style.fileHeaderHeight else { + guard pushedY > topContentInset - style.fileHeaderHeight else { return nil } @@ -1533,7 +1540,7 @@ private final class ReviewDiffContentView: UIView, UIGestureRecognizerDelegate { while lowerBound < upperBound { let midpoint = (lowerBound + upperBound) / 2 let rowIndex = fileHeaderRowIndices[midpoint] - if rowOffsets[rowIndex] <= verticalOffset { + if rowOffsets[rowIndex] <= verticalOffset + topContentInset { lowerBound = midpoint + 1 } else { upperBound = midpoint @@ -1550,7 +1557,7 @@ private final class ReviewDiffContentView: UIView, UIGestureRecognizerDelegate { func scrollAnchor(forFileId fileId: String) -> ReviewDiffScrollAnchor? { if let stickyHeader = stickyFileHeaderTarget(), resolvedFileId(for: stickyHeader.row) == fileId { - return ReviewDiffScrollAnchor(fileId: fileId, screenY: 0) + return ReviewDiffScrollAnchor(fileId: fileId, screenY: topContentInset) } guard let headerOffset = fileHeaderOffset(forFileId: fileId) else { diff --git a/apps/mobile/src/Stack.tsx b/apps/mobile/src/Stack.tsx index 569bfc11ada9..714eded82d68 100644 --- a/apps/mobile/src/Stack.tsx +++ b/apps/mobile/src/Stack.tsx @@ -158,8 +158,7 @@ const GLASS_HEADER_OPTIONS: AppScreenOptions = { unstable_navigationItemStyle: NATIVE_LIQUID_GLASS_SUPPORTED ? "editor" : undefined, }; -// SOLID: opaque sheet-colored header for surfaces whose content scrolls internally -// (terminal, review) — there is nothing for glass to sample there. +// SOLID: opaque sheet-colored header for surfaces that manage their own viewport. const SOLID_HEADER_OPTIONS: AppScreenOptions = { headerBackButtonDisplayMode: "minimal", headerBackTitle: "", @@ -724,7 +723,7 @@ const RootStackConfig = createWorkspaceStackNavigator({ ThreadReview: createNativeStackScreen({ screen: ReviewSheet, linking: `${THREAD_LINKING_PREFIX}/review`, - options: SOLID_HEADER_OPTIONS, + options: GLASS_HEADER_OPTIONS, }), ThreadReviewComment: createNativeStackScreen({ screen: ReviewCommentComposerSheet, diff --git a/apps/mobile/src/features/review/ReviewSheet.tsx b/apps/mobile/src/features/review/ReviewSheet.tsx index d7754aac9a52..8fe1e8b785fc 100644 --- a/apps/mobile/src/features/review/ReviewSheet.tsx +++ b/apps/mobile/src/features/review/ReviewSheet.tsx @@ -29,6 +29,7 @@ import { View, } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { useNativeColumnLayoutMetrics } from "../../native/native-layout-metrics"; import { AppText as Text } from "../../components/AppText"; import { SymbolView } from "../../components/AppSymbol"; @@ -156,7 +157,6 @@ function ReviewHeader( ); } -const REVIEW_HEADER_SPACING = 0; const SHOWCASE_ENABLED = process.env.EXPO_PUBLIC_SHOWCASE === "1"; const ReviewNotice = memo(function ReviewNotice(props: { readonly notice: string }) { @@ -461,10 +461,11 @@ export function ReviewSheet(props: ReviewSheetProps) { const { draftMessage } = useThreadDraftForThread({ environmentId, threadId }); const reviewCache = useReviewCacheForThread({ environmentId, threadId }); const { selectedThreadCwd } = useSelectedThreadWorktree(); - // With a solid (non-overlay) header the content lays out below the header - // natively, so no manual top inset is needed. (Android renders its own - // in-flow AndroidScreenHeader, so it needs no inset either.) - const topContentInset = 0; + const columnMetrics = useNativeColumnLayoutMetrics(); + const safeArea = columnMetrics?.safeArea ?? insets; + const topContentInset = Platform.OS === "ios" ? safeArea.top : 0; + const bottomContentInset = + Platform.OS === "ios" ? safeArea.bottom + 8 : Math.max(insets.bottom, 18) + 18; useEffect(() => { showAuxiliaryPane("inspector"); @@ -760,7 +761,7 @@ export function ReviewSheet(props: ReviewSheetProps) { > {listHeader} @@ -777,6 +778,8 @@ export function ReviewSheet(props: ReviewSheetProps) { collapsedCommentIdsJson={nativeBridge.collapsedCommentIdsJson} contentResetKey={`${reviewCache.threadKey}:${selectedSection.id}`} contentWidth={NATIVE_REVIEW_DIFF_CONTENT_WIDTH} + contentInsetTop={listHeader ? 0 : topContentInset} + contentInsetBottom={bottomContentInset} nativeViewRef={nativeReviewDiffViewRef} rowHeight={nativeReviewDiffStyle.rowHeight} rowsJson={nativeBridge.rowsJson} @@ -804,12 +807,10 @@ export function ReviewSheet(props: ReviewSheetProps) { : undefined } contentInsetAdjustmentBehavior="never" - contentInset={{ top: topContentInset, bottom: Math.max(insets.bottom, 18) + 18 }} + contentInset={{ top: topContentInset, bottom: bottomContentInset }} contentOffset={{ x: 0, y: -topContentInset }} - scrollIndicatorInsets={{ - top: topContentInset, - bottom: Math.max(insets.bottom, 18) + 18, - }} + automaticallyAdjustsScrollIndicatorInsets={false} + scrollIndicatorInsets={{ top: topContentInset, bottom: bottomContentInset }} showsVerticalScrollIndicator={false} className="flex-1" refreshControl={