From 9836939728cb7da7555ae7d633ac412dc1488827 Mon Sep 17 00:00:00 2001 From: Samuel Newman Date: Fri, 27 Feb 2026 18:13:29 +0200 Subject: [PATCH] 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) --- .../expo-module.config.json | 6 + modules/expo-scroll-edge-interaction/index.ts | 2 + .../ios/ExpoScrollEdgeInteraction.podspec | 21 ++++ .../ios/ExpoScrollEdgeInteractionModule.swift | 19 +++ .../ios/ExpoScrollEdgeInteractionView.swift | 73 +++++++++++ .../src/ExpoScrollEdgeInteraction.types.ts | 11 ++ .../src/ExpoScrollEdgeInteractionView.ios.tsx | 13 ++ .../src/ExpoScrollEdgeInteractionView.tsx | 7 ++ src/components/Layout/Header/index.ios.tsx | 106 ++++++---------- src/components/Layout/Header/index.shared.tsx | 16 --- .../Layout/ScrollEdgeInteraction.tsx | 118 ++++++++++++++++++ src/components/Layout/index.tsx | 37 +++++- src/components/dms/MessagesListHeader.tsx | 2 +- src/screens/Messages/Conversation.tsx | 2 +- src/view/com/util/List.tsx | 28 ++++- 15 files changed, 373 insertions(+), 88 deletions(-) create mode 100644 modules/expo-scroll-edge-interaction/expo-module.config.json create mode 100644 modules/expo-scroll-edge-interaction/index.ts create mode 100644 modules/expo-scroll-edge-interaction/ios/ExpoScrollEdgeInteraction.podspec create mode 100644 modules/expo-scroll-edge-interaction/ios/ExpoScrollEdgeInteractionModule.swift create mode 100644 modules/expo-scroll-edge-interaction/ios/ExpoScrollEdgeInteractionView.swift create mode 100644 modules/expo-scroll-edge-interaction/src/ExpoScrollEdgeInteraction.types.ts create mode 100644 modules/expo-scroll-edge-interaction/src/ExpoScrollEdgeInteractionView.ios.tsx create mode 100644 modules/expo-scroll-edge-interaction/src/ExpoScrollEdgeInteractionView.tsx create mode 100644 src/components/Layout/ScrollEdgeInteraction.tsx diff --git a/modules/expo-scroll-edge-interaction/expo-module.config.json b/modules/expo-scroll-edge-interaction/expo-module.config.json new file mode 100644 index 0000000000..a0574d7792 --- /dev/null +++ b/modules/expo-scroll-edge-interaction/expo-module.config.json @@ -0,0 +1,6 @@ +{ + "platforms": ["ios"], + "ios": { + "modules": ["ExpoScrollEdgeInteractionModule"] + } +} diff --git a/modules/expo-scroll-edge-interaction/index.ts b/modules/expo-scroll-edge-interaction/index.ts new file mode 100644 index 0000000000..5c23e7298a --- /dev/null +++ b/modules/expo-scroll-edge-interaction/index.ts @@ -0,0 +1,2 @@ +export type {ScrollEdgeInteractionEdge} from './src/ExpoScrollEdgeInteraction.types' +export {ExpoScrollEdgeInteractionView} from './src/ExpoScrollEdgeInteractionView' diff --git a/modules/expo-scroll-edge-interaction/ios/ExpoScrollEdgeInteraction.podspec b/modules/expo-scroll-edge-interaction/ios/ExpoScrollEdgeInteraction.podspec new file mode 100644 index 0000000000..2642f043e8 --- /dev/null +++ b/modules/expo-scroll-edge-interaction/ios/ExpoScrollEdgeInteraction.podspec @@ -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 diff --git a/modules/expo-scroll-edge-interaction/ios/ExpoScrollEdgeInteractionModule.swift b/modules/expo-scroll-edge-interaction/ios/ExpoScrollEdgeInteractionModule.swift new file mode 100644 index 0000000000..907bd7f22f --- /dev/null +++ b/modules/expo-scroll-edge-interaction/ios/ExpoScrollEdgeInteractionModule.swift @@ -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 + } + } + } +} diff --git a/modules/expo-scroll-edge-interaction/ios/ExpoScrollEdgeInteractionView.swift b/modules/expo-scroll-edge-interaction/ios/ExpoScrollEdgeInteractionView.swift new file mode 100644 index 0000000000..217a4580f2 --- /dev/null +++ b/modules/expo-scroll-edge-interaction/ios/ExpoScrollEdgeInteractionView.swift @@ -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) + } +} diff --git a/modules/expo-scroll-edge-interaction/src/ExpoScrollEdgeInteraction.types.ts b/modules/expo-scroll-edge-interaction/src/ExpoScrollEdgeInteraction.types.ts new file mode 100644 index 0000000000..d394f89819 --- /dev/null +++ b/modules/expo-scroll-edge-interaction/src/ExpoScrollEdgeInteraction.types.ts @@ -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 +} diff --git a/modules/expo-scroll-edge-interaction/src/ExpoScrollEdgeInteractionView.ios.tsx b/modules/expo-scroll-edge-interaction/src/ExpoScrollEdgeInteractionView.ios.tsx new file mode 100644 index 0000000000..4d40650324 --- /dev/null +++ b/modules/expo-scroll-edge-interaction/src/ExpoScrollEdgeInteractionView.ios.tsx @@ -0,0 +1,13 @@ +import {requireNativeViewManager} from 'expo-modules-core' + +import {type ExpoScrollEdgeInteractionViewProps} from './ExpoScrollEdgeInteraction.types' + +const NativeView: React.ComponentType = + requireNativeViewManager('ExpoScrollEdgeInteraction') + +export function ExpoScrollEdgeInteractionView({ + children, + ...rest +}: ExpoScrollEdgeInteractionViewProps) { + return {children} +} diff --git a/modules/expo-scroll-edge-interaction/src/ExpoScrollEdgeInteractionView.tsx b/modules/expo-scroll-edge-interaction/src/ExpoScrollEdgeInteractionView.tsx new file mode 100644 index 0000000000..438b9c356d --- /dev/null +++ b/modules/expo-scroll-edge-interaction/src/ExpoScrollEdgeInteractionView.tsx @@ -0,0 +1,7 @@ +import {type ExpoScrollEdgeInteractionViewProps} from './ExpoScrollEdgeInteraction.types' + +export function ExpoScrollEdgeInteractionView({ + children, +}: React.PropsWithChildren) { + return children +} diff --git a/src/components/Layout/Header/index.ios.tsx b/src/components/Layout/Header/index.ios.tsx index a48485314e..7bb0f4a5ec 100644 --- a/src/components/Layout/Header/index.ios.tsx +++ b/src/components/Layout/Header/index.ios.tsx @@ -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 + const transparentHeaderProps = useTransparentHeaderProps() + if (transparentHeaderProps) { + return ( + + ) } - return } -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 ( - 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} - - ) - }, [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() - 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 ( + + {children} + + ) } diff --git a/src/components/Layout/Header/index.shared.tsx b/src/components/Layout/Header/index.shared.tsx index 471286e7da..57b62d86b3 100644 --- a/src/components/Layout/Header/index.shared.tsx +++ b/src/components/Layout/Header/index.shared.tsx @@ -35,22 +35,6 @@ export type OuterProps = { noBottomBorder?: boolean headerRef?: React.RefObject 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 - * - * ``` - * and also set `noInsetTop={IS_LIQUID_GLASS}` on `Layout.Screen` - * - * @platform ios - * */ - transparent?: boolean } export function Outer({ diff --git a/src/components/Layout/ScrollEdgeInteraction.tsx b/src/components/Layout/ScrollEdgeInteraction.tsx new file mode 100644 index 0000000000..ad83b62f01 --- /dev/null +++ b/src/components/Layout/ScrollEdgeInteraction.tsx @@ -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) => void + setScrollViewTag: (update: React.SetStateAction) => void + setHeaderHeight: (height: number) => void +} + +const ScrollEdgeContext = createContext(null) +const ScrollEdgeHeightContext = createContext(null) + +export function ScrollEdgeInteractionProvider({ + children, + edge = 'top', +}: { + children: React.ReactNode + edge?: ScrollEdgeInteractionEdge +}) { + const [nodeHandle, setNodeHandle] = useState(null) + const [scrollViewTag, setScrollViewTag] = useState(null) + const [headerHeight, setHeaderHeight] = useState(0) + + if (!IS_LIQUID_GLASS) { + return children + } + + return ( + + + + {children} + + + + ) +} + +/** + * 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(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(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) +} diff --git a/src/components/Layout/index.tsx b/src/components/Layout/index.tsx index ae01c05b99..e6e3bb75b7 100644 --- a/src/components/Layout/index.tsx +++ b/src/components/Layout/index.tsx @@ -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 & { style?: StyleProp 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 && } + style={[ + a.util_screen_outer, + {paddingTop: skipInsetTop ? 0 : top}, + style, + ]} + {...props}> + {transparentHeader ? ( + + {children} + + ) : ( + children + )} + ) }) diff --git a/src/components/dms/MessagesListHeader.tsx b/src/components/dms/MessagesListHeader.tsx index 4dc483f3c4..d59c9323d3 100644 --- a/src/components/dms/MessagesListHeader.tsx +++ b/src/components/dms/MessagesListHeader.tsx @@ -55,7 +55,7 @@ export function MessagesListHeader({ }, [moderation]) return ( - + diff --git a/src/screens/Messages/Conversation.tsx b/src/screens/Messages/Conversation.tsx index 9c5d81646f..0ca20009bb 100644 --- a/src/screens/Messages/Conversation.tsx +++ b/src/screens/Messages/Conversation.tsx @@ -89,7 +89,7 @@ export function MessagesConversationScreenInner({route}: Props) { + transparentHeader={IS_LIQUID_GLASS}> diff --git a/src/view/com/util/List.tsx b/src/view/com/util/List.tsx index c8c4007f02..42bdb5b63c 100644 --- a/src/view/com/util/List.tsx +++ b/src/view/com/util/List.tsx @@ -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( headerOffset, style, progressViewOffset, + contentInset, automaticallyAdjustsScrollIndicatorInsets = false, ...props }, @@ -63,6 +70,13 @@ let List = forwardRef( 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( 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( scrollEventThrottle={1} style={style} // @ts-expect-error FlatList_INTERNAL ref type is wrong -sfn - ref={ref} + ref={ + transparentHeaderRef ? mergeRefs([ref, transparentHeaderRef]) : ref + } /> ) },