replace react-nav header hack with scroll edge interaction module

- new expo-scroll-edge-interaction native module (iOS-only) that sets up
  UIScrollEdgeElementContainerInteraction on iOS 26+
- new ScrollEdgeInteractionProvider context and hooks
- Layout.Screen gains `transparentHeader` prop
- Layout.Header.Outer auto-detects transparent mode from context
- List auto-registers scroll view and applies content inset

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Samuel Newman
2026-02-27 18:13:29 +02:00
parent b7283d92bf
commit 9836939728
15 changed files with 373 additions and 88 deletions
@@ -0,0 +1,6 @@
{
"platforms": ["ios"],
"ios": {
"modules": ["ExpoScrollEdgeInteractionModule"]
}
}
@@ -0,0 +1,2 @@
export type {ScrollEdgeInteractionEdge} from './src/ExpoScrollEdgeInteraction.types'
export {ExpoScrollEdgeInteractionView} from './src/ExpoScrollEdgeInteractionView'
@@ -0,0 +1,21 @@
Pod::Spec.new do |s|
s.name = 'ExpoScrollEdgeInteraction'
s.version = '1.0.0'
s.summary = 'UIScrollEdgeElementContainerInteraction for React Native views'
s.description = 'UIScrollEdgeElementContainerInteraction for React Native views'
s.author = 'bluesky-social'
s.homepage = 'https://github.com/bluesky-social/social-app'
s.platforms = { :ios => '13.4', :tvos => '13.4' }
s.source = { git: '' }
s.static_framework = true
s.dependency 'ExpoModulesCore'
# Swift/Objective-C compatibility
s.pod_target_xcconfig = {
'DEFINES_MODULE' => 'YES',
'SWIFT_COMPILATION_MODE' => 'wholemodule'
}
s.source_files = "**/*.{h,m,mm,swift,hpp,cpp}"
end
@@ -0,0 +1,19 @@
import ExpoModulesCore
public class ExpoScrollEdgeInteractionModule: Module {
public func definition() -> ModuleDefinition {
Name("ExpoScrollEdgeInteraction")
View(ExpoScrollEdgeInteractionView.self) {
Prop("nodeHandle") { (view: ExpoScrollEdgeInteractionView, prop: Int?) in
view.nodeHandle = prop
}
Prop("scrollViewTag") { (view: ExpoScrollEdgeInteractionView, prop: Int?) in
view.scrollViewTag = prop
}
Prop("edge") { (view: ExpoScrollEdgeInteractionView, prop: String?) in
view.edge = prop
}
}
}
}
@@ -0,0 +1,73 @@
import ExpoModulesCore
import React
class ExpoScrollEdgeInteractionView: ExpoView {
var nodeHandle: Int? {
didSet {
setupInteraction()
}
}
var scrollViewTag: Int? {
didSet {
setupInteraction()
}
}
var edge: String? {
didSet {
setupInteraction()
}
}
private var currentInteraction: NSObject?
private func setupInteraction() {
guard #available(iOS 26.0, *) else { return }
// Clean up old interaction
if let interaction = currentInteraction as? UIScrollEdgeElementContainerInteraction,
let headerView = findHeaderView() {
headerView.removeInteraction(interaction)
currentInteraction = nil
}
guard let nodeHandle = nodeHandle,
let scrollViewTag = scrollViewTag
else { return }
guard let headerView = appContext?.findView(withTag: nodeHandle, ofType: UIView.self) else {
return
}
// RCTScrollView wraps a UIScrollView get the inner one
let scrollView: UIScrollView?
if let rctScrollView = appContext?.findView(withTag: scrollViewTag, ofType: RCTScrollView.self) {
scrollView = rctScrollView.scrollView
} else {
scrollView = appContext?.findView(withTag: scrollViewTag, ofType: UIScrollView.self)
}
guard let scrollView = scrollView else { return }
let interaction = UIScrollEdgeElementContainerInteraction()
interaction.edge = resolveEdge()
interaction.scrollView = scrollView
headerView.addInteraction(interaction)
currentInteraction = interaction
}
private func resolveEdge() -> NSDirectionalRectEdge {
switch edge {
case "bottom": return .bottom
case "left": return .leading
case "right": return .trailing
default: return .top
}
}
private func findHeaderView() -> UIView? {
guard let nodeHandle = nodeHandle else { return nil }
return appContext?.findView(withTag: nodeHandle, ofType: UIView.self)
}
}
@@ -0,0 +1,11 @@
import {type ViewStyle} from 'react-native'
export type ScrollEdgeInteractionEdge = 'top' | 'bottom' | 'left' | 'right'
export interface ExpoScrollEdgeInteractionViewProps {
nodeHandle: number | null
scrollViewTag: number | null
edge?: ScrollEdgeInteractionEdge
children: React.ReactNode
style?: ViewStyle
}
@@ -0,0 +1,13 @@
import {requireNativeViewManager} from 'expo-modules-core'
import {type ExpoScrollEdgeInteractionViewProps} from './ExpoScrollEdgeInteraction.types'
const NativeView: React.ComponentType<ExpoScrollEdgeInteractionViewProps> =
requireNativeViewManager('ExpoScrollEdgeInteraction')
export function ExpoScrollEdgeInteractionView({
children,
...rest
}: ExpoScrollEdgeInteractionViewProps) {
return <NativeView {...rest}>{children}</NativeView>
}
@@ -0,0 +1,7 @@
import {type ExpoScrollEdgeInteractionViewProps} from './ExpoScrollEdgeInteraction.types'
export function ExpoScrollEdgeInteractionView({
children,
}: React.PropsWithChildren<ExpoScrollEdgeInteractionViewProps>) {
return children
}