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
}
+41 -65
View File
@@ -1,10 +1,8 @@
import {useLayoutEffect, useMemo, useState} from 'react'
import {useWindowDimensions, View} from 'react-native'
import {useIsFocused, useNavigation} from '@react-navigation/native'
import {View} from 'react-native'
import {useSafeAreaInsets} from 'react-native-safe-area-context'
import {type NavigationProp} from '#/lib/routes/types'
import {atoms as a} from '#/alf'
import {IS_LIQUID_GLASS} from '#/env'
import {atoms as a, useGutters} from '#/alf'
import {useTransparentHeaderProps} from '#/components/Layout/ScrollEdgeInteraction'
import {Outer as DefaultOuter, type OuterProps} from './index.shared'
export {
@@ -18,68 +16,46 @@ export {
} from './index.shared'
export function Outer(props: OuterProps) {
if (IS_LIQUID_GLASS && props.transparent) {
return <TransparentOuter {...props} />
const transparentHeaderProps = useTransparentHeaderProps()
if (transparentHeaderProps) {
return (
<TransparentOuter
transparentHeaderProps={transparentHeaderProps}
{...props}
/>
)
}
return <DefaultOuter {...props} />
}
function TransparentOuter({children, headerRef}: OuterProps) {
const [headerWidth, setHeaderWidth] = useState(0)
const {width: screenWidth} = useWindowDimensions()
function TransparentOuter({
children,
transparentHeaderProps,
}: OuterProps & {
transparentHeaderProps: {ref: (node: View | null) => void; onLayout: any}
}) {
const {top} = useSafeAreaInsets()
const gutters = useGutters([0, 'base'])
// bit of a hack - react-native-screens initially renders the header too wide
// so let's delay showing it until the padding has been applied -sfn
const isInitialRender = headerWidth === 0 || headerWidth === screenWidth
const headerElement = useMemo(() => {
return (
<View
ref={headerRef}
onLayout={evt => setHeaderWidth(evt.nativeEvent.layout.width)}
style={[
a.flex_1,
a.flex_row,
a.align_center,
a.gap_sm,
// built-in padding, but slightly more on right than left,
// so compensate for that. this is because we're using
// headerRightItems
a.pl_xs,
isInitialRender && {opacity: 0},
]}>
{children}
</View>
)
}, [children, headerRef, isInitialRender])
// this is how expo-router handles it
// https://github.com/expo/expo/blob/main/packages/expo-router/src/views/Screen.tsx#L34
// note: I'm skipping handling preloading for now -sfn
const navigation = useNavigation<NavigationProp>()
const isFocused = useIsFocused()
useLayoutEffect(() => {
if (isFocused) {
navigation.setOptions({
headerShown: true,
// abuse the headerItems API so we get
// the sweet sweet progressive blur header.
// unclear why just `header: () => elem` doesn't work -sfn
unstable_headerRightItems: () => [
{
type: 'custom',
element: headerElement,
hidesSharedBackground: true,
},
],
headerTransparent: true,
headerBackVisible: false,
scrollEdgeEffects: {
top: 'soft',
},
})
}
}, [isFocused, navigation, headerElement])
return null
return (
<View
collapsable={false}
ref={transparentHeaderProps.ref}
onLayout={transparentHeaderProps.onLayout}
style={[
a.absolute,
a.top_0,
a.left_0,
a.right_0,
a.z_10,
a.flex_row,
a.align_center,
a.gap_sm,
gutters,
{paddingTop: top, minHeight: 48 + top},
a.pb_xs,
]}>
{children}
</View>
)
}
@@ -35,22 +35,6 @@ export type OuterProps = {
noBottomBorder?: boolean
headerRef?: React.RefObject<View | null>
sticky?: boolean
/**
* Use native transparent blurred header on iOS 26 (looks very nice)
*
* When using this, make sure to enable these props on the scrollview for the screen:
* ```tsx
* <Layout.Content
* contentInsetAdjustmentBehavior="automatic"
* automaticallyAdjustsScrollIndicatorInsets
* // everything else
* />
* ```
* and also set `noInsetTop={IS_LIQUID_GLASS}` on `Layout.Screen`
*
* @platform ios
* */
transparent?: boolean
}
export function Outer({
@@ -0,0 +1,118 @@
import {createContext, useContext, useRef, useState} from 'react'
import {findNodeHandle, type LayoutChangeEvent, type View} from 'react-native'
import {atoms as a} from '#/alf'
import {IS_LIQUID_GLASS} from '#/env'
import {
ExpoScrollEdgeInteractionView,
type ScrollEdgeInteractionEdge,
} from '../../../modules/expo-scroll-edge-interaction'
interface ScrollEdgeContextValue {
setNodeHandle: (update: React.SetStateAction<number | null>) => void
setScrollViewTag: (update: React.SetStateAction<number | null>) => void
setHeaderHeight: (height: number) => void
}
const ScrollEdgeContext = createContext<ScrollEdgeContextValue | null>(null)
const ScrollEdgeHeightContext = createContext<number | null>(null)
export function ScrollEdgeInteractionProvider({
children,
edge = 'top',
}: {
children: React.ReactNode
edge?: ScrollEdgeInteractionEdge
}) {
const [nodeHandle, setNodeHandle] = useState<number | null>(null)
const [scrollViewTag, setScrollViewTag] = useState<number | null>(null)
const [headerHeight, setHeaderHeight] = useState(0)
if (!IS_LIQUID_GLASS) {
return children
}
return (
<ScrollEdgeContext.Provider
value={{setNodeHandle, setScrollViewTag, setHeaderHeight}}>
<ScrollEdgeHeightContext.Provider value={headerHeight}>
<ExpoScrollEdgeInteractionView
nodeHandle={nodeHandle}
scrollViewTag={scrollViewTag}
edge={edge}
style={a.flex_1}>
{children}
</ExpoScrollEdgeInteractionView>
</ScrollEdgeHeightContext.Provider>
</ScrollEdgeContext.Provider>
)
}
/**
* Returns a ref callback to attach to the header view. The native module
* will add a UIScrollEdgeElementContainerInteraction to this view.
* Returns undefined when there's no provider (non-Liquid Glass).
*/
export function useTransparentHeaderProps() {
const ctx = useContext(ScrollEdgeContext)
// Track which handle this specific hook instance set, so that
// unmounting one header doesn't clobber another's handle.
const ourHandle = useRef<number | null>(null)
if (!ctx) return undefined
const {setNodeHandle, setHeaderHeight} = ctx
const refCallback = (node: View | null) => {
const handle = node ? findNodeHandle(node) : null
if (handle !== null) {
ourHandle.current = handle
setNodeHandle(handle)
} else {
// Only clear if we were the last one to set it
const prev = ourHandle.current
ourHandle.current = null
setNodeHandle(cur => (cur === prev ? null : cur))
}
}
const onLayout = (e: LayoutChangeEvent) => {
setHeaderHeight(e.nativeEvent.layout.height)
}
return {ref: refCallback, onLayout}
}
/**
* Registers a scroll view with the scroll edge interaction provider.
* Called automatically by List.
* Returns a ref callback, or undefined if no provider.
*/
export function useScrollEdgeScrollView() {
const ctx = useContext(ScrollEdgeContext)
const ourTag = useRef<number | null>(null)
if (!ctx) return undefined
const {setScrollViewTag} = ctx
return (node: any) => {
const tag = node ? findNodeHandle(node) : null
if (tag !== null) {
ourTag.current = tag
setScrollViewTag(tag)
} else {
const prev = ourTag.current
ourTag.current = null
setScrollViewTag(cur => (cur === prev ? null : cur))
}
}
}
/**
* Returns the measured header height, or null if not inside a
* transparent header provider.
*/
export function useTransparentHeaderHeight(): number | null {
return useContext(ScrollEdgeHeightContext)
}
+34 -3
View File
@@ -22,32 +22,63 @@ import {
import {useDialogContext} from '#/components/Dialog'
import {CENTER_COLUMN_OFFSET, SCROLLBAR_OFFSET} from '#/components/Layout/const'
import {ScrollbarOffsetContext} from '#/components/Layout/context'
import {ScrollEdgeInteractionProvider} from '#/components/Layout/ScrollEdgeInteraction'
import {IS_WEB} from '#/env'
export * from '#/components/Layout/const'
export * as Header from '#/components/Layout/Header'
export {useTransparentHeaderHeight} from '#/components/Layout/ScrollEdgeInteraction'
export type ScreenProps = React.ComponentProps<typeof View> & {
style?: StyleProp<ViewStyle>
noInsetTop?: boolean
/**
* Enables a transparent, absolutely-positioned header with the iOS 26
* Liquid Glass scroll-edge blur effect. When set, wraps screen children
* in a `ScrollEdgeInteractionProvider`, implies `noInsetTop`, and causes
* `Layout.Header.Outer` to render as a transparent overlay.
*
* The blur effect only works with `List` (native FlatList), not with
* `Layout.Content` (Animated.ScrollView). `List` automatically picks up
* the header height and applies `contentInset`.
*
* On non-Liquid-Glass devices this is a no-op.
*
* @platform ios
*/
transparentHeader?: boolean
}
/**
* Outermost component of every screen
*/
export const Screen = memo(function Screen({
children,
style,
noInsetTop,
transparentHeader,
...props
}: ScreenProps) {
const {top} = useSafeAreaInsets()
const skipInsetTop = noInsetTop || transparentHeader
return (
<>
{IS_WEB && <WebCenterBorders />}
<View
style={[a.util_screen_outer, {paddingTop: noInsetTop ? 0 : top}, style]}
{...props}
/>
style={[
a.util_screen_outer,
{paddingTop: skipInsetTop ? 0 : top},
style,
]}
{...props}>
{transparentHeader ? (
<ScrollEdgeInteractionProvider>
{children}
</ScrollEdgeInteractionProvider>
) : (
children
)}
</View>
</>
)
})
+1 -1
View File
@@ -55,7 +55,7 @@ export function MessagesListHeader({
}, [moderation])
return (
<Layout.Header.Outer transparent>
<Layout.Header.Outer>
<View style={[a.flex_1, a.flex_row, a.gap_xs, a.align_start]}>
<View style={[{minHeight: PFP_SIZE}, a.justify_center]}>
<Layout.Header.BackButton />
+1 -1
View File
@@ -89,7 +89,7 @@ export function MessagesConversationScreenInner({route}: Props) {
<Layout.Screen
testID="convoScreen"
style={web([{minHeight: 0}, a.flex_1])}
noInsetTop={IS_LIQUID_GLASS}>
transparentHeader={IS_LIQUID_GLASS}>
<ConvoProvider key={convoId} convoId={convoId}>
<Inner />
</ConvoProvider>
+26 -2
View File
@@ -6,14 +6,20 @@ import {
useAnimatedScrollHandler,
useSharedValue,
} from 'react-native-reanimated'
import {useSafeAreaInsets} from 'react-native-safe-area-context'
import {updateActiveVideoViewAsync} from '@haileyok/bluesky-video'
import {useDedupe} from '#/lib/hooks/useDedupe'
import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
import {mergeRefs} from '#/lib/merge-refs'
import {useScrollHandlers} from '#/lib/ScrollContext'
import {addStyle} from '#/lib/styles'
import {useLightbox} from '#/state/lightbox'
import {useTheme} from '#/alf'
import {
useScrollEdgeScrollView,
useTransparentHeaderHeight,
} from '#/components/Layout/ScrollEdgeInteraction'
import {IS_IOS} from '#/env'
import {FlatList_INTERNAL} from './Views'
@@ -54,6 +60,7 @@ let List = forwardRef<ListMethods, ListProps>(
headerOffset,
style,
progressViewOffset,
contentInset,
automaticallyAdjustsScrollIndicatorInsets = false,
...props
},
@@ -63,6 +70,13 @@ let List = forwardRef<ListMethods, ListProps>(
const t = useTheme()
const dedupe = useDedupe(400)
const scrollsToTop = useAllowScrollToTop()
const transparentHeaderRef = useScrollEdgeScrollView()
const insets = useSafeAreaInsets()
const transparentHeaderHeight = useTransparentHeaderHeight()
const isHeaderTransparent = transparentHeaderHeight != null
const headerHeight = isHeaderTransparent
? transparentHeaderHeight - insets.top
: undefined
const handleScrolledDownChange = useNonReactiveCallback(
(didScrollDown: boolean) => {
@@ -163,10 +177,18 @@ let List = forwardRef<ListMethods, ListProps>(
automaticallyAdjustsScrollIndicatorInsets
}
scrollIndicatorInsets={{
top: headerOffset,
top: headerHeight || headerOffset,
right: 1,
...props.scrollIndicatorInsets,
}}
contentInset={
headerHeight
? {
...contentInset,
top: headerHeight,
}
: contentInset
}
indicatorStyle={t.scheme === 'dark' ? 'white' : 'black'}
contentOffset={contentOffset}
refreshControl={refreshControl}
@@ -175,7 +197,9 @@ let List = forwardRef<ListMethods, ListProps>(
scrollEventThrottle={1}
style={style}
// @ts-expect-error FlatList_INTERNAL ref type is wrong -sfn
ref={ref}
ref={
transparentHeaderRef ? mergeRefs([ref, transparentHeaderRef]) : ref
}
/>
)
},