diff --git a/apps/mobile/modules/t3-native-controls/ios/T3ScrollEdgeBleedView.swift b/apps/mobile/modules/t3-native-controls/ios/T3ScrollEdgeBleedView.swift index 642a87f14e85a..cb3c9041efcc5 100644 --- a/apps/mobile/modules/t3-native-controls/ios/T3ScrollEdgeBleedView.swift +++ b/apps/mobile/modules/t3-native-controls/ios/T3ScrollEdgeBleedView.swift @@ -2,16 +2,18 @@ import ExpoModulesCore import UIKit /// Wraps a screen's header scroll view. Where a column pads its content away from the iPhone Duo's -/// vertical bar, this widens the scroll view under the bar. UIKit draws -/// the header's scroll-edge effect inside the scroll view, so the blur reaches the bar like in the -/// system apps while the content stays in the safe area. Everything happens in UIKit layout, so -/// folds move the blur and the content together. +/// vertical bar, this widens the scroll view under the bar, like the system apps' lists. UIKit +/// draws the header's scroll-edge effect inside the scroll view, so the blur reaches the bar while +/// the content stays in the safe area. Everything happens in UIKit layout, so folds move the blur +/// and the content together. final class T3ScrollEdgeBleedView: ExpoView { // Fabric recycles scroll views, so everything changed here is put back before this view leaves // the window, which unmounting does before any recycling. private var scrollView: UIScrollView? private var container: UIView? - private var bleed: CGFloat = 0 + /// React Native's content view, which every row lives in. + private var content: UIView? + private var bleed = UIEdgeInsets.zero /// Whether the scroll view currently carries this view's changes. private var applied = false /// React Native's clipping, saved while bleeding. React Native only rewrites it when the @@ -55,55 +57,72 @@ final class T3ScrollEdgeBleedView: ExpoView { detach() scrollView = found container = parent + // React Native adds one plain UIView to its scroll view and mounts every child into it. + content = found.subviews.first { type(of: $0) == UIView.self } } bleed = verticalBarBleed() + // Without the content view to shift back, bleeding left would move the rows under the bar. + if content == nil { bleed.left = 0 } apply() } - /// How far the vertical bar's safe area reaches past this view's right edge, when this view sits - /// flush against the padding its column added for it. Nothing else is bled into: not a sidebar - /// column overlapping this one, not a notch, and not a bar on the left, where widening would - /// move the content and need a horizontal offset React Native keeps rewriting. - private func verticalBarBleed() -> CGFloat { - guard verticalBarIsOnTheRight() else { return 0 } + /// How far the vertical bar's safe area reaches past this view's edge, when this view sits flush + /// against the padding its column added for it. Nothing else is bled into: not a sidebar column + /// overlapping this one, and not a notch. + private func verticalBarBleed() -> UIEdgeInsets { + guard let side = verticalBarSide() else { return .zero } var ancestor = superview while let view = ancestor { - let inset = view.safeAreaInsets.right + let inset = side == .left ? view.safeAreaInsets.left : view.safeAreaInsets.right if inset > 0 { let frame = convert(bounds, to: view) - return abs(view.bounds.maxX - inset - frame.maxX) < 0.5 ? inset : 0 + let flush = side == .left + ? abs(frame.minX - (view.bounds.minX + inset)) < 0.5 + : abs(view.bounds.maxX - inset - frame.maxX) < 0.5 + guard flush else { return .zero } + return side == .left + ? UIEdgeInsets(top: 0, left: inset, bottom: 0, right: 0) + : UIEdgeInsets(top: 0, left: 0, bottom: 0, right: inset) } ancestor = view.superview } - return 0 + return .zero } - private func verticalBarIsOnTheRight() -> Bool { + private enum Side { case left, right } + + private func verticalBarSide() -> Side? { // The vertical-bar API ships in the iOS 27.1 SDK (UIKit 9127.0.85); see T3LayoutMetricsView. #if canImport(UIKit, _version: 9127.0.85) if #available(iOS 27.1, *) { let rtl = effectiveUserInterfaceLayoutDirection == .rightToLeft switch traitCollection.verticalBarEdge { - case .leading: return rtl - case .trailing: return !rtl - default: return false + case .leading: return rtl ? .right : .left + case .trailing: return rtl ? .left : .right + default: return nil } } #endif - return false + return nil } - /// Widening to the right leaves the content where React Native laid it out, so only the frame, - /// the container's clipping and the scroll indicator change; contentInset and contentOffset are - /// never touched. Nothing at all is written unless this view is bleeding or undoing its bleed. + /// Widening to the left would move the rows with it. Instead of a horizontal content offset, + /// which React Native rewrites on its own layout passes, the content view's bounds origin shifts + /// them back: React Native only ever sets that view's frame, which leaves its bounds origin. + /// Nothing at all is written unless this view is bleeding or undoing its bleed. private func apply() { guard let scrollView, let container else { return } - guard bleed > 0 || applied else { return } + let bleeding = bleed != .zero + guard bleeding || applied else { return } // React Native sizes the scroll view's component view; the UIScrollView inside follows it by // autoresizing, so the widened frame survives React Native's own layout updates. - let frame = container.bounds.inset(by: UIEdgeInsets(top: 0, left: 0, bottom: 0, right: -bleed)) + let frame = container.bounds.inset( + by: UIEdgeInsets(top: 0, left: -bleed.left, bottom: 0, right: -bleed.right)) if scrollView.frame != frame { scrollView.frame = frame } - if bleed == 0 { + if let content, content.bounds.origin.x != -bleed.left { + content.bounds.origin.x = -bleed.left + } + if !bleeding { if let clipped = containerClipped { containerClipped = nil container.layer.masksToBounds = clipped @@ -115,19 +134,20 @@ final class T3ScrollEdgeBleedView: ExpoView { container.layer.masksToBounds = false } var indicators = scrollView.verticalScrollIndicatorInsets - if indicators.right != bleed { - indicators.right = bleed + if indicators.right != bleed.right { + indicators.right = bleed.right scrollView.verticalScrollIndicatorInsets = indicators } - applied = bleed > 0 + applied = bleeding } private func detach() { guard scrollView != nil else { return } - bleed = 0 + bleed = .zero apply() scrollView = nil container = nil + content = nil } /// The scroll view sits a fixed few levels down (marker, React Native's scroll view component); diff --git a/docs/internals/mobile-navigation.md b/docs/internals/mobile-navigation.md index cf4dea3159403..e32f1da1c7ebf 100644 --- a/docs/internals/mobile-navigation.md +++ b/docs/internals/mobile-navigation.md @@ -53,11 +53,12 @@ preserves behavior that is easy to break when changing native headers: columns pad their content away from the iPhone Duo's vertical bar. Register a header scroll view only through [`NativeScrollViewMarker`](../../apps/mobile/src/native/NativeScrollViewMarker.tsx) - or `NativeScrollView`: their native wrapper widens the scroll view to the right - under that bar, so the blur reaches it while the content keeps its React Native - frame. It never bleeds left: that would need a horizontal content offset, which - React Native rewrites on its own layout passes. Lint rejects `ScrollViewMarker` - imported anywhere else. + or `NativeScrollView`: their native wrapper widens the scroll view under that + bar, so the blur reaches it while the content stays in the safe area. On the + left it shifts React Native's content view back through its bounds origin, + never with a content offset: React Native rewrites offsets on its own layout + passes, which left threads shifted under the bar. Lint rejects + `ScrollViewMarker` imported anywhere else. The patch adds native header props that require code generation and a new binary. Patch rebuild guidance lives in the