diff --git a/example/src/App.tsx b/example/src/App.tsx
index b8b71d7..f1eff4a 100644
--- a/example/src/App.tsx
+++ b/example/src/App.tsx
@@ -7,7 +7,7 @@ import { GestureHandlerRootView } from 'react-native-gesture-handler';
import { enableScreens } from 'react-native-screens';
import { ExampleMenuScreen } from './components/ExampleMenuScreen';
import { ExampleScreen } from './screens/ExampleScreen';
-import { titleByKey } from './data';
+import { navigationByKey, titleByKey } from './data';
import type { RootStackParamList } from './types';
enableScreens();
@@ -55,6 +55,7 @@ export default function App() {
headerTransparent: true,
headerLargeTitleEnabled: true,
headerBackButtonDisplayMode: 'minimal',
+ ...navigationByKey[route.params.key],
headerTintColor: DynamicColorIOS({
light: 'black',
dark: 'white',
diff --git a/example/src/components/ExampleMenuScreen.tsx b/example/src/components/ExampleMenuScreen.tsx
index 3cbc4a7..e7e440c 100644
--- a/example/src/components/ExampleMenuScreen.tsx
+++ b/example/src/components/ExampleMenuScreen.tsx
@@ -1,7 +1,7 @@
import {
- DynamicColorIOS,
+ FlatList,
+ PlatformColor,
Pressable,
- ScrollView,
StyleSheet,
Text,
View,
@@ -11,76 +11,93 @@ import { exampleList } from '../data';
export function ExampleMenuScreen({ navigation }: { navigation: any }) {
return (
-
- {exampleList.map((item) => (
+ item.key}
+ style={styles.menuList}
+ contentContainerStyle={styles.menuContent}
+ renderItem={({ item }) => (
navigation.push('Example', { key: item.key })}
- style={styles.menuItem}
+ style={({ pressed }) => [
+ styles.menuItem,
+ pressed && styles.menuItemPressed,
+ ]}
>
-
-
-
+
{item.title}
{item.subtitle}
- ›
+
+
+
- ))}
-
+ )}
+ />
);
}
const styles = StyleSheet.create({
- menuScrollView: {
+ menuList: {
flex: 1,
- backgroundColor: DynamicColorIOS({ light: '#f2f2f7', dark: '#000000' }),
+ backgroundColor: PlatformColor('systemBackground'),
+ },
+ menuContent: {
+ paddingBottom: 24,
},
menuItem: {
- minHeight: 72,
+ minHeight: 78,
paddingHorizontal: 16,
- paddingVertical: 12,
+ paddingVertical: 14,
borderBottomWidth: StyleSheet.hairlineWidth,
- borderBottomColor: DynamicColorIOS({ light: '#d1d1d6', dark: '#2c2c2e' }),
- backgroundColor: DynamicColorIOS({ light: '#ffffff', dark: '#1c1c1e' }),
+ borderBottomColor: PlatformColor('separator'),
+ backgroundColor: PlatformColor('systemBackground'),
flexDirection: 'row',
alignItems: 'center',
},
+ menuItemPressed: {
+ opacity: 0.72,
+ },
menuIconWrap: {
- width: 30,
- height: 30,
- marginRight: 14,
+ width: 40,
+ height: 40,
+ marginRight: 16,
alignItems: 'center',
justifyContent: 'center',
borderWidth: 1,
- borderRadius: 8,
- backgroundColor: DynamicColorIOS({ light: '#f8f8f8', dark: '#2a2a2d' }),
+ borderRadius: 12,
},
menuTextColumn: {
flex: 1,
+ paddingRight: 12,
+ paddingLeft: 16,
},
menuTitle: {
fontSize: 17,
+ lineHeight: 22,
fontWeight: '600',
- color: DynamicColorIOS({ light: '#111111', dark: '#f5f5f5' }),
+ color: PlatformColor('label'),
},
menuSubtitle: {
- marginTop: 2,
- fontSize: 12,
- color: DynamicColorIOS({ light: '#6d6d72', dark: '#8e8e93' }),
+ marginTop: 1,
+ fontSize: 13,
+ lineHeight: 18,
+ fontWeight: '400',
+ color: PlatformColor('secondaryLabel'),
},
- menuChevron: {
- marginLeft: 12,
- fontSize: 24,
- lineHeight: 24,
- color: DynamicColorIOS({ light: '#c7c7cc', dark: '#636366' }),
+ menuChevronWrap: {
+ width: 24,
+ alignItems: 'flex-end',
},
});
diff --git a/example/src/data.ts b/example/src/data.ts
index effcfc6..24b30c2 100644
--- a/example/src/data.ts
+++ b/example/src/data.ts
@@ -1,67 +1,115 @@
-import type { ExampleKey } from './types';
+import type { ExampleKey, ExampleNavigationConfig } from './types';
export const exampleList: Array<{
key: ExampleKey;
title: string;
subtitle: string;
symbol: string;
- tint: string;
+ navigation?: ExampleNavigationConfig;
}> = [
{
key: 'appStore',
title: 'App Store Listing',
subtitle: 'Segmented control top bar',
symbol: 'bag',
- tint: '#2563eb',
+ navigation: {
+ title: 'App Store Listing',
+ headerTransparent: true,
+ headerLargeTitleEnabled: false,
+ headerShadowVisible: false,
+ headerBackButtonDisplayMode: 'minimal',
+ },
},
{
key: 'pullRequests',
title: 'Pull Requests',
subtitle: 'Filter chips with large title',
symbol: 'arrow.triangle.pull',
- tint: '#16a34a',
+ navigation: {
+ title: 'Pull Requests',
+ headerTransparent: true,
+ headerLargeTitleEnabled: true,
+ headerShadowVisible: false,
+ headerBackButtonDisplayMode: 'minimal',
+ },
},
{
key: 'prDetail',
title: 'PR Detail',
subtitle: 'Review banner + action buttons',
symbol: 'text.page.badge.magnifyingglass',
- tint: '#7c3aed',
+ navigation: {
+ title: 'PR Detail',
+ headerTransparent: true,
+ headerLargeTitleEnabled: true,
+ headerShadowVisible: false,
+ headerBackButtonDisplayMode: 'minimal',
+ },
},
{
key: 'transitionShowcase',
title: 'Transition Showcase',
subtitle: 'Top and bottom bars over color blocks',
symbol: 'paintpalette',
- tint: '#db2777',
+ navigation: {
+ title: 'Transition Showcase',
+ headerTransparent: true,
+ headerLargeTitleEnabled: true,
+ headerShadowVisible: false,
+ headerBackButtonDisplayMode: 'minimal',
+ },
},
{
key: 'toolbar',
title: 'Toolbar',
subtitle: 'Bottom edge bar emphasis',
symbol: 'hammer',
- tint: '#d97706',
+ navigation: {
+ title: 'Toolbar',
+ headerTransparent: true,
+ headerLargeTitleEnabled: true,
+ headerShadowVisible: false,
+ headerBackButtonDisplayMode: 'minimal',
+ },
},
{
key: 'searchBar',
title: 'Search Bar',
subtitle: 'Search-like screen with segmented top bar',
symbol: 'magnifyingglass',
- tint: '#0891b2',
+ navigation: {
+ title: 'Search Bar',
+ headerTransparent: true,
+ headerLargeTitleEnabled: true,
+ headerShadowVisible: false,
+ headerBackButtonDisplayMode: 'minimal',
+ },
},
{
key: 'tabAccessory',
title: 'Tab Accessory',
subtitle: 'Large bottom accessory-style bar',
symbol: 'music.note.list',
- tint: '#ea580c',
+ navigation: {
+ title: 'Tab Accessory',
+ headerTransparent: true,
+ headerLargeTitleEnabled: true,
+ headerShadowVisible: false,
+ headerBackButtonDisplayMode: 'minimal',
+ },
},
{
key: 'calendar',
title: 'Calendar',
subtitle: 'Week selector with stronger top bar',
symbol: 'calendar',
- tint: '#dc2626',
+ navigation: {
+ title: 'Calendar',
+ headerTransparent: true,
+ headerLargeTitleEnabled: true,
+ headerShadowVisible: false,
+ headerBackButtonDisplayMode: 'minimal',
+ },
},
];
@@ -69,6 +117,11 @@ export const titleByKey: Record = Object.fromEntries(
exampleList.map((item) => [item.key, item.title])
) as Record;
+export const navigationByKey: Record =
+ Object.fromEntries(
+ exampleList.map((item) => [item.key, item.navigation ?? {}])
+ ) as Record;
+
export const appColors = [
'#4fd1c5',
'#68d391',
diff --git a/example/src/types.ts b/example/src/types.ts
index 5c40b54..a994036 100644
--- a/example/src/types.ts
+++ b/example/src/types.ts
@@ -8,6 +8,14 @@ export type ExampleKey =
| 'tabAccessory'
| 'calendar';
+export type ExampleNavigationConfig = {
+ title?: string;
+ headerTransparent?: boolean;
+ headerLargeTitleEnabled?: boolean;
+ headerShadowVisible?: boolean;
+ headerBackButtonDisplayMode?: 'default' | 'generic' | 'minimal';
+};
+
export type RootStackParamList = {
Home: undefined;
Example: { key: ExampleKey };
diff --git a/ios/HybridScrollEdgeBar.swift b/ios/HybridScrollEdgeBar.swift
index 6c42395..5f3fc97 100644
--- a/ios/HybridScrollEdgeBar.swift
+++ b/ios/HybridScrollEdgeBar.swift
@@ -98,8 +98,7 @@ class ScrollEdgeBarContainerView: UIView {
override func willMove(toWindow newWindow: UIWindow?) {
super.willMove(toWindow: newWindow)
- // Before the view is removed from the window, restore all reparented
- // Fabric views so Fabric's unmount code finds them in the expected parent.
+ // Restore reparented views before Fabric unmounts them.
if newWindow == nil && window != nil {
cleanupController()
}
@@ -116,8 +115,7 @@ class ScrollEdgeBarContainerView: UIView {
override func layoutSubviews() {
super.layoutSubviews()
- // Always re-detect if any view is missing — React Native may mount
- // component children AFTER the component wrapper is added to the parent.
+ // React Native may mount children after the container enters the hierarchy.
if detectedScrollView == nil || detectedTopBarView == nil || detectedBottomBarView == nil {
detectChildViews()
}
@@ -128,14 +126,13 @@ class ScrollEdgeBarContainerView: UIView {
}
private func detectChildViews() {
- // In Fabric/Nitro, React children are siblings of our view (subviews of the
- // component wrapper), not subviews of our container. Search the parent's children.
+ // In Fabric/Nitro, React children are siblings of this view inside the
+ // generated component wrapper, not direct subviews of the container.
let searchViews = superview?.subviews ?? subviews
for subview in searchViews where subview !== self {
if detectedTopBarView == nil,
let topBarMarker = findView(ofType: ScrollEdgeBarTopBarView.self, in: subview) {
- // Use the component wrapper (superview) which holds both the marker
- // and the actual React content (Fabric adds children as siblings).
+ // Use the wrapper that holds both the marker and the React content.
detectedTopBarView = topBarMarker.superview ?? topBarMarker
} else if detectedBottomBarView == nil,
let bottomBarMarker = findView(ofType: ScrollEdgeBarBottomBarView.self, in: subview) {
@@ -146,7 +143,7 @@ class ScrollEdgeBarContainerView: UIView {
}
}
- // If already set up, push newly-detected bar views to the controller
+ // Bars can appear after initial setup.
if #available(iOS 16.0, *), isSetup,
let controller = edgeBarController as? ScrollEdgeBarController {
if let topBarView = detectedTopBarView {
@@ -160,7 +157,7 @@ class ScrollEdgeBarContainerView: UIView {
trySetup()
}
- // Explicit wiring from the component view (preferred over discovery)
+ // Explicit wiring from the generated component view.
func setTopBarView(_ view: UIView?) {
detectedTopBarView = view as? ScrollEdgeBarTopBarView
trySetup()
@@ -228,10 +225,8 @@ class ScrollEdgeBarContainerView: UIView {
guard let startVC = nearestVC else { return nil }
- // Walk up the VC parent chain and prefer the direct child of UITabBarController
- // first, then fall back to the direct child of UINavigationController.
- // This keeps the hosting controller close to the system container that owns
- // the relevant safe area and scroll-edge behavior.
+ // Prefer the direct child of the nearest system container so safe-area
+ // propagation and scroll-edge behavior come from the correct owner.
var candidate = startVC
while let parentVC = candidate.parent {
if parentVC is UITabBarController {
@@ -320,8 +315,7 @@ class ScrollEdgeBarContainerView: UIView {
}
}
- /// Called from the generated ObjC++ unmount code to tear down the SwiftUI
- /// hosting controller and restore reparented views before Fabric's assertions.
+ /// Called from generated ObjC++ unmount code before Fabric tears down children.
@objc func prepareForFabricUnmount() {
cleanupController()
}
@@ -351,7 +345,7 @@ class ScrollEdgeBarContainerView: UIView {
}
}
-// MARK: - ScrollEdgeBarController (iOS 16+, scroll-edge effect requires iOS 26)
+// MARK: - ScrollEdgeBarController
@available(iOS 16.0, *)
final class ScrollEdgeBarController: UIViewController {
@@ -366,10 +360,11 @@ final class ScrollEdgeBarController: UIViewController {
private var bottomBarView: UIView?
private var hostingController: UIHostingController?
private var didSetup = false
- private var registeredScrollObservation: NSKeyValueObservation?
- private var registeredScrollDebugCount = 0
+ private var lastTopInset: CGFloat = 0
+ private var lastBottomInset: CGFloat = 0
+ private var displayLink: CADisplayLink?
- // Track original Fabric parents + subview indices for teardown restore
+ // Original parents and indices for teardown restore.
private weak var scrollViewOriginalParent: UIView?
private var scrollViewOriginalIndex: Int = 0
private weak var topBarOriginalParent: UIView?
@@ -411,6 +406,19 @@ final class ScrollEdgeBarController: UIViewController {
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = .clear
+ scrollView.alpha = 0
+ }
+
+ override func viewWillDisappear(_ animated: Bool) {
+ super.viewWillDisappear(animated)
+ stopDisplayLink()
+ }
+
+ override func viewDidAppear(_ animated: Bool) {
+ super.viewDidAppear(animated)
+ if didSetup {
+ startDisplayLink()
+ }
}
override func viewDidLayoutSubviews() {
@@ -437,9 +445,7 @@ final class ScrollEdgeBarController: UIViewController {
let wrapperView = ScrollEdgeBarWrapperView(
scrollView: scrollView,
topBarContent: topContent,
- bottomBarContent: bottomContent,
- topBarOffset: topBarOffset,
- bottomBarOffset: bottomBarOffset
+ bottomBarContent: bottomContent
)
let hosting = UIHostingController(rootView: wrapperView)
@@ -460,67 +466,41 @@ final class ScrollEdgeBarController: UIViewController {
hosting.didMove(toParent: self)
hostingController = hosting
hosting.view.layoutIfNeeded()
-
- // Register the SwiftUI scroll view with the navigation bar so the large
- // title collapses naturally as the user scrolls. Done async so SwiftUI
- // has finished its first layout pass before we search the hierarchy.
- // This API is available on UIViewController and used here so the
- // navigation bar tracks the SwiftUI-hosted scroll view instead of the
- // original reparented React Native scroll view.
- DispatchQueue.main.async { [weak self] in
- guard let self else { return }
- let swiftuiScrollView = self.findSwiftUIScrollView()
- self.logScrollGeometry(label: "rn", scrollView: self.scrollView)
- if let swiftuiScrollView {
- self.logScrollGeometry(label: "swiftui", scrollView: swiftuiScrollView)
+ let estimatedInsets = UIEdgeInsets(
+ top: estimatedTopBarHeight + topBarOffset,
+ left: 0,
+ bottom: estimatedBottomBarHeight + bottomBarOffset,
+ right: 0
+ )
+ scrollView.contentInset = estimatedInsets
+ scrollView.verticalScrollIndicatorInsets = estimatedInsets
+ scrollView.contentOffset = CGPoint(x: 0, y: -estimatedInsets.top)
+ scrollView.alpha = 1
+
+ DispatchQueue.main.async {
+ if #available(iOS 26.0, *) {
+ // In the RN containment hierarchy, navigation tracking does not
+ // get inferred automatically. Register the real wrapped scroll view.
+ self.parent?.setContentScrollView(self.scrollView, for: .top)
}
- self.startRegisteredScrollDebug(label: "registered", on: self.scrollView)
- self.parent?.setContentScrollView(self.scrollView, for: .top)
+ self.applyInsets()
+ self.startDisplayLink()
}
}
- private func logScrollGeometry(label: String, scrollView: UIScrollView) {
- print("[ScrollEdgeBar][NavTrack][\(label)] class=\(NSStringFromClass(type(of: scrollView))) contentInset.top=\(scrollView.contentInset.top) adjustedTop=\(scrollView.adjustedContentInset.top) offsetY=\(scrollView.contentOffset.y)")
- }
-
- private func startRegisteredScrollDebug(label: String, on scrollView: UIScrollView) {
- registeredScrollObservation = nil
- registeredScrollDebugCount = 0
-
- print("[ScrollEdgeBar][NavTrack][\(label)] register class=\(NSStringFromClass(type(of: scrollView))) contentInset.top=\(scrollView.contentInset.top) adjustedTop=\(scrollView.adjustedContentInset.top) offsetY=\(scrollView.contentOffset.y)")
-
- registeredScrollObservation = scrollView.observe(\.contentOffset, options: [.new]) { [weak self] sv, _ in
- guard let self else { return }
- guard self.registeredScrollDebugCount < 20 else { return }
- self.registeredScrollDebugCount += 1
- print("[ScrollEdgeBar][NavTrack][\(label)] offsetY=\(sv.contentOffset.y) contentInset.top=\(sv.contentInset.top) adjustedTop=\(sv.adjustedContentInset.top)")
- }
- }
-
- private func findSwiftUIScrollView() -> UIScrollView? {
- guard let hostingView = hostingController?.view else { return nil }
- return firstScrollView(in: hostingView)
- }
-
- private func firstScrollView(in view: UIView) -> UIScrollView? {
- if let sv = view as? UIScrollView { return sv }
- for sub in view.subviews {
- if let found = firstScrollView(in: sub) { return found }
- }
- return nil
- }
-
private func updateHostingControllerIfNeeded() {
guard didSetup else { return }
let wrapperView = ScrollEdgeBarWrapperView(
scrollView: scrollView,
topBarContent: makeBarContent(topBarView, estimatedHeight: estimatedTopBarHeight),
- bottomBarContent: makeBarContent(bottomBarView, estimatedHeight: estimatedBottomBarHeight),
- topBarOffset: topBarOffset,
- bottomBarOffset: bottomBarOffset
+ bottomBarContent: makeBarContent(bottomBarView, estimatedHeight: estimatedBottomBarHeight)
)
hostingController?.rootView = wrapperView
+
+ DispatchQueue.main.async {
+ self.applyInsets()
+ }
}
func setOffsets(top: CGFloat, bottom: CGFloat) {
@@ -529,30 +509,107 @@ final class ScrollEdgeBarController: UIViewController {
updateHostingControllerIfNeeded()
}
- /// Restore all reparented views to their original Fabric parents so
- /// Fabric's unmount logic finds them in the expected location.
+ private func applyInsets() {
+ let (topInset, bottomInset) = findEdgeBarInsets()
+ let adjustedTop = topInset + topBarOffset
+ let adjustedBottom = bottomInset + bottomBarOffset
+
+ lastTopInset = adjustedTop
+ lastBottomInset = adjustedBottom
+
+ scrollView.contentInset = UIEdgeInsets(top: adjustedTop, left: 0, bottom: adjustedBottom, right: 0)
+ scrollView.verticalScrollIndicatorInsets = UIEdgeInsets(top: adjustedTop, left: 0, bottom: adjustedBottom, right: 0)
+ scrollView.contentOffset = CGPoint(x: 0, y: -adjustedTop)
+ }
+
+ private func startDisplayLink() {
+ guard displayLink == nil else { return }
+ let link = CADisplayLink(target: self, selector: #selector(displayLinkFired))
+ link.add(to: .main, forMode: .common)
+ displayLink = link
+ }
+
+ private func stopDisplayLink() {
+ displayLink?.invalidate()
+ displayLink = nil
+ }
+
+ @objc private func displayLinkFired() {
+ let (topInset, bottomInset) = findEdgeBarInsets()
+ let adjustedTop = topInset + topBarOffset
+ let adjustedBottom = bottomInset + bottomBarOffset
+
+ guard adjustedTop != lastTopInset || adjustedBottom != lastBottomInset else { return }
+
+ lastTopInset = adjustedTop
+ lastBottomInset = adjustedBottom
+
+ let newInsets = UIEdgeInsets(top: adjustedTop, left: 0, bottom: adjustedBottom, right: 0)
+ UIView.animate(withDuration: 0.3, delay: 0, options: [.beginFromCurrentState, .curveEaseInOut]) {
+ self.scrollView.contentInset = newInsets
+ self.scrollView.verticalScrollIndicatorInsets = newInsets
+ }
+ }
+
+ private func findEdgeBarInsets() -> (top: CGFloat, bottom: CGFloat) {
+ guard let rootView = navigationController?.view ?? view.window?.rootViewController?.view else {
+ return (estimatedTopBarHeight, estimatedBottomBarHeight)
+ }
+
+ var topInset: CGFloat = estimatedTopBarHeight
+ var bottomInset: CGFloat = estimatedBottomBarHeight
+ let screenHeight = view.bounds.height
+
+ findEdgeBarViews(in: rootView) { barView in
+ let frameInWindow = barView.convert(barView.bounds, to: nil)
+
+ if frameInWindow.origin.y < screenHeight / 2 {
+ topInset = frameInWindow.maxY
+ }
+
+ if frameInWindow.maxY > screenHeight / 2 {
+ bottomInset = screenHeight - frameInWindow.minY
+ }
+ }
+
+ return (topInset, bottomInset)
+ }
+
+ private func findEdgeBarViews(in view: UIView, handler: (UIView) -> Void) {
+ for interaction in view.interactions {
+ let className = String(describing: type(of: interaction))
+ if className.contains("ScrollPocketBarInteraction") {
+ handler(view)
+ return
+ }
+ }
+
+ for subview in view.subviews {
+ findEdgeBarViews(in: subview, handler: handler)
+ }
+ }
+
+ /// Restore all reparented views to their original parents so Fabric unmount
+ /// finds the expected hierarchy.
func cleanup() {
- // Tear down hosting controller first
hostingController?.willMove(toParent: nil)
hostingController?.view.removeFromSuperview()
hostingController?.removeFromParent()
hostingController = nil
didSetup = false
- registeredScrollObservation = nil
- registeredScrollDebugCount = 0
+ stopDisplayLink()
- // Restore scroll view at its original index
scrollView.removeFromSuperview()
scrollView.isScrollEnabled = true
scrollView.contentInsetAdjustmentBehavior = .automatic
scrollView.showsVerticalScrollIndicator = true
scrollView.showsHorizontalScrollIndicator = true
+ scrollView.alpha = 1
if let parent = scrollViewOriginalParent {
let idx = min(scrollViewOriginalIndex, parent.subviews.count)
parent.insertSubview(scrollView, at: idx)
}
- // Restore bar views at their original indices
if let topBar = topBarView, let parent = topBarOriginalParent {
topBar.removeFromSuperview()
let idx = min(topBarOriginalIndex, parent.subviews.count)
@@ -564,12 +621,10 @@ final class ScrollEdgeBarController: UIViewController {
parent.insertSubview(bottomBar, at: idx)
}
- // Deregister the scroll view from the navigation bar
if #available(iOS 26.0, *) {
parent?.setContentScrollView(nil, for: .top)
}
- // Remove self from parent VC
willMove(toParent: nil)
view.removeFromSuperview()
removeFromParent()
@@ -604,7 +659,7 @@ final class PassthroughView: UIView {
}
}
-// MARK: - SwiftUI Views (iOS 16+, scroll-edge effect requires iOS 26)
+// MARK: - SwiftUI Views
@available(iOS 16.0, *)
struct BarViewWrapper: UIViewRepresentable {
@@ -637,7 +692,7 @@ struct BarViewWrapper: UIViewRepresentable {
func sizeThatFits(_ proposal: ProposedViewSize, uiView: UIView, context: Context) -> CGSize? {
let width = proposal.width ?? UIView.layoutFittingExpandedSize.width
- // Prefer the bounds height set by React Native's layout engine
+ // Prefer the height React Native laid out for the wrapped bar view.
let height = barView.bounds.height
if height > 0 {
return CGSize(width: width, height: height)
@@ -665,17 +720,12 @@ struct ScrollEdgeBarWrapperView: View {
let scrollView: UIScrollView
let topBarContent: AnyView?
let bottomBarContent: AnyView?
- let topBarOffset: CGFloat
- let bottomBarOffset: CGFloat
- @State private var contentHeight: CGFloat = 5000
var body: some View {
- applyBars(to:
- ScrollView {
- ScrollContentBridge(scrollView: scrollView, contentHeight: $contentHeight)
- .frame(height: contentHeight)
- }
- )
+ let base = DirectScrollViewWrapper(scrollView: scrollView)
+ .ignoresSafeArea(.all)
+
+ applyBars(to: base)
}
@ViewBuilder
@@ -725,50 +775,39 @@ struct ScrollEdgeBarWrapperView: View {
}
}
-/// Embeds the RN scroll view (with scrolling disabled) inside a native SwiftUI
-/// ScrollView. SwiftUI handles scrolling so `safeAreaBar` works correctly.
-/// The scroll view is kept intact (no content extraction) so it survives
-/// rootView updates when bars are detected late.
@available(iOS 16.0, *)
-struct ScrollContentBridge: UIViewRepresentable {
+struct DirectScrollViewWrapper: UIViewRepresentable {
let scrollView: UIScrollView
- @Binding var contentHeight: CGFloat
- func makeCoordinator() -> Coordinator { Coordinator() }
+ func makeUIView(context: Context) -> UIView {
+ let container = UIView()
- func makeUIView(context: Context) -> UIScrollView {
- scrollView.isScrollEnabled = false
+ scrollView.translatesAutoresizingMaskIntoConstraints = false
scrollView.contentInsetAdjustmentBehavior = .never
- scrollView.showsVerticalScrollIndicator = false
scrollView.showsHorizontalScrollIndicator = false
+ scrollView.alwaysBounceHorizontal = false
+ scrollView.isDirectionalLockEnabled = true
- // Observe content size changes to update SwiftUI layout
- context.coordinator.observation = scrollView.observe(\.contentSize, options: [.new]) { sv, _ in
- let newHeight = sv.contentSize.height
- if newHeight > 0 && newHeight != contentHeight {
- DispatchQueue.main.async {
- contentHeight = newHeight
- }
- }
- }
+ container.addSubview(scrollView)
- if scrollView.contentSize.height > 0 {
- DispatchQueue.main.async {
- contentHeight = scrollView.contentSize.height
+ NSLayoutConstraint.activate([
+ scrollView.topAnchor.constraint(equalTo: container.topAnchor),
+ scrollView.leadingAnchor.constraint(equalTo: container.leadingAnchor),
+ scrollView.trailingAnchor.constraint(equalTo: container.trailingAnchor),
+ scrollView.bottomAnchor.constraint(equalTo: container.bottomAnchor)
+ ])
+
+ DispatchQueue.main.async {
+ if let contentView = scrollView.subviews.first(where: { !($0 is UIImageView) }) {
+ contentView.translatesAutoresizingMaskIntoConstraints = false
+ NSLayoutConstraint.activate([
+ contentView.widthAnchor.constraint(equalTo: scrollView.frameLayoutGuide.widthAnchor)
+ ])
}
}
- return scrollView
- }
-
- func sizeThatFits(_ proposal: ProposedViewSize, uiView: UIScrollView, context: Context) -> CGSize? {
- let width = proposal.width ?? uiView.bounds.width
- return CGSize(width: width, height: max(contentHeight, 1))
+ return container
}
- func updateUIView(_ uiView: UIScrollView, context: Context) {}
-
- class Coordinator: NSObject {
- var observation: NSKeyValueObservation?
- }
+ func updateUIView(_ uiView: UIView, context: Context) {}
}