Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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);
Expand Down
11 changes: 6 additions & 5 deletions docs/internals/mobile-navigation.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading