From 65dffc1c4fc828b419df0990a84decfd0dad2371 Mon Sep 17 00:00:00 2001 From: Oskar Pawica Date: Fri, 27 Mar 2026 20:10:46 +0100 Subject: [PATCH 1/8] feat: first codex pass --- README.md | 274 ++++++------------ example/src/app/animated-on-scroll.tsx | 37 +-- example/src/app/collapsible-pager.tsx | 42 ++- example/src/app/colors.tsx | 45 ++- example/src/app/external-ref.tsx | 37 +-- example/src/app/flashlist.tsx | 19 +- example/src/app/flatlist-handlers.tsx | 37 +-- example/src/app/flatlist.tsx | 37 +-- example/src/app/header-pan.tsx | 37 +-- example/src/app/legendlist.tsx | 19 +- example/src/app/new-api.tsx | 134 +++++++++ example/src/app/offset-margin.tsx | 37 +-- example/src/app/offset-none.tsx | 37 +-- example/src/app/offset-padding.tsx | 37 +-- example/src/app/offset-top.tsx | 37 +-- example/src/app/offset-transform.tsx | 37 +-- example/src/app/overscroll.tsx | 37 +-- example/src/app/pager-header-pan.tsx | 42 ++- example/src/app/refresh-flatlist-control.tsx | 37 +-- .../src/app/refresh-flatlist-props-offset.tsx | 37 +-- example/src/app/refresh-flatlist-props.tsx | 37 +-- .../src/app/refresh-scrollview-control.tsx | 37 +-- example/src/app/scroll-handlers.tsx | 37 +-- example/src/app/scroll-manager.tsx | 37 +-- example/src/app/scroll-to-button.tsx | 37 +-- .../src/app/short-content-no-min-height.tsx | 37 +-- example/src/app/short-content.tsx | 37 +-- example/src/app/simple.tsx | 37 +-- .../components/ShowcaseCollapsibleHeader.tsx | 24 +- src/components/Header.tsx | 204 +++++++++++-- src/components/HeaderBase.tsx | 119 ++------ src/components/HeaderMotion.tsx | 15 +- src/components/__tests__/Header.test.tsx | 237 +++++++++++++++ src/components/index.ts | 1 - src/context.ts | 35 +-- src/hooks/index.ts | 1 + src/hooks/useHeaderMotionBridge.ts | 13 + src/hooks/useMotionProgress.test.ts | 68 +++++ src/hooks/useMotionProgress.ts | 48 +-- src/index.ts | 40 ++- src/types.ts | 25 +- 41 files changed, 1221 insertions(+), 961 deletions(-) create mode 100644 example/src/app/new-api.tsx create mode 100644 src/components/__tests__/Header.test.tsx create mode 100644 src/hooks/useHeaderMotionBridge.ts create mode 100644 src/hooks/useMotionProgress.test.ts diff --git a/README.md b/README.md index afb8d3a..ad50aae 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ This library is **100% a wrapper around Reanimated**. All the credit for the und ## What changed since `v0.3.0` - **Performance-focused internals:** motion threshold + header height now flow through `SharedValue`s to reduce JS-side churn. -- **Pannable header support:** new `enableHeaderPan` on `HeaderMotion` and required `animatedHeaderBaseProps` on `AnimatedHeaderBase`. +- **Pannable header support:** `enableHeaderPan` now works directly through `HeaderMotion.Header`. - **Ecosystem update:** example app moved to Expo 55 + Reanimated 4.2; `react-native-gesture-handler` is now a peer dependency. ## What this is (and isn’t) @@ -82,7 +82,7 @@ As a `HeaderMotion` prop, you can provide: If you provide a function, it uses the value measured by `measureDynamic`. -When you read `progressThreshold` from `useMotionProgress()` / `HeaderMotion.Header`, it is a `SharedValue`. +When you read `progressThreshold` from `useMotionProgress()`, it is a `SharedValue`. Read it inside worklets via `progressThreshold.get()` (or `progressThreshold.value`). ### 3) Measurement functions @@ -92,15 +92,20 @@ The library gives you two measurement callbacks that you pass to your header lay - `measureTotalHeight` – attach to the _outer_ header container to measure the total header height. Scrollables use this to offset content so it starts below the header. - `measureDynamic` – attach to the part of the header that determines the threshold (often the animated/dynamic portion). -## Why `HeaderMotion.Header` exists +## Why `HeaderMotion.Bridge` / `HeaderMotion.NavigationBridge` exist When you pass a `header` component to React Navigation / Expo Router, that header is rendered by the navigator in a different part of the React tree. -Because of that, the navigation header **cannot read the `HeaderMotion` context**, so calling `useMotionProgress()` inside that header would throw. +Because of that, the navigation header **cannot read the `HeaderMotion` context** directly. -`HeaderMotion.Header` solves this by acting as a **bridge**: it runs inside the provider, reads context, and passes the values to your navigation header via a render function. +Use: + +- `HeaderMotion.Bridge` to read the full context inside the provider tree +- `HeaderMotion.NavigationBridge` to re-provide that same context inside the navigation-rendered header subtree + +Once bridged, your header component can call `useMotionProgress()` normally. -## Why `HeaderBase` / `AnimatedHeaderBase` uses absolute positioning +## Why `HeaderMotion.Header` uses absolute positioning Navigation headers are special: @@ -109,7 +114,7 @@ Navigation headers are special: - content below becoming unclickable (an invisible parent header still sits on top), or - content hidden under the header container. -`HeaderBase` and `AnimatedHeaderBase` are **absolutely positioned** to avoid those layout traps, which is especially important when you use transforms/translations. +`HeaderMotion.Header` is **absolutely positioned by default** (`overlay={true}`) to avoid those layout traps, which is especially important when you use transforms/translations. ## When to use components vs hooks @@ -127,8 +132,9 @@ You can use either style; pick based on your integration needs: Also: -- Use `HeaderMotion.Header` when your header is rendered by navigation. -- Use `useMotionProgress` when your header is rendered inside the same tree as `HeaderMotion`. +- Use `HeaderMotion.Bridge` + `HeaderMotion.NavigationBridge` when your header is rendered by navigation. +- Use `HeaderMotion.Header` + `HeaderMotion.Header.Dynamic` to wire header measurement automatically. +- Use `useMotionProgress` when you only need `progress` and `progressThreshold`. ## Examples @@ -138,15 +144,12 @@ Examples live in the example app: `example/`. They demonstrate a few cases, from Those examples use Expo Router as the navigation library, but it should be fairly simple to do the same with plain React Navigation. -### Expo Router +### Expo Router / React Navigation -This is the core pattern used in the example app (`example/src/app/simple.tsx`). +This is the core pattern used in the example app (`example/src/app/new-api.tsx`). ```tsx -import HeaderMotion, { - AnimatedHeaderBase, - type WithCollapsibleHeaderProps, -} from 'react-native-header-motion'; +import HeaderMotion, { useMotionProgress } from 'react-native-header-motion'; import { Stack } from 'expo-router'; import Animated, { Extrapolation, @@ -159,15 +162,19 @@ import { View } from 'react-native'; export default function Screen() { return ( - - {(headerProps) => ( + + {(value) => ( , + header: () => ( + + + + ), }} /> )} - + {/* your scrollable content */} @@ -176,99 +183,8 @@ export default function Screen() { ); } -function MyHeader({ - progress, - measureTotalHeight, - measureDynamic, - progressThreshold, - animatedHeaderBaseProps, -}: WithCollapsibleHeaderProps) { - const insets = useSafeAreaInsets(); - - const containerStyle = useAnimatedStyle(() => { - const threshold = progressThreshold.get(); - const translateY = interpolate( - progress.get(), - [0, 1], - [0, -threshold], - Extrapolation.CLAMP - ); - return { transform: [{ translateY }] }; - }); - - return ( - - - {/* “dynamic” part of the header */} - - - {/* "regular" part of the header */} - - ); -} -``` - -### React Navigation - -In React Navigation you typically configure headers via `navigation.setOptions()`. - -Important: the header itself can’t call `useMotionProgress()`, so we still use `HeaderMotion.Header` as a bridge. - -```tsx -import React from 'react'; -import HeaderMotion, { - AnimatedHeaderBase, - type WithCollapsibleHeaderProps, -} from 'react-native-header-motion'; -import { useNavigation } from '@react-navigation/native'; -import Animated, { - Extrapolation, - interpolate, - useAnimatedStyle, -} from 'react-native-reanimated'; -import { useSafeAreaInsets } from 'react-native-safe-area-context'; -import { View } from 'react-native'; - -export function MyScreen() { - return ( - - - {(headerProps) => ( - - )} - - {/* content */} - - ); -} - -function NavigationHeaderInstaller({ - headerProps, -}: { - headerProps: WithCollapsibleHeaderProps; -}) { - const navigation = useNavigation(); - - React.useLayoutEffect(() => { - navigation.setOptions({ - header: () => , - }); - }, [navigation, headerProps]); - - return null; -} - -function MyHeader({ - progress, - measureTotalHeight, - measureDynamic, - progressThreshold, - animatedHeaderBaseProps, -}: WithCollapsibleHeaderProps) { +function MyHeader() { + const { progress, progressThreshold } = useMotionProgress(); const insets = useSafeAreaInsets(); const containerStyle = useAnimatedStyle(() => { @@ -283,17 +199,13 @@ function MyHeader({ }); return ( - - + + {/* “dynamic” part of the header */} - + {/* "regular" part of the header */} - + ); } ``` @@ -308,23 +220,12 @@ If you have multiple scrollables (e.g. pages in `react-native-pager-view`), you The example app shows this pattern in `example/src/app/collapsible-pager.tsx` using `HeaderMotion.ScrollManager`. -### Keeping the native header (back button/title) + custom animated header below - -Sometimes you want to keep the native navigation header for back buttons + title, but still animate a custom header section below it. - -In that case: - -- set `headerTransparent: true` -- do **not** provide a custom `header` component -- render your animated header content _inside the screen_ under the native header +### Keeping the native header + custom animated content below -Sketch: +If you want to keep the native header for back buttons/title, render your animated content _inside the screen_ under the native header: ```tsx -import HeaderMotion, { - AnimatedHeaderBase, - useMotionProgress, -} from 'react-native-header-motion'; +import HeaderMotion, { useMotionProgress } from 'react-native-header-motion'; import { Stack } from 'expo-router'; import Animated, { Extrapolation, @@ -348,13 +249,7 @@ export default function Screen() { } function InlineAnimatedHeader() { - const { - progress, - measureTotalHeight, - measureDynamic, - progressThreshold, - animatedHeaderBaseProps, - } = useMotionProgress(); + const { progress, progressThreshold } = useMotionProgress(); const containerStyle = useAnimatedStyle(() => { const threshold = progressThreshold.get(); @@ -368,30 +263,28 @@ function InlineAnimatedHeader() { }); return ( - - - {/* custom animated header content below the native header */} - + + + {/* custom animated content below the native header */} + {/* sticky part */} - + ); } ``` ## API -The package exports a default compound component plus hooks, types, and a couple base components. +The package exports a default compound component plus hooks, types, and named aliases. ### `HeaderMotion` (default export) `HeaderMotion` is a compound component: - `HeaderMotion` (provider) -- `HeaderMotion.Header` (bridge for navigation headers) +- `HeaderMotion.Header` (header container) +- `HeaderMotion.Bridge` (bridge render-prop for navigation headers) +- `HeaderMotion.NavigationBridge` (re-provides context into navigation headers) - `HeaderMotion.ScrollView` (pre-wired Animated.ScrollView) - `HeaderMotion.FlatList` (pre-wired Animated.FlatList) - `createHeaderMotionScrollable` (factory for reusable custom scrollables) @@ -411,19 +304,55 @@ The package exports a default compound component plus hooks, types, and a couple - `progressExtrapolation?: ExtrapolationType` - Controls how progress behaves outside the threshold range (useful for overscroll). - `enableHeaderPan?: boolean` - - Enables direct pan gestures on `AnimatedHeaderBase` (`false` by default). + - Enables direct pan gestures on `HeaderMotion.Header` (`false` by default). #### `HeaderMotion.Header` -Render-prop component that passes motion progress props to a header you render via navigation. +Header container component that: + +- measures total header height automatically +- supports direct header pan gestures +- renders an `Animated.View` by default +- applies absolute overlay positioning by default ```tsx - - {(headerProps) => /* pass headerProps into navigation header */} + + + {/* dynamic measured section */} + ``` -Use this instead of `useMotionProgress()` when your header is rendered by React Navigation / Expo Router. +Props: + +- `overlay?: boolean` + - Defaults to `true` +- `withGestureHandlerRootView?: boolean` +- `asChild?: boolean` + +When `asChild` is `true`, pass a single child that accepts `onLayout`. + +#### `HeaderMotion.Header.Dynamic` + +Dynamic measured section of the header. + +- renders an `Animated.View` by default +- wires `measureDynamic` automatically +- supports `asChild` + +#### `HeaderMotion.Bridge` + +Render-prop bridge for navigation-rendered headers. + +```tsx + + {(value) => /* pass value into HeaderMotion.NavigationBridge */} + +``` + +#### `HeaderMotion.NavigationBridge` + +Re-provides header motion context in a separate subtree, typically inside a navigation header. #### `HeaderMotion.ScrollView` @@ -560,12 +489,14 @@ Returns: - `progress` (`SharedValue`) - `progressThreshold` (`SharedValue`) -- `measureTotalHeight` (`onLayout` callback) -- `measureDynamic` (`onLayout` callback) -- `animatedHeaderBaseProps` (required by `AnimatedHeaderBase`) -- `activeScrollId` (`SharedValue | undefined`) -Only use inside the `HeaderMotion` provider tree. +Only use inside the `HeaderMotion` / `HeaderMotion.NavigationBridge` context tree. + +#### `useHeaderMotionBridge()` + +Returns the full bridge value used by `HeaderMotion.Bridge`. + +Use this only for advanced integrations that need to move HeaderMotion context into another subtree manually. #### `useScrollManager(scrollId?)` @@ -584,24 +515,11 @@ Helper for multi-scroll scenarios (tabs/pager). Returns: - `active.state` (React state) - `active.sv` (SharedValue) -### Base components - -#### `HeaderBase` - -Non-animated absolutely positioned header base. - -#### `AnimatedHeaderBase` - -Reanimated-powered, absolutely positioned header base. - -- Requires `animatedHeaderBaseProps` from `useMotionProgress()` / `HeaderMotion.Header`. -- It is required for header panning functionality. -- Optional `withGestureHandlerRootView` can wrap this header in `GestureHandlerRootView` when needed. - ### Types -- `WithCollapsibleHeaderProps` – convenience type for headers using motion progress props. -- `WithCollapsiblePagedHeaderProps` – like above, plus `activeTab` and `onTabChange`. +- `HeaderMotionBridgeValue` – full bridge value passed through `HeaderMotion.Bridge` +- `HeaderProps` – props for `HeaderMotion.Header` +- `HeaderDynamicProps` – props for `HeaderMotion.Header.Dynamic` ## Additional notes diff --git a/example/src/app/animated-on-scroll.tsx b/example/src/app/animated-on-scroll.tsx index a49bfb6..58e0937 100644 --- a/example/src/app/animated-on-scroll.tsx +++ b/example/src/app/animated-on-scroll.tsx @@ -1,8 +1,5 @@ import { DynamicBox, TitleWithSubtitle, generateContent } from '@/components'; -import HeaderMotion, { - AnimatedHeaderBase, - type WithCollapsibleHeaderProps, -} from 'react-native-header-motion'; +import HeaderMotion, { useMotionProgress } from 'react-native-header-motion'; import { Stack } from 'expo-router'; import { StyleSheet, View } from 'react-native'; import Animated, { @@ -23,15 +20,19 @@ export default function Screen() { return ( - - {(headerProps) => ( + + {(value) => ( , + header: () => ( + + + + ), }} /> )} - + {content} @@ -39,13 +40,8 @@ export default function Screen() { ); } -function CollapsibleHeader({ - progress, - measureTotalHeight, - measureDynamic, - progressThreshold, - animatedHeaderBaseProps, -}: WithCollapsibleHeaderProps) { +function CollapsibleHeader() { + const { progress, progressThreshold } = useMotionProgress(); const insets = useSafeAreaInsets(); const containerStyle = useAnimatedStyle(() => { @@ -97,9 +93,7 @@ function CollapsibleHeader({ }); return ( - @@ -110,15 +104,14 @@ function CollapsibleHeader({ - - + - + ); } diff --git a/example/src/app/collapsible-pager.tsx b/example/src/app/collapsible-pager.tsx index 5d7d158..9151d25 100644 --- a/example/src/app/collapsible-pager.tsx +++ b/example/src/app/collapsible-pager.tsx @@ -5,9 +5,8 @@ import { generateContent, } from '@/components'; import HeaderMotion, { - AnimatedHeaderBase, useActiveScrollId, - type WithCollapsiblePagedHeaderProps, + useMotionProgress, } from 'react-native-header-motion'; import { Stack } from 'expo-router'; import { useRef } from 'react'; @@ -45,21 +44,22 @@ export default function Screen() { return ( - - {(headerProps) => ( + + {(value) => ( ( - + + + ), }} /> )} - + void; +}) { + const { progress, progressThreshold } = useMotionProgress(); const insets = useSafeAreaInsets(); // 1. Container Animation (Moves UP) @@ -144,9 +143,7 @@ function CollapsibleHeader({ }); return ( - @@ -154,13 +151,12 @@ function CollapsibleHeader({ - - + @@ -175,7 +171,7 @@ function CollapsibleHeader({ onPress={() => onTabChange('B')} /> - + ); } diff --git a/example/src/app/colors.tsx b/example/src/app/colors.tsx index 8d29703..f7922eb 100644 --- a/example/src/app/colors.tsx +++ b/example/src/app/colors.tsx @@ -5,9 +5,8 @@ import { generateContent, } from '@/components'; import HeaderMotion, { - AnimatedHeaderBase, useActiveScrollId, - type WithCollapsiblePagedHeaderProps, + useMotionProgress, } from 'react-native-header-motion'; import { Stack } from 'expo-router'; import { useRef } from 'react'; @@ -15,10 +14,7 @@ import { StyleSheet, View } from 'react-native'; import PagerView, { type PagerViewOnPageSelectedEvent, } from 'react-native-pager-view'; -import Animated, { - interpolateColor, - useAnimatedStyle, -} from 'react-native-reanimated'; +import { interpolateColor, useAnimatedStyle } from 'react-native-reanimated'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; const indexToKey = new Map([ @@ -47,21 +43,22 @@ export default function Screen() { activeScrollId={activeScrollId.sv} progressThreshold={(measured) => measured * 10} > - - {(headerProps) => ( + + {(value) => ( ( - + + + ), }} /> )} - + void; +}) { + const { progress } = useMotionProgress(); const insets = useSafeAreaInsets(); const animatedStyle = useAnimatedStyle(() => { @@ -104,9 +101,7 @@ function CollapsibleHeader({ }); return ( - - + - + onTabChange('B')} /> - + ); } diff --git a/example/src/app/external-ref.tsx b/example/src/app/external-ref.tsx index 412665f..f36c5d5 100644 --- a/example/src/app/external-ref.tsx +++ b/example/src/app/external-ref.tsx @@ -1,8 +1,5 @@ import { DynamicBox, TitleWithSubtitle, generateContent } from '@/components'; -import HeaderMotion, { - AnimatedHeaderBase, - type WithCollapsibleHeaderProps, -} from 'react-native-header-motion'; +import HeaderMotion, { useMotionProgress } from 'react-native-header-motion'; import { Stack } from 'expo-router'; import { StyleSheet, View } from 'react-native'; import Animated, { @@ -18,15 +15,19 @@ export default function Screen() { return ( - - {(headerProps) => ( + + {(value) => ( , + header: () => ( + + + + ), }} /> )} - + {content} @@ -34,13 +35,8 @@ export default function Screen() { ); } -function CollapsibleHeader({ - progress, - measureTotalHeight, - measureDynamic, - progressThreshold, - animatedHeaderBaseProps, -}: WithCollapsibleHeaderProps) { +function CollapsibleHeader() { + const { progress, progressThreshold } = useMotionProgress(); const insets = useSafeAreaInsets(); const containerStyle = useAnimatedStyle(() => { @@ -92,9 +88,7 @@ function CollapsibleHeader({ }); return ( - @@ -105,15 +99,14 @@ function CollapsibleHeader({ - - + - + ); } diff --git a/example/src/app/flashlist.tsx b/example/src/app/flashlist.tsx index cc82b91..8971027 100644 --- a/example/src/app/flashlist.tsx +++ b/example/src/app/flashlist.tsx @@ -17,22 +17,23 @@ const HeaderMotionFlashList = createHeaderMotionScrollable(FlashList, { export default function Screen() { return ( - - {(headerProps) => ( + + {(value) => ( ( - + + + ), }} /> )} - + `${item.index}`} diff --git a/example/src/app/flatlist-handlers.tsx b/example/src/app/flatlist-handlers.tsx index 3347ab9..c87ac55 100644 --- a/example/src/app/flatlist-handlers.tsx +++ b/example/src/app/flatlist-handlers.tsx @@ -1,8 +1,5 @@ import { ContentCard, DynamicBox, TitleWithSubtitle } from '@/components'; -import HeaderMotion, { - AnimatedHeaderBase, - type WithCollapsibleHeaderProps, -} from 'react-native-header-motion'; +import HeaderMotion, { useMotionProgress } from 'react-native-header-motion'; import { Stack } from 'expo-router'; import type { ComponentProps } from 'react'; import { useCallback, useRef } from 'react'; @@ -32,15 +29,19 @@ export default function Screen() { return ( - - {(headerProps) => ( + + {(value) => ( , + header: () => ( + + + + ), }} /> )} - + item.key} @@ -124,13 +125,8 @@ function useFlatListHandlerLoggers() { }; } -function CollapsibleHeader({ - progress, - measureTotalHeight, - measureDynamic, - progressThreshold, - animatedHeaderBaseProps, -}: WithCollapsibleHeaderProps) { +function CollapsibleHeader() { + const { progress, progressThreshold } = useMotionProgress(); const insets = useSafeAreaInsets(); const containerStyle = useAnimatedStyle(() => { @@ -182,9 +178,7 @@ function CollapsibleHeader({ }); return ( - @@ -192,15 +186,14 @@ function CollapsibleHeader({ - - + - + ); } diff --git a/example/src/app/flatlist.tsx b/example/src/app/flatlist.tsx index 273c75b..92683b6 100644 --- a/example/src/app/flatlist.tsx +++ b/example/src/app/flatlist.tsx @@ -1,8 +1,5 @@ import { ContentCard, DynamicBox, TitleWithSubtitle } from '@/components'; -import HeaderMotion, { - AnimatedHeaderBase, - type WithCollapsibleHeaderProps, -} from 'react-native-header-motion'; +import HeaderMotion, { useMotionProgress } from 'react-native-header-motion'; import { Stack } from 'expo-router'; import { StyleSheet, View } from 'react-native'; import Animated, { @@ -20,15 +17,19 @@ type ListRow = { export default function Screen() { return ( - - {(headerProps) => ( + + {(value) => ( , + header: () => ( + + + + ), }} /> )} - + `${item.index}`} @@ -40,13 +41,8 @@ export default function Screen() { ); } -function CollapsibleHeader({ - progress, - measureTotalHeight, - measureDynamic, - progressThreshold, - animatedHeaderBaseProps, -}: WithCollapsibleHeaderProps) { +function CollapsibleHeader() { + const { progress, progressThreshold } = useMotionProgress(); const insets = useSafeAreaInsets(); // 1. Container Animation (Moves UP) @@ -101,9 +97,7 @@ function CollapsibleHeader({ }); return ( - @@ -111,15 +105,14 @@ function CollapsibleHeader({ - - + - + ); } diff --git a/example/src/app/header-pan.tsx b/example/src/app/header-pan.tsx index 9d9ed33..4b7aae8 100644 --- a/example/src/app/header-pan.tsx +++ b/example/src/app/header-pan.tsx @@ -1,8 +1,5 @@ import { DynamicBox, TitleWithSubtitle, generateContent } from '@/components'; -import HeaderMotion, { - AnimatedHeaderBase, - type WithCollapsibleHeaderProps, -} from 'react-native-header-motion'; +import HeaderMotion, { useMotionProgress } from 'react-native-header-motion'; import { Stack } from 'expo-router'; import { StyleSheet, View } from 'react-native'; import Animated, { @@ -15,27 +12,26 @@ import { useSafeAreaInsets } from 'react-native-safe-area-context'; export default function Screen() { return ( - - {(headerProps) => ( + + {(value) => ( , + header: () => ( + + + + ), }} /> )} - + {content} ); } -function CollapsibleHeader({ - progress, - measureTotalHeight, - measureDynamic, - progressThreshold, - animatedHeaderBaseProps, -}: WithCollapsibleHeaderProps) { +function CollapsibleHeader() { + const { progress, progressThreshold } = useMotionProgress(); const insets = useSafeAreaInsets(); // 1. Container Animation (Moves UP) @@ -87,9 +83,7 @@ function CollapsibleHeader({ }); return ( - @@ -97,15 +91,14 @@ function CollapsibleHeader({ - - + - + ); } diff --git a/example/src/app/legendlist.tsx b/example/src/app/legendlist.tsx index 4b866cb..3717f0e 100644 --- a/example/src/app/legendlist.tsx +++ b/example/src/app/legendlist.tsx @@ -22,22 +22,23 @@ const HeaderMotionLegendList = createHeaderMotionScrollable( export default function Screen() { return ( - - {(headerProps) => ( + + {(value) => ( ( - + + + ), }} /> )} - + + + {(ctx) => ( + ( + +
+ + ), + }} + /> + )} + + {content} + + ); +} + +function Header() { + const { progress, progressThreshold } = useMotionProgress(); + + const insets = useSafeAreaInsets(); + + // 1. Container Animation (Moves UP) + const containerStyle = useAnimatedStyle(() => { + const threshold = progressThreshold.get(); + const translateY = interpolate( + progress.get(), + [0, 1], + [0, -threshold], + Extrapolation.CLAMP + ); + return { transform: [{ translateY }] }; + }); + + // 2. Title Animation (Counter-Moves DOWN to stay sticky) + const titleStyle = useAnimatedStyle(() => { + const threshold = progressThreshold.get(); + const translateY = interpolate( + progress.get(), + [0, 1], + [0, threshold], + Extrapolation.CLAMP + ); + return { transform: [{ translateY }] }; + }); + + // 3. Content Animation (Parallax + Opacity + Scale) + const boxSectionStyle = useAnimatedStyle(() => { + const threshold = progressThreshold.get(); + const parallaxTranslateY = interpolate( + progress.get(), + [0, 1], + [0, threshold * 0.5], + Extrapolation.CLAMP + ); + const opacity = interpolate( + progress.get(), + [0, 1 * 0.6], + [1, 0], + Extrapolation.CLAMP + ); + const scale = interpolate( + progress.get(), + [0, 1], + [1, 0.8], + Extrapolation.CLAMP + ); + return { + opacity, + transform: [{ translateY: parallaxTranslateY }, { scale }], + }; + }); + + return ( + + + + + + + + + + + + + ); +} + +const styles = StyleSheet.create({ + headerWrapper: { + backgroundColor: '#304077', + borderBottomWidth: 1, + borderBottomColor: 'rgba(0,0,0,0.1)', + }, + dynamicContent: { + overflow: 'hidden', + }, + boxContainer: { + flexDirection: 'row', + gap: 6, + padding: 12, + alignItems: 'stretch', + overflow: 'hidden', + }, +}); + +const content = generateContent({ + count: 500, + backgroundColor: '#E3CBFC', + textColor: '#304077', +}); diff --git a/example/src/app/offset-margin.tsx b/example/src/app/offset-margin.tsx index b7b887a..2943b44 100644 --- a/example/src/app/offset-margin.tsx +++ b/example/src/app/offset-margin.tsx @@ -1,8 +1,5 @@ import { DynamicBox, TitleWithSubtitle, generateContent } from '@/components'; -import HeaderMotion, { - AnimatedHeaderBase, - type WithCollapsibleHeaderProps, -} from 'react-native-header-motion'; +import HeaderMotion, { useMotionProgress } from 'react-native-header-motion'; import { Stack } from 'expo-router'; import { StyleSheet, View } from 'react-native'; import Animated, { @@ -15,15 +12,19 @@ import { useSafeAreaInsets } from 'react-native-safe-area-context'; export default function Screen() { return ( - - {(headerProps) => ( + + {(value) => ( , + header: () => ( + + + + ), }} /> )} - + {content} @@ -31,13 +32,8 @@ export default function Screen() { ); } -function CollapsibleHeader({ - progress, - measureTotalHeight, - measureDynamic, - progressThreshold, - animatedHeaderBaseProps, -}: WithCollapsibleHeaderProps) { +function CollapsibleHeader() { + const { progress, progressThreshold } = useMotionProgress(); const insets = useSafeAreaInsets(); // 1. Container Animation (Moves UP) @@ -92,9 +88,7 @@ function CollapsibleHeader({ }); return ( - @@ -105,15 +99,14 @@ function CollapsibleHeader({ - - + - + ); } diff --git a/example/src/app/offset-none.tsx b/example/src/app/offset-none.tsx index 4f33520..de0ecad 100644 --- a/example/src/app/offset-none.tsx +++ b/example/src/app/offset-none.tsx @@ -1,8 +1,5 @@ import { DynamicBox, TitleWithSubtitle, generateContent } from '@/components'; -import HeaderMotion, { - AnimatedHeaderBase, - type WithCollapsibleHeaderProps, -} from 'react-native-header-motion'; +import HeaderMotion, { useMotionProgress } from 'react-native-header-motion'; import { Stack } from 'expo-router'; import { StyleSheet, View } from 'react-native'; import Animated, { @@ -15,15 +12,19 @@ import { useSafeAreaInsets } from 'react-native-safe-area-context'; export default function Screen() { return ( - - {(headerProps) => ( + + {(value) => ( , + header: () => ( + + + + ), }} /> )} - + {content} @@ -31,13 +32,8 @@ export default function Screen() { ); } -function CollapsibleHeader({ - progress, - measureTotalHeight, - measureDynamic, - progressThreshold, - animatedHeaderBaseProps, -}: WithCollapsibleHeaderProps) { +function CollapsibleHeader() { + const { progress, progressThreshold } = useMotionProgress(); const insets = useSafeAreaInsets(); // 1. Container Animation (Moves UP) @@ -92,9 +88,7 @@ function CollapsibleHeader({ }); return ( - @@ -105,15 +99,14 @@ function CollapsibleHeader({ - - + - + ); } diff --git a/example/src/app/offset-padding.tsx b/example/src/app/offset-padding.tsx index 9194fb4..a60b062 100644 --- a/example/src/app/offset-padding.tsx +++ b/example/src/app/offset-padding.tsx @@ -1,8 +1,5 @@ import { DynamicBox, TitleWithSubtitle, generateContent } from '@/components'; -import HeaderMotion, { - AnimatedHeaderBase, - type WithCollapsibleHeaderProps, -} from 'react-native-header-motion'; +import HeaderMotion, { useMotionProgress } from 'react-native-header-motion'; import { Stack } from 'expo-router'; import { StyleSheet, View } from 'react-native'; import Animated, { @@ -15,15 +12,19 @@ import { useSafeAreaInsets } from 'react-native-safe-area-context'; export default function Screen() { return ( - - {(headerProps) => ( + + {(value) => ( , + header: () => ( + + + + ), }} /> )} - + {content} @@ -31,13 +32,8 @@ export default function Screen() { ); } -function CollapsibleHeader({ - progress, - measureTotalHeight, - measureDynamic, - progressThreshold, - animatedHeaderBaseProps, -}: WithCollapsibleHeaderProps) { +function CollapsibleHeader() { + const { progress, progressThreshold } = useMotionProgress(); const insets = useSafeAreaInsets(); // 1. Container Animation (Moves UP) @@ -92,9 +88,7 @@ function CollapsibleHeader({ }); return ( - @@ -105,15 +99,14 @@ function CollapsibleHeader({ - - + - + ); } diff --git a/example/src/app/offset-top.tsx b/example/src/app/offset-top.tsx index 3e5b320..eca1e16 100644 --- a/example/src/app/offset-top.tsx +++ b/example/src/app/offset-top.tsx @@ -1,8 +1,5 @@ import { DynamicBox, TitleWithSubtitle, generateContent } from '@/components'; -import HeaderMotion, { - AnimatedHeaderBase, - type WithCollapsibleHeaderProps, -} from 'react-native-header-motion'; +import HeaderMotion, { useMotionProgress } from 'react-native-header-motion'; import { Stack } from 'expo-router'; import { StyleSheet, View } from 'react-native'; import Animated, { @@ -15,15 +12,19 @@ import { useSafeAreaInsets } from 'react-native-safe-area-context'; export default function Screen() { return ( - - {(headerProps) => ( + + {(value) => ( , + header: () => ( + + + + ), }} /> )} - + {content} @@ -31,13 +32,8 @@ export default function Screen() { ); } -function CollapsibleHeader({ - progress, - measureTotalHeight, - measureDynamic, - progressThreshold, - animatedHeaderBaseProps, -}: WithCollapsibleHeaderProps) { +function CollapsibleHeader() { + const { progress, progressThreshold } = useMotionProgress(); const insets = useSafeAreaInsets(); // 1. Container Animation (Moves UP) @@ -92,9 +88,7 @@ function CollapsibleHeader({ }); return ( - @@ -105,15 +99,14 @@ function CollapsibleHeader({ - - + - + ); } diff --git a/example/src/app/offset-transform.tsx b/example/src/app/offset-transform.tsx index d7824cc..f699cd5 100644 --- a/example/src/app/offset-transform.tsx +++ b/example/src/app/offset-transform.tsx @@ -1,8 +1,5 @@ import { DynamicBox, TitleWithSubtitle, generateContent } from '@/components'; -import HeaderMotion, { - AnimatedHeaderBase, - type WithCollapsibleHeaderProps, -} from 'react-native-header-motion'; +import HeaderMotion, { useMotionProgress } from 'react-native-header-motion'; import { Stack } from 'expo-router'; import { StyleSheet, View } from 'react-native'; import Animated, { @@ -15,15 +12,19 @@ import { useSafeAreaInsets } from 'react-native-safe-area-context'; export default function Screen() { return ( - - {(headerProps) => ( + + {(value) => ( , + header: () => ( + + + + ), }} /> )} - + {content} @@ -31,13 +32,8 @@ export default function Screen() { ); } -function CollapsibleHeader({ - progress, - measureTotalHeight, - measureDynamic, - progressThreshold, - animatedHeaderBaseProps, -}: WithCollapsibleHeaderProps) { +function CollapsibleHeader() { + const { progress, progressThreshold } = useMotionProgress(); const insets = useSafeAreaInsets(); // 1. Container Animation (Moves UP) @@ -92,9 +88,7 @@ function CollapsibleHeader({ }); return ( - @@ -105,15 +99,14 @@ function CollapsibleHeader({ - - + - + ); } diff --git a/example/src/app/overscroll.tsx b/example/src/app/overscroll.tsx index 215140a..a69079c 100644 --- a/example/src/app/overscroll.tsx +++ b/example/src/app/overscroll.tsx @@ -1,8 +1,5 @@ import { DynamicBox, generateContent, TitleWithSubtitle } from '@/components'; -import HeaderMotion, { - AnimatedHeaderBase, - type WithCollapsibleHeaderProps, -} from 'react-native-header-motion'; +import HeaderMotion, { useMotionProgress } from 'react-native-header-motion'; import { Stack } from 'expo-router'; import { Image, StyleSheet, View } from 'react-native'; import Animated, { @@ -23,27 +20,26 @@ export default function Screen() { extrapolateRight: Extrapolation.CLAMP, }} > - - {(headerProps) => ( + + {(value) => ( , + header: () => ( + + + + ), }} /> )} - + {content} ); } -function CollapsibleHeader({ - progress, - measureTotalHeight, - measureDynamic, - progressThreshold, - animatedHeaderBaseProps, -}: WithCollapsibleHeaderProps) { +function CollapsibleHeader() { + const { progress, progressThreshold } = useMotionProgress(); const insets = useSafeAreaInsets(); // 1. Container Animation (Moves UP) @@ -104,9 +100,7 @@ function CollapsibleHeader({ }); return ( - - - + - + ); } diff --git a/example/src/app/pager-header-pan.tsx b/example/src/app/pager-header-pan.tsx index bfc04c6..f005722 100644 --- a/example/src/app/pager-header-pan.tsx +++ b/example/src/app/pager-header-pan.tsx @@ -5,9 +5,8 @@ import { generateContent, } from '@/components'; import HeaderMotion, { - AnimatedHeaderBase, useActiveScrollId, - type WithCollapsiblePagedHeaderProps, + useMotionProgress, } from 'react-native-header-motion'; import { Stack } from 'expo-router'; import { useRef } from 'react'; @@ -45,21 +44,22 @@ export default function Screen() { return ( - - {(headerProps) => ( + + {(value) => ( ( - + + + ), }} /> )} - + void; +}) { + const { progress, progressThreshold } = useMotionProgress(); const insets = useSafeAreaInsets(); const containerStyle = useAnimatedStyle(() => { @@ -141,9 +140,7 @@ function CollapsibleHeader({ }); return ( - @@ -154,13 +151,12 @@ function CollapsibleHeader({ - - + @@ -175,7 +171,7 @@ function CollapsibleHeader({ onPress={() => onTabChange('B')} /> - + ); } diff --git a/example/src/app/refresh-flatlist-control.tsx b/example/src/app/refresh-flatlist-control.tsx index 4c11d9f..2899d5d 100644 --- a/example/src/app/refresh-flatlist-control.tsx +++ b/example/src/app/refresh-flatlist-control.tsx @@ -1,9 +1,6 @@ import * as React from 'react'; import { ContentCard, DynamicBox, TitleWithSubtitle } from '@/components'; -import HeaderMotion, { - AnimatedHeaderBase, - type WithCollapsibleHeaderProps, -} from 'react-native-header-motion'; +import HeaderMotion, { useMotionProgress } from 'react-native-header-motion'; import { Stack } from 'expo-router'; import { RefreshControl, StyleSheet, View } from 'react-native'; import Animated, { @@ -30,15 +27,19 @@ export default function Screen() { return ( - - {(headerProps) => ( + + {(value) => ( , + header: () => ( + + + + ), }} /> )} - + `${item.index}`} @@ -53,13 +54,8 @@ export default function Screen() { ); } -function CollapsibleHeader({ - progress, - measureTotalHeight, - measureDynamic, - progressThreshold, - animatedHeaderBaseProps, -}: WithCollapsibleHeaderProps) { +function CollapsibleHeader() { + const { progress, progressThreshold } = useMotionProgress(); const insets = useSafeAreaInsets(); const containerStyle = useAnimatedStyle(() => { @@ -111,9 +107,7 @@ function CollapsibleHeader({ }); return ( - @@ -124,15 +118,14 @@ function CollapsibleHeader({ - - + - + ); } diff --git a/example/src/app/refresh-flatlist-props-offset.tsx b/example/src/app/refresh-flatlist-props-offset.tsx index b74816b..5cedc71 100644 --- a/example/src/app/refresh-flatlist-props-offset.tsx +++ b/example/src/app/refresh-flatlist-props-offset.tsx @@ -1,9 +1,6 @@ import * as React from 'react'; import { ContentCard, DynamicBox, TitleWithSubtitle } from '@/components'; -import HeaderMotion, { - AnimatedHeaderBase, - type WithCollapsibleHeaderProps, -} from 'react-native-header-motion'; +import HeaderMotion, { useMotionProgress } from 'react-native-header-motion'; import { Stack } from 'expo-router'; import { StyleSheet, View } from 'react-native'; import Animated, { @@ -32,15 +29,19 @@ export default function Screen() { return ( - - {(headerProps) => ( + + {(value) => ( , + header: () => ( + + + + ), }} /> )} - + `${item.index}`} @@ -55,13 +56,8 @@ export default function Screen() { ); } -function CollapsibleHeader({ - progress, - measureTotalHeight, - measureDynamic, - progressThreshold, - animatedHeaderBaseProps, -}: WithCollapsibleHeaderProps) { +function CollapsibleHeader() { + const { progress, progressThreshold } = useMotionProgress(); const insets = useSafeAreaInsets(); const containerStyle = useAnimatedStyle(() => { @@ -113,9 +109,7 @@ function CollapsibleHeader({ }); return ( - - - + - + ); } diff --git a/example/src/app/refresh-flatlist-props.tsx b/example/src/app/refresh-flatlist-props.tsx index 28371e6..9f84fe3 100644 --- a/example/src/app/refresh-flatlist-props.tsx +++ b/example/src/app/refresh-flatlist-props.tsx @@ -1,9 +1,6 @@ import * as React from 'react'; import { ContentCard, DynamicBox, TitleWithSubtitle } from '@/components'; -import HeaderMotion, { - AnimatedHeaderBase, - type WithCollapsibleHeaderProps, -} from 'react-native-header-motion'; +import HeaderMotion, { useMotionProgress } from 'react-native-header-motion'; import { Stack } from 'expo-router'; import { StyleSheet, View } from 'react-native'; import Animated, { @@ -30,15 +27,19 @@ export default function Screen() { return ( - - {(headerProps) => ( + + {(value) => ( , + header: () => ( + + + + ), }} /> )} - + `${item.index}`} @@ -52,13 +53,8 @@ export default function Screen() { ); } -function CollapsibleHeader({ - progress, - measureTotalHeight, - measureDynamic, - progressThreshold, - animatedHeaderBaseProps, -}: WithCollapsibleHeaderProps) { +function CollapsibleHeader() { + const { progress, progressThreshold } = useMotionProgress(); const insets = useSafeAreaInsets(); const containerStyle = useAnimatedStyle(() => { @@ -110,9 +106,7 @@ function CollapsibleHeader({ }); return ( - @@ -123,15 +117,14 @@ function CollapsibleHeader({ - - + - + ); } diff --git a/example/src/app/refresh-scrollview-control.tsx b/example/src/app/refresh-scrollview-control.tsx index 5b39543..c51e02f 100644 --- a/example/src/app/refresh-scrollview-control.tsx +++ b/example/src/app/refresh-scrollview-control.tsx @@ -1,9 +1,6 @@ import * as React from 'react'; import { DynamicBox, TitleWithSubtitle, generateContent } from '@/components'; -import HeaderMotion, { - AnimatedHeaderBase, - type WithCollapsibleHeaderProps, -} from 'react-native-header-motion'; +import HeaderMotion, { useMotionProgress } from 'react-native-header-motion'; import { Stack } from 'expo-router'; import { RefreshControl, StyleSheet, View } from 'react-native'; import Animated, { @@ -25,15 +22,19 @@ export default function Screen() { return ( - - {(headerProps) => ( + + {(value) => ( , + header: () => ( + + + + ), }} /> )} - + @@ -45,13 +46,8 @@ export default function Screen() { ); } -function CollapsibleHeader({ - progress, - measureTotalHeight, - measureDynamic, - progressThreshold, - animatedHeaderBaseProps, -}: WithCollapsibleHeaderProps) { +function CollapsibleHeader() { + const { progress, progressThreshold } = useMotionProgress(); const insets = useSafeAreaInsets(); const containerStyle = useAnimatedStyle(() => { @@ -103,9 +99,7 @@ function CollapsibleHeader({ }); return ( - @@ -116,15 +110,14 @@ function CollapsibleHeader({ - - + - + ); } diff --git a/example/src/app/scroll-handlers.tsx b/example/src/app/scroll-handlers.tsx index 3445213..23ef4e9 100644 --- a/example/src/app/scroll-handlers.tsx +++ b/example/src/app/scroll-handlers.tsx @@ -1,8 +1,5 @@ import { DynamicBox, TitleWithSubtitle, generateContent } from '@/components'; -import HeaderMotion, { - AnimatedHeaderBase, - type WithCollapsibleHeaderProps, -} from 'react-native-header-motion'; +import HeaderMotion, { useMotionProgress } from 'react-native-header-motion'; import { Stack } from 'expo-router'; import { useCallback } from 'react'; import type { NativeScrollEvent, NativeSyntheticEvent } from 'react-native'; @@ -19,15 +16,19 @@ export default function Screen() { return ( - - {(headerProps) => ( + + {(value) => ( , + header: () => ( + + + + ), }} /> )} - + {content} @@ -82,13 +83,8 @@ function useScrollHandlerLoggers() { }; } -function CollapsibleHeader({ - progress, - measureTotalHeight, - measureDynamic, - progressThreshold, - animatedHeaderBaseProps, -}: WithCollapsibleHeaderProps) { +function CollapsibleHeader() { + const { progress, progressThreshold } = useMotionProgress(); const insets = useSafeAreaInsets(); const containerStyle = useAnimatedStyle(() => { @@ -140,9 +136,7 @@ function CollapsibleHeader({ }); return ( - @@ -153,15 +147,14 @@ function CollapsibleHeader({ - - + - + ); } diff --git a/example/src/app/scroll-manager.tsx b/example/src/app/scroll-manager.tsx index 3cf8f6b..ef2d6f5 100644 --- a/example/src/app/scroll-manager.tsx +++ b/example/src/app/scroll-manager.tsx @@ -1,8 +1,5 @@ import { DynamicBox, TitleWithSubtitle, generateContent } from '@/components'; -import HeaderMotion, { - AnimatedHeaderBase, - type WithCollapsibleHeaderProps, -} from 'react-native-header-motion'; +import HeaderMotion, { useMotionProgress } from 'react-native-header-motion'; import { Stack } from 'expo-router'; import { StyleSheet, View } from 'react-native'; import Animated, { @@ -15,15 +12,19 @@ import { useSafeAreaInsets } from 'react-native-safe-area-context'; export default function Screen() { return ( - - {(headerProps) => ( + + {(value) => ( , + header: () => ( + + + + ), }} /> )} - + {( @@ -46,13 +47,8 @@ export default function Screen() { ); } -function CollapsibleHeader({ - progress, - measureTotalHeight, - measureDynamic, - progressThreshold, - animatedHeaderBaseProps, -}: WithCollapsibleHeaderProps) { +function CollapsibleHeader() { + const { progress, progressThreshold } = useMotionProgress(); const insets = useSafeAreaInsets(); const containerStyle = useAnimatedStyle(() => { @@ -104,9 +100,7 @@ function CollapsibleHeader({ }); return ( - @@ -114,15 +108,14 @@ function CollapsibleHeader({ - - + - + ); } diff --git a/example/src/app/scroll-to-button.tsx b/example/src/app/scroll-to-button.tsx index 967d5f5..2025667 100644 --- a/example/src/app/scroll-to-button.tsx +++ b/example/src/app/scroll-to-button.tsx @@ -1,8 +1,5 @@ import { DynamicBox, TitleWithSubtitle, generateContent } from '@/components'; -import HeaderMotion, { - AnimatedHeaderBase, - type WithCollapsibleHeaderProps, -} from 'react-native-header-motion'; +import HeaderMotion, { useMotionProgress } from 'react-native-header-motion'; import { Stack } from 'expo-router'; import { Pressable, StyleSheet, Text, View } from 'react-native'; import Animated, { @@ -27,15 +24,19 @@ export default function Screen() { return ( - - {(headerProps) => ( + + {(value) => ( , + header: () => ( + + + + ), }} /> )} - + {content} @@ -47,13 +48,8 @@ export default function Screen() { ); } -function CollapsibleHeader({ - progress, - measureTotalHeight, - measureDynamic, - progressThreshold, - animatedHeaderBaseProps, -}: WithCollapsibleHeaderProps) { +function CollapsibleHeader() { + const { progress, progressThreshold } = useMotionProgress(); const insets = useSafeAreaInsets(); const containerStyle = useAnimatedStyle(() => { @@ -105,9 +101,7 @@ function CollapsibleHeader({ }); return ( - @@ -118,15 +112,14 @@ function CollapsibleHeader({ - - + - + ); } diff --git a/example/src/app/short-content-no-min-height.tsx b/example/src/app/short-content-no-min-height.tsx index 30cc2ea..5ed18b6 100644 --- a/example/src/app/short-content-no-min-height.tsx +++ b/example/src/app/short-content-no-min-height.tsx @@ -1,8 +1,5 @@ import { DynamicBox, TitleWithSubtitle, generateContent } from '@/components'; -import HeaderMotion, { - AnimatedHeaderBase, - type WithCollapsibleHeaderProps, -} from 'react-native-header-motion'; +import HeaderMotion, { useMotionProgress } from 'react-native-header-motion'; import { Stack } from 'expo-router'; import { StyleSheet, View } from 'react-native'; import Animated, { @@ -15,27 +12,26 @@ import { useSafeAreaInsets } from 'react-native-safe-area-context'; export default function Screen() { return ( - - {(headerProps) => ( + + {(value) => ( , + header: () => ( + + + + ), }} /> )} - + {content} ); } -function CollapsibleHeader({ - progress, - measureTotalHeight, - measureDynamic, - progressThreshold, - animatedHeaderBaseProps, -}: WithCollapsibleHeaderProps) { +function CollapsibleHeader() { + const { progress, progressThreshold } = useMotionProgress(); const insets = useSafeAreaInsets(); const containerStyle = useAnimatedStyle(() => { @@ -87,9 +83,7 @@ function CollapsibleHeader({ }); return ( - @@ -100,15 +94,14 @@ function CollapsibleHeader({ - - + - + ); } diff --git a/example/src/app/short-content.tsx b/example/src/app/short-content.tsx index 9778c0b..e7e46f4 100644 --- a/example/src/app/short-content.tsx +++ b/example/src/app/short-content.tsx @@ -1,8 +1,5 @@ import { DynamicBox, TitleWithSubtitle, generateContent } from '@/components'; -import HeaderMotion, { - AnimatedHeaderBase, - type WithCollapsibleHeaderProps, -} from 'react-native-header-motion'; +import HeaderMotion, { useMotionProgress } from 'react-native-header-motion'; import { Stack } from 'expo-router'; import { StyleSheet, View } from 'react-native'; import Animated, { @@ -15,15 +12,19 @@ import { useSafeAreaInsets } from 'react-native-safe-area-context'; export default function Screen() { return ( - - {(headerProps) => ( + + {(value) => ( , + header: () => ( + + + + ), }} /> )} - + {content} @@ -31,13 +32,8 @@ export default function Screen() { ); } -function CollapsibleHeader({ - progress, - measureTotalHeight, - measureDynamic, - progressThreshold, - animatedHeaderBaseProps, -}: WithCollapsibleHeaderProps) { +function CollapsibleHeader() { + const { progress, progressThreshold } = useMotionProgress(); const insets = useSafeAreaInsets(); const containerStyle = useAnimatedStyle(() => { @@ -89,9 +85,7 @@ function CollapsibleHeader({ }); return ( - @@ -99,15 +93,14 @@ function CollapsibleHeader({ - - + - + ); } diff --git a/example/src/app/simple.tsx b/example/src/app/simple.tsx index aaf2dc7..4114854 100644 --- a/example/src/app/simple.tsx +++ b/example/src/app/simple.tsx @@ -1,8 +1,5 @@ import { DynamicBox, TitleWithSubtitle, generateContent } from '@/components'; -import HeaderMotion, { - AnimatedHeaderBase, - type WithCollapsibleHeaderProps, -} from 'react-native-header-motion'; +import HeaderMotion, { useMotionProgress } from 'react-native-header-motion'; import { Stack } from 'expo-router'; import { StyleSheet, View } from 'react-native'; import Animated, { @@ -15,27 +12,26 @@ import { useSafeAreaInsets } from 'react-native-safe-area-context'; export default function Screen() { return ( - - {(headerProps) => ( + + {(value) => ( , + header: () => ( + + + + ), }} /> )} - + {content} ); } -function CollapsibleHeader({ - progress, - measureTotalHeight, - measureDynamic, - progressThreshold, - animatedHeaderBaseProps, -}: WithCollapsibleHeaderProps) { +function CollapsibleHeader() { + const { progress, progressThreshold } = useMotionProgress(); const insets = useSafeAreaInsets(); // 1. Container Animation (Moves UP) @@ -90,9 +86,7 @@ function CollapsibleHeader({ }); return ( - @@ -100,15 +94,14 @@ function CollapsibleHeader({ - - + - + ); } diff --git a/example/src/components/ShowcaseCollapsibleHeader.tsx b/example/src/components/ShowcaseCollapsibleHeader.tsx index 4626ae6..66d3271 100644 --- a/example/src/components/ShowcaseCollapsibleHeader.tsx +++ b/example/src/components/ShowcaseCollapsibleHeader.tsx @@ -1,7 +1,4 @@ -import { - AnimatedHeaderBase, - type WithCollapsibleHeaderProps, -} from 'react-native-header-motion'; +import HeaderMotion, { useMotionProgress } from 'react-native-header-motion'; import { StyleSheet, View } from 'react-native'; import Animated, { Extrapolation, @@ -12,22 +9,18 @@ import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { DynamicBox } from './DynamicBox'; import { TitleWithSubtitle } from './TitleWithSubtitle'; -interface ShowcaseCollapsibleHeaderProps extends WithCollapsibleHeaderProps { +interface ShowcaseCollapsibleHeaderProps { title: string; subtitle: string; backgroundColor: string; } export function ShowcaseCollapsibleHeader({ - progress, - measureTotalHeight, - measureDynamic, - progressThreshold, - animatedHeaderBaseProps, title, subtitle, backgroundColor, }: ShowcaseCollapsibleHeaderProps) { + const { progress, progressThreshold } = useMotionProgress(); const insets = useSafeAreaInsets(); const containerStyle = useAnimatedStyle(() => { @@ -79,9 +72,7 @@ export function ShowcaseCollapsibleHeader({ }); return ( - - - + - + ); } diff --git a/src/components/Header.tsx b/src/components/Header.tsx index 11efba5..0b17e67 100644 --- a/src/components/Header.tsx +++ b/src/components/Header.tsx @@ -1,30 +1,196 @@ -import { useMotionProgress } from '../hooks/useMotionProgress'; -import type { MotionProgress } from '../types'; -import type { ReactNode } from 'react'; +import { + Fragment, + cloneElement, + isValidElement, + type ReactElement, + type ReactNode, +} from 'react'; +import type { ViewProps } from 'react-native'; +import Animated, { type AnimatedProps } from 'react-native-reanimated'; +import { HeaderMotionContext, useHeaderMotionContextOrThrow } from '../context'; +import { useHeaderMotionBridge } from '../hooks/useHeaderMotionBridge'; +import type { HeaderMotionBridgeValue, MotionProgress } from '../types'; +import { HeaderPanBoundary, headerOverlayStyle } from './HeaderBase'; -type HeaderRenderChildren = (props: MotionProgress) => ReactNode; +type HeaderRenderChildren = (value: HeaderMotionBridgeValue) => ReactNode; -export interface HeaderMotionHeaderProps { - /** - * Render function that receives motion progress props. - * Use this to animate your header based on scroll progress and to provide measurement functions to the elements of the header. - */ +type HeaderAsChildProps = { + asChild: true; + children: ReactElement; +}; + +type HeaderDefaultProps = AnimatedProps & { + asChild?: false; +}; + +export type HeaderProps = + | (HeaderDefaultProps & { + overlay?: boolean; + withGestureHandlerRootView?: boolean; + }) + | (HeaderAsChildProps & { + withGestureHandlerRootView?: boolean; + }); + +export type HeaderDynamicProps = HeaderDefaultProps | HeaderAsChildProps; + +export interface HeaderMotionBridgeProps { children: HeaderRenderChildren; } -/** - * Header component for providing motion progress properties to animated headers. - * Must be used within a HeaderMotion component. - * - * Use to pass props to the header components in React Navigation / Expo Router, which cannot access HeaderMotion's context and `useMotionProgress` otherwise.` - */ -export function HeaderMotionHeader({ children }: HeaderMotionHeaderProps) { +export interface HeaderMotionNavigationBridgeProps { + value: HeaderMotionBridgeValue; + children: ReactNode; +} + +type HeaderComponent = ((props: HeaderProps) => ReactElement | null) & { + Dynamic: typeof HeaderMotionHeaderDynamic; +}; + +export function HeaderMotionBridge({ children }: HeaderMotionBridgeProps) { if (typeof children !== 'function') { throw new Error( - 'HeaderMotion.Header only accepts render function as the only child.' + 'HeaderMotion.Bridge only accepts a render function as its child.' + ); + } + + return children(useHeaderMotionBridge()); +} + +export function HeaderMotionNavigationBridge({ + value, + children, +}: HeaderMotionNavigationBridgeProps) { + return ( + + {children} + + ); +} + +function HeaderMotionHeaderRoot(props: HeaderProps) { + const ctxValue = useHeaderMotionContextOrThrow( + 'HeaderMotion.Header must be used within or . If you are rendering inside a navigation header, bridge the context with and .' + ); + + if (props.asChild) { + const child = resolveSlottableChild('HeaderMotion.Header', props.children); + + return ( + + {cloneWithOnLayout( + child, + ctxValue.measureTotalHeight, + 'HeaderMotion.Header' + )} + ); } - const motionProgressProps = useMotionProgress(); - return children(motionProgressProps); + const { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + asChild: _asChild, + overlay = true, + onLayout, + style, + withGestureHandlerRootView, + ...rest + } = props; + const resolvedOnLayout = onLayout as ViewProps['onLayout'] | undefined; + + return ( + + + + ); } + +export function HeaderMotionHeaderDynamic(props: HeaderDynamicProps) { + const ctxValue = useHeaderMotionContextOrThrow( + 'HeaderMotion.Header.Dynamic must be used within or . If you are rendering inside a navigation header, bridge the context with and .' + ); + + if (props.asChild) { + return cloneWithOnLayout( + resolveSlottableChild('HeaderMotion.Header.Dynamic', props.children), + ctxValue.measureDynamic, + 'HeaderMotion.Header.Dynamic' + ); + } + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { asChild: _asChild, onLayout, ...rest } = props; + const resolvedOnLayout = onLayout as ViewProps['onLayout'] | undefined; + return ( + + ); +} + +export const HeaderMotionHeader = Object.assign(HeaderMotionHeaderRoot, { + Dynamic: HeaderMotionHeaderDynamic, +}) as HeaderComponent; + +function composeOnLayoutHandlers( + userHandler: ViewProps['onLayout'], + internalHandler: ViewProps['onLayout'] +) { + return (e: Parameters>[0]) => { + internalHandler?.(e); + userHandler?.(e); + }; +} + +function resolveSlottableChild(componentName: string, child: ReactElement) { + if (!isValidElement(child) || child.type === Fragment) { + throw new Error( + `${componentName} with \`asChild\` expects a single valid React element child that accepts \`onLayout\`.` + ); + } + + return child as ReactElement<{ + onLayout?: ViewProps['onLayout']; + }>; +} + +function cloneWithOnLayout( + child: ReactElement<{ + onLayout?: ViewProps['onLayout']; + }>, + onLayout: ViewProps['onLayout'], + componentName: string +) { + if (!isValidElement(child)) { + throw new Error( + `${componentName} with \`asChild\` expects a valid React element child.` + ); + } + + return cloneElement(child, { + onLayout: composeOnLayoutHandlers(child.props.onLayout, onLayout), + }); +} + +export type { HeaderMotionBridgeValue, MotionProgress }; diff --git a/src/components/HeaderBase.tsx b/src/components/HeaderBase.tsx index f518ea7..983225b 100644 --- a/src/components/HeaderBase.tsx +++ b/src/components/HeaderBase.tsx @@ -1,88 +1,44 @@ -import { useMemo } from 'react'; -import { Platform, StyleSheet, View, type ViewProps } from 'react-native'; +import { useMemo, type ReactElement } from 'react'; +import { Platform, StyleSheet } from 'react-native'; import { Gesture, GestureDetector, GestureHandlerRootView, } from 'react-native-gesture-handler'; -import Animated, { - useAnimatedReaction, - withDecay, - type AnimatedProps, -} from 'react-native-reanimated'; +import { useAnimatedReaction, withDecay } from 'react-native-reanimated'; +import type { HeaderMotionBridgeValue } from '../types'; const PLATFORM_PANNING_ENABLED = Platform.select({ default: true, android: false, }); -import type { MotionProgress } from '../types'; +type HeaderPanBoundaryProps = Pick< + HeaderMotionBridgeValue, + 'enableHeaderPan' | 'scrollToRef' | 'headerPanMomentumOffset' +> & { + children: ReactElement; + withGestureHandlerRootView?: boolean; +}; -export type HeaderBaseProps = ViewProps; -export type AnimatedHeaderBaseProps = AnimatedProps & - Pick & { - /** - * Wraps the header with GestureHandlerRootView. - * Keep this disabled when your app already has a root-level GestureHandlerRootView. - */ - withGestureHandlerRootView?: boolean; - }; - -/** - * Base header component with absolute positioning. - * Provides a foundation for building headers that need to be positioned absolutely. - * - * @example - * ```tsx - * - * ... - * - * ``` - */ -export function HeaderBase({ style, ...rest }: HeaderBaseProps) { - return ; -} - -/** - * Animated version of HeaderBase using Reanimated's Animated.View. - * Use this when you need to animate the header based on scroll progress. - * - * @example - * ```tsx - * - * ... - * - * ``` - */ +export const headerOverlayStyle = StyleSheet.create({ + overlay: { + position: 'absolute', + top: 0, + left: 0, + right: 0, + }, +}).overlay; -// TODO: Thinking about DX, perhaps creating another context in AnimatedHeaderBase or somewhere else could make sense -// Note: Depending on feedback, there might be a need to intercept ongoing scroll when starting to pan (perhaps even on the tap itself but to be checked what feels better when using) -// Note: Depending on feedback, there might be a need to block momentum by forcing scrollTo -export function AnimatedHeaderBase({ - style, - animatedHeaderBaseProps, +export function HeaderPanBoundary({ + children, + enableHeaderPan, + scrollToRef, + headerPanMomentumOffset, withGestureHandlerRootView = false, - ...rest -}: AnimatedHeaderBaseProps) { - if (!animatedHeaderBaseProps) { - throw new Error( - 'AnimatedHeaderBase requires `animatedHeaderBaseProps`. Pass the value from HeaderMotion.Header or useMotionProgress.' - ); - } - - const { - enableHeaderPan, - scrollToRef, - headerPanMomentumOffset: momentumScrollOffset, - } = animatedHeaderBaseProps; - +}: HeaderPanBoundaryProps) { useAnimatedReaction( - () => momentumScrollOffset.get(), + () => headerPanMomentumOffset.get(), (offset, prevOffset) => { if (offset !== null) { const dy = offset - (prevOffset ?? 0); @@ -102,25 +58,20 @@ export function AnimatedHeaderBase({ scrollToRef.current?.(dy); }) .onEnd((e) => { - momentumScrollOffset.set( + headerPanMomentumOffset.set( withDecay( { velocity: e.velocityY, }, - () => momentumScrollOffset.set(null) + () => headerPanMomentumOffset.set(null) ) ); }) .shouldCancelWhenOutside(false), - // Note: In first testing Android works without gesture handler whatsoever. If this functionality is needed, we can further control it with a prop in the future - [isPanEnabled, scrollToRef, momentumScrollOffset] + [isPanEnabled, scrollToRef, headerPanMomentumOffset] ); - const content = ( - - - - ); + const content = {children}; if (!withGestureHandlerRootView) { return content; @@ -128,13 +79,3 @@ export function AnimatedHeaderBase({ return {content}; } - -const styles = StyleSheet.create({ - container: { - position: 'absolute', - left: 0, - right: 0, - }, -}); - -// TODO: Lib refactor: context repetition, make people use less boilerplate by just wrapping the header with that does everything under the hood (measuring total for example). That will then allow for people to use context inside diff --git a/src/components/HeaderMotion.tsx b/src/components/HeaderMotion.tsx index 0aaa65a..7194d9b 100644 --- a/src/components/HeaderMotion.tsx +++ b/src/components/HeaderMotion.tsx @@ -63,7 +63,9 @@ export interface HeaderMotionProps { * * Receives the layout change event from React Native. * - * This function can be further accessed when rendering headers from `HeaderMotion.Header` or `useMotionProgress` - should be passed to the `onLayout` prop of such. If used, can be used for dynamic calculation of the {@link progressThreshold}. + * This function is used internally by `HeaderMotion.Header.Dynamic`. + * It is also available through `HeaderMotion.Bridge`, `HeaderMotion.NavigationBridge`, + * and `useHeaderMotionBridge()` for advanced integrations. * * Defaults to measuring the height from the event. */ @@ -208,15 +210,6 @@ function HeaderMotionContextProvider({ // were not propagating reliably, while it works for refs. Revisit later. // We need to be updating the scrollTo on active scroll ID changes and doing it via state would cause re-renders. // It's a bit of an anti-pattern to use refs for this as well, but I am yet to figure out a better way to pass those if SV won't work. - const animatedHeaderBaseProps = useMemo( - () => ({ - enableHeaderPan, - scrollToRef, - headerPanMomentumOffset, - }), - [enableHeaderPan, headerPanMomentumOffset] - ); - const ctxValue = useMemo( () => ({ progress, @@ -225,7 +218,6 @@ function HeaderMotionContextProvider({ measureTotalHeight, enableHeaderPan, headerPanMomentumOffset, - animatedHeaderBaseProps, progressThreshold: progressThresholdValue, scrollValues, scrollToRef, @@ -237,7 +229,6 @@ function HeaderMotionContextProvider({ measureTotalHeight, enableHeaderPan, headerPanMomentumOffset, - animatedHeaderBaseProps, setOrUpdateDynamicMeasurement, scrollValues, activeScrollId, diff --git a/src/components/__tests__/Header.test.tsx b/src/components/__tests__/Header.test.tsx new file mode 100644 index 0000000..40b0641 --- /dev/null +++ b/src/components/__tests__/Header.test.tsx @@ -0,0 +1,237 @@ +const mockUseHeaderMotionContextOrThrow = jest.fn(); +const mockUseHeaderMotionBridge = jest.fn(); + +jest.mock('react', () => { + const ReactActual = jest.requireActual('react'); + + return { + ...ReactActual, + useMemo: (factory: () => unknown) => factory(), + }; +}); + +jest.mock('../../context', () => { + const actual = jest.requireActual('../../context'); + + return { + __esModule: true, + ...actual, + useHeaderMotionContextOrThrow: (...args: any[]) => + mockUseHeaderMotionContextOrThrow(...args), + }; +}); + +jest.mock('../../hooks/useHeaderMotionBridge', () => ({ + __esModule: true, + useHeaderMotionBridge: (...args: any[]) => mockUseHeaderMotionBridge(...args), +})); + +jest.mock('react-native-gesture-handler', () => { + const ReactActual = jest.requireActual('react'); + const pan = { + enabled: () => pan, + onChange: () => pan, + onEnd: () => pan, + shouldCancelWhenOutside: () => pan, + }; + + return { + __esModule: true, + Gesture: { + Pan: () => pan, + }, + GestureDetector: ({ children, gesture }: any) => + ReactActual.createElement('GestureDetector', { gesture }, children), + GestureHandlerRootView: ({ children }: any) => + ReactActual.createElement('GestureHandlerRootView', null, children), + }; +}); + +import React from 'react'; +import { + HeaderMotionBridge, + HeaderMotionHeader, + HeaderMotionHeaderDynamic, + HeaderMotionNavigationBridge, +} from '../Header'; +import { HeaderPanBoundary, headerOverlayStyle } from '../HeaderBase'; +import { HeaderMotionContext } from '../../context'; + +function createSharedValue(value: T) { + return { + get: jest.fn(() => value), + set: jest.fn(), + value, + addListener: jest.fn(), + removeListener: jest.fn(), + modify: jest.fn(), + } as any; +} + +const bridgeValue = { + progress: createSharedValue(0), + progressThreshold: createSharedValue(120), + measureTotalHeight: jest.fn(), + measureDynamic: jest.fn(), + enableHeaderPan: true, + headerPanMomentumOffset: createSharedValue(null), + scrollValues: createSharedValue({}), + activeScrollId: undefined, + scrollToRef: { current: jest.fn() }, + originalHeaderHeight: createSharedValue(0), +}; + +const layoutEvent = { + nativeEvent: { + layout: { + height: 120, + }, + }, +} as any; + +describe('Header components', () => { + beforeEach(() => { + mockUseHeaderMotionContextOrThrow.mockReset(); + mockUseHeaderMotionBridge.mockReset(); + bridgeValue.measureTotalHeight.mockClear(); + bridgeValue.measureDynamic.mockClear(); + }); + + it('HeaderMotion.Bridge requires a render function child', () => { + expect(() => + HeaderMotionBridge({ + children: 'invalid' as any, + }) + ).toThrow( + 'HeaderMotion.Bridge only accepts a render function as its child.' + ); + }); + + it('HeaderMotion.Bridge passes the bridge value to its child', () => { + const children = jest.fn(() => 'ok'); + mockUseHeaderMotionBridge.mockReturnValue(bridgeValue); + + expect(HeaderMotionBridge({ children })).toBe('ok'); + expect(children).toHaveBeenCalledWith(bridgeValue); + }); + + it('HeaderMotion.NavigationBridge returns the main context provider', () => { + const child = React.createElement('Child'); + const element = HeaderMotionNavigationBridge({ + value: bridgeValue, + children: child, + }) as React.ReactElement; + + expect(element.type).toBe(HeaderMotionContext.Provider); + expect(element.props.value).toBe(bridgeValue); + expect(element.props.children).toBe(child); + }); + + it('HeaderMotion.Header wires overlay styles and total-height measurement', () => { + const userOnLayout = jest.fn(); + mockUseHeaderMotionContextOrThrow.mockReturnValue(bridgeValue); + + const element = HeaderMotionHeader({ + onLayout: userOnLayout, + style: { opacity: 0.5 }, + children: React.createElement('Child'), + } as any) as React.ReactElement; + const viewElement = element.props.children; + + expect(element.type).toBe(HeaderPanBoundary); + expect(viewElement.props.style).toEqual([ + headerOverlayStyle, + { opacity: 0.5 }, + ]); + + viewElement.props.onLayout(layoutEvent); + expect(bridgeValue.measureTotalHeight).toHaveBeenCalledWith(layoutEvent); + expect(userOnLayout).toHaveBeenCalledWith(layoutEvent); + }); + + it('HeaderMotion.Header omits overlay styles when overlay is false', () => { + mockUseHeaderMotionContextOrThrow.mockReturnValue(bridgeValue); + + const element = HeaderMotionHeader({ + overlay: false, + style: { opacity: 1 }, + children: React.createElement('Child'), + } as any) as React.ReactElement; + + expect(element.props.children.props.style).toEqual([ + undefined, + { opacity: 1 }, + ]); + }); + + it('HeaderMotion.Header composes onLayout in asChild mode', () => { + const childOnLayout = jest.fn(); + mockUseHeaderMotionContextOrThrow.mockReturnValue(bridgeValue); + + const element = HeaderMotionHeader({ + asChild: true, + children: React.createElement('Child', { onLayout: childOnLayout }), + }) as React.ReactElement; + const child = element.props.children; + + child.props.onLayout(layoutEvent); + expect(bridgeValue.measureTotalHeight).toHaveBeenCalledWith(layoutEvent); + expect(childOnLayout).toHaveBeenCalledWith(layoutEvent); + }); + + it('HeaderMotion.Header rejects invalid asChild children', () => { + mockUseHeaderMotionContextOrThrow.mockReturnValue(bridgeValue); + + expect(() => + HeaderMotionHeader({ + asChild: true, + children: React.createElement(React.Fragment, null), + }) + ).toThrow( + 'HeaderMotion.Header with `asChild` expects a single valid React element child that accepts `onLayout`.' + ); + }); + + it('HeaderMotion.Header.Dynamic composes onLayout', () => { + const userOnLayout = jest.fn(); + mockUseHeaderMotionContextOrThrow.mockReturnValue(bridgeValue); + + const element = HeaderMotionHeaderDynamic({ + onLayout: userOnLayout, + children: React.createElement('Child'), + } as any) as React.ReactElement; + + element.props.onLayout(layoutEvent); + expect(bridgeValue.measureDynamic).toHaveBeenCalledWith(layoutEvent); + expect(userOnLayout).toHaveBeenCalledWith(layoutEvent); + }); + + it('HeaderMotion.Header.Dynamic composes onLayout in asChild mode', () => { + const childOnLayout = jest.fn(); + mockUseHeaderMotionContextOrThrow.mockReturnValue(bridgeValue); + + const element = HeaderMotionHeaderDynamic({ + asChild: true, + children: React.createElement('Child', { onLayout: childOnLayout }), + }) as React.ReactElement; + + element.props.onLayout(layoutEvent); + expect(bridgeValue.measureDynamic).toHaveBeenCalledWith(layoutEvent); + expect(childOnLayout).toHaveBeenCalledWith(layoutEvent); + }); + + it('HeaderPanBoundary wraps with GestureHandlerRootView when requested', () => { + const child = React.createElement('Child'); + + const element = HeaderPanBoundary({ + children: child, + enableHeaderPan: true, + headerPanMomentumOffset: bridgeValue.headerPanMomentumOffset, + scrollToRef: bridgeValue.scrollToRef, + withGestureHandlerRootView: true, + }) as React.ReactElement; + + expect((element.type as any).name).toBe('GestureHandlerRootView'); + expect((element.props.children.type as any).name).toBe('GestureDetector'); + }); +}); diff --git a/src/components/index.ts b/src/components/index.ts index d3f7e40..f9a4b00 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -1,6 +1,5 @@ export * from './FlatList'; export * from './Header'; -export * from './HeaderBase'; export * from './HeaderMotion'; export * from './ScrollManager'; export * from './ScrollView'; diff --git a/src/context.ts b/src/context.ts index d63727a..0adb73b 100644 --- a/src/context.ts +++ b/src/context.ts @@ -1,27 +1,14 @@ -import { createContext } from 'react'; -import { type SharedValue } from 'react-native-reanimated'; -import type { - AnimatedHeaderBaseMotionProps, - MeasureAnimatedHeaderAndSet, - Progress, - ScrollTo, - ScrollValues, -} from './types'; +import { createContext, useContext } from 'react'; +import type { HeaderMotionBridgeValue } from './types'; -interface HeaderMotionContextType { - progress: Progress; - measureTotalHeight: MeasureAnimatedHeaderAndSet; - measureDynamic: MeasureAnimatedHeaderAndSet; - enableHeaderPan: boolean; - headerPanMomentumOffset: SharedValue; - animatedHeaderBaseProps: AnimatedHeaderBaseMotionProps; - scrollValues: SharedValue; - activeScrollId: SharedValue | undefined; - progressThreshold: SharedValue; - originalHeaderHeight: number; +export const HeaderMotionContext = + createContext(null); - scrollToRef: React.RefObject; -} +export function useHeaderMotionContextOrThrow(errorMessage: string) { + const ctxValue = useContext(HeaderMotionContext); + if (!ctxValue) { + throw new Error(errorMessage); + } -export const HeaderMotionContext = - createContext(null); + return ctxValue; +} diff --git a/src/hooks/index.ts b/src/hooks/index.ts index 7686e2a..a520f32 100644 --- a/src/hooks/index.ts +++ b/src/hooks/index.ts @@ -1,3 +1,4 @@ export * from './useActiveScrollId'; +export * from './useHeaderMotionBridge'; export * from './useMotionProgress'; export * from './useScrollManager'; diff --git a/src/hooks/useHeaderMotionBridge.ts b/src/hooks/useHeaderMotionBridge.ts new file mode 100644 index 0000000..cd525bc --- /dev/null +++ b/src/hooks/useHeaderMotionBridge.ts @@ -0,0 +1,13 @@ +import { useHeaderMotionContextOrThrow } from '../context'; +import type { HeaderMotionBridgeValue } from '../types'; + +/** + * Hook to access the full HeaderMotion bridge value. + * Use this only when you need to bridge header motion context into another + * part of the tree, such as a navigation-rendered header. + */ +export function useHeaderMotionBridge(): HeaderMotionBridgeValue { + return useHeaderMotionContextOrThrow( + 'useHeaderMotionBridge must be used within . Use it only when bridging context into a separate subtree with and .' + ); +} diff --git a/src/hooks/useMotionProgress.test.ts b/src/hooks/useMotionProgress.test.ts new file mode 100644 index 0000000..2080a69 --- /dev/null +++ b/src/hooks/useMotionProgress.test.ts @@ -0,0 +1,68 @@ +const mockUseHeaderMotionContextOrThrow = jest.fn(); + +jest.mock('../context', () => ({ + __esModule: true, + useHeaderMotionContextOrThrow: (...args: any[]) => + mockUseHeaderMotionContextOrThrow(...args), +})); + +import { useHeaderMotionBridge } from './useHeaderMotionBridge'; +import { useMotionProgress } from './useMotionProgress'; + +function createSharedValue(value: T) { + return { + get: jest.fn(() => value), + set: jest.fn(), + value, + }; +} + +const bridgeValue = { + progress: createSharedValue(0), + progressThreshold: createSharedValue(120), + measureTotalHeight: jest.fn(), + measureDynamic: jest.fn(), + enableHeaderPan: false, + headerPanMomentumOffset: createSharedValue(null), + scrollValues: createSharedValue({}), + activeScrollId: undefined, + scrollToRef: { current: null }, + originalHeaderHeight: createSharedValue(0), +}; + +describe('motion hooks', () => { + beforeEach(() => { + mockUseHeaderMotionContextOrThrow.mockReset(); + }); + + it('useMotionProgress returns only progress and progressThreshold', () => { + mockUseHeaderMotionContextOrThrow.mockReturnValue(bridgeValue); + + expect(useMotionProgress()).toEqual({ + progress: bridgeValue.progress, + progressThreshold: bridgeValue.progressThreshold, + }); + expect(mockUseHeaderMotionContextOrThrow).toHaveBeenCalledWith( + 'useMotionProgress must be used within or . If you are rendering inside a navigation header, bridge the context with and .' + ); + }); + + it('useHeaderMotionBridge returns the full bridge value', () => { + mockUseHeaderMotionContextOrThrow.mockReturnValue(bridgeValue); + + expect(useHeaderMotionBridge()).toBe(bridgeValue); + expect(mockUseHeaderMotionContextOrThrow).toHaveBeenCalledWith( + 'useHeaderMotionBridge must be used within . Use it only when bridging context into a separate subtree with and .' + ); + }); + + it('rethrows missing-context errors from the helper hook', () => { + const error = new Error('missing context'); + mockUseHeaderMotionContextOrThrow.mockImplementation(() => { + throw error; + }); + + expect(() => useMotionProgress()).toThrow(error); + expect(() => useHeaderMotionBridge()).toThrow(error); + }); +}); diff --git a/src/hooks/useMotionProgress.ts b/src/hooks/useMotionProgress.ts index 0ac704e..63691ca 100644 --- a/src/hooks/useMotionProgress.ts +++ b/src/hooks/useMotionProgress.ts @@ -1,64 +1,32 @@ -import { useContext } from 'react'; -import { HeaderMotionContext } from '../context'; +import { useHeaderMotionContextOrThrow } from '../context'; import type { MotionProgress } from '../types'; /** - * Hook to access motion progress values and measuring functions for header animations. - * Returns the progress value (0-1), threshold, and measurement functions. + * Hook to access motion progress values for header animations. + * Returns the progress value (0-1) and threshold. * * Must be used within a {@link HeaderMotion} component. * - * @returns Motion progress values and measuring functions: + * @returns Motion progress values: * - `progress`: Shared value from 0 to 1 * - `progressThreshold`: The threshold at which animation completes - * - `measureTotalHeight`: Function to measure total header height. Should be passed to the `onLayout` prop of the base of a header, to let scrollables account for the total header height - * - `measureDynamic`: Function to measure a dimension of choice of the animated element of the header - should be passed to the `onLayout` prop of such. If used, can be used for dynamic calculation of the {@link progressThreshold}. * * @throws Error if used outside of a {@link HeaderMotion} component * * @example * ```tsx * function MyHeader() { - * const { progress, progressThreshold, measureTotalHeight, measureDynamic } = useMotionProgress(); - * const dynamicStyle = useAnimatedStyle(() => { - * const translateY = interpolate( - * progress.value, - * [0, 1], - * [0, -progressThreshold.get()], - * Extrapolation.CLAMP, - * ) - * return { transform: [{ translateY }] } - * }) - * return ( - * - * - * - * ) + * const { progress, progressThreshold } = useMotionProgress(); * } * ``` */ export function useMotionProgress(): MotionProgress { - const ctxValue = useContext(HeaderMotionContext); - if (!ctxValue) { - throw new Error( - 'useMotionProgress must be used within a component. If using inside a navigation header, consider using instead to ensure context access.' - ); - } - const { - progress, - measureTotalHeight, - measureDynamic, - progressThreshold, - animatedHeaderBaseProps, - activeScrollId, - } = ctxValue; + const { progress, progressThreshold } = useHeaderMotionContextOrThrow( + 'useMotionProgress must be used within or . If you are rendering inside a navigation header, bridge the context with and .' + ); return { progress, - measureTotalHeight, - measureDynamic, progressThreshold, - animatedHeaderBaseProps, - activeScrollId, }; } diff --git a/src/index.ts b/src/index.ts index b655847..8ad886c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,19 +1,22 @@ import { - AnimatedHeaderBase, - HeaderBase, createHeaderMotionScrollable, + HeaderMotionBridge, HeaderMotionContextProvider, HeaderMotionFlatList, HeaderMotionHeader, + HeaderMotionNavigationBridge, HeaderMotionScrollManager, HeaderMotionScrollView, type CreateHeaderMotionScrollableOptions, + type HeaderDynamicProps, type HeaderMotionFlatListProps, - type HeaderMotionHeaderProps, + type HeaderMotionBridgeProps, + type HeaderMotionNavigationBridgeProps, type HeaderMotionProps, type HeaderMotionScrollManagerProps, type HeaderMotionScrollableOwnProps, type HeaderMotionScrollViewProps, + type HeaderProps, } from './components'; import type { ReactElement } from 'react'; @@ -24,10 +27,12 @@ import type { ReactElement } from 'react'; type HeaderMotionComponent = { /** Main context provider component */ (props: HeaderMotionProps): ReactElement; - /** Component for providing motion progress properties to animated headers. - * Use to pass props to the header components in React Navigation / Expo Router, which cannot access HeaderMotion's context and `useMotionProgress` otherwise. - */ + /** Header container component with built-in measurement wiring. */ Header: typeof HeaderMotionHeader; + /** Render-prop bridge for navigation-installed headers. */ + Bridge: typeof HeaderMotionBridge; + /** Context provider for navigation-installed headers. */ + NavigationBridge: typeof HeaderMotionNavigationBridge; /** Component for custom scroll implementations. * Use when you want render-prop composition instead of calling {@link useScrollManager} directly. */ @@ -45,17 +50,19 @@ type HeaderMotionComponent = { * @example * ```tsx * - * - * {(headerProps) => ( + * + * {(value) => ( * ( - * + * + * + * * ), * }} * /> * )} - * + * * * * @@ -64,6 +71,8 @@ type HeaderMotionComponent = { */ const HeaderMotion = HeaderMotionContextProvider as HeaderMotionComponent; HeaderMotion.Header = HeaderMotionHeader; +HeaderMotion.Bridge = HeaderMotionBridge; +HeaderMotion.NavigationBridge = HeaderMotionNavigationBridge; HeaderMotion.ScrollManager = HeaderMotionScrollManager; HeaderMotion.ScrollView = HeaderMotionScrollView; HeaderMotion.FlatList = HeaderMotionFlatList; @@ -71,14 +80,21 @@ HeaderMotion.FlatList = HeaderMotionFlatList; export default HeaderMotion; export * from './hooks'; export type * from './types'; -export { AnimatedHeaderBase, HeaderBase }; export { createHeaderMotionScrollable }; +export { + HeaderMotionBridge as Bridge, + HeaderMotionHeader as Header, + HeaderMotionNavigationBridge as NavigationBridge, +}; export type { CreateHeaderMotionScrollableOptions, + HeaderDynamicProps, HeaderMotionFlatListProps, - HeaderMotionHeaderProps, + HeaderMotionBridgeProps, + HeaderMotionNavigationBridgeProps, HeaderMotionProps, HeaderMotionScrollManagerProps, HeaderMotionScrollableOwnProps, HeaderMotionScrollViewProps, + HeaderProps, }; diff --git a/src/types.ts b/src/types.ts index b0c0b86..211e90a 100644 --- a/src/types.ts +++ b/src/types.ts @@ -52,31 +52,20 @@ export type ScrollValues = Record & { [key in typeof DEFAULT_SCROLL_ID]?: ScrollValue; }; -export type WithCollapsibleHeaderProps< - T extends Record = Record -> = T & MotionProgress; - -export type WithCollapsiblePagedHeaderProps< - Tab extends string = string, - T extends Record = Record -> = WithCollapsibleHeaderProps & { - onTabChange: (newTab: Tab) => void; - activeTab: Tab; -}; - export interface MotionProgress { progress: Progress; progressThreshold: SharedValue; - measureTotalHeight: MeasureAnimatedHeaderAndSet; - measureDynamic: MeasureAnimatedHeaderAndSet; - animatedHeaderBaseProps: AnimatedHeaderBaseMotionProps; - activeScrollId: SharedValue | undefined; } -export interface AnimatedHeaderBaseMotionProps { +export interface HeaderMotionBridgeValue extends MotionProgress { + measureTotalHeight: MeasureAnimatedHeaderAndSet; + measureDynamic: MeasureAnimatedHeaderAndSet; enableHeaderPan: boolean; - scrollToRef: React.RefObject; headerPanMomentumOffset: SharedValue; + scrollValues: SharedValue; + activeScrollId: SharedValue | undefined; + scrollToRef: React.RefObject; + originalHeaderHeight: number; } export interface ScrollManagerHeaderMotionContext { From 86472fc904c9ac22f8723026f430534286515c2d Mon Sep 17 00:00:00 2001 From: Oskar Pawica Date: Sat, 28 Mar 2026 22:30:15 +0100 Subject: [PATCH 2/8] feat: add headerPanDecayConfig and move enableHeaderPan --- README.md | 10 ++-- example/src/app/header-pan.tsx | 3 +- example/src/app/pager-header-pan.tsx | 11 +++- src/components/Header.tsx | 49 ++++++++++++---- src/components/HeaderBase.tsx | 43 ++++++++++---- src/components/HeaderMotion.tsx | 7 --- src/components/__tests__/Header.test.tsx | 71 +++++++++++++++++++++++- src/hooks/useMotionProgress.test.ts | 3 +- src/types.ts | 13 ++++- 9 files changed, 171 insertions(+), 39 deletions(-) diff --git a/README.md b/README.md index ad50aae..d71b963 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ This library is **100% a wrapper around Reanimated**. All the credit for the und ## What changed since `v0.3.0` - **Performance-focused internals:** motion threshold + header height now flow through `SharedValue`s to reduce JS-side churn. -- **Pannable header support:** `enableHeaderPan` now works directly through `HeaderMotion.Header`. +- **Pannable header support:** `enableHeaderPan` and header-pan momentum config now live directly on `HeaderMotion.Header`. - **Ecosystem update:** example app moved to Expo 55 + Reanimated 4.2; `react-native-gesture-handler` is now a peer dependency. ## What this is (and isn’t) @@ -303,9 +303,6 @@ The package exports a default compound component plus hooks, types, and named al - Enables multi-scroll orchestration (tabs/pager). - `progressExtrapolation?: ExtrapolationType` - Controls how progress behaves outside the threshold range (useful for overscroll). -- `enableHeaderPan?: boolean` - - Enables direct pan gestures on `HeaderMotion.Header` (`false` by default). - #### `HeaderMotion.Header` Header container component that: @@ -327,6 +324,11 @@ Props: - `overlay?: boolean` - Defaults to `true` +- `enableHeaderPan?: boolean` + - Enables direct pan gestures on that specific header instance. +- `headerPanDecayConfig?: WithDecayConfig | ((event: GestureStateChangeEvent) => WithDecayConfig)` + - Optional momentum config used after the header pan ends. + - If you pass a function, it runs inside the gesture end worklet and must be worklet-safe. - `withGestureHandlerRootView?: boolean` - `asChild?: boolean` diff --git a/example/src/app/header-pan.tsx b/example/src/app/header-pan.tsx index 4b7aae8..5eed03e 100644 --- a/example/src/app/header-pan.tsx +++ b/example/src/app/header-pan.tsx @@ -11,7 +11,7 @@ import { useSafeAreaInsets } from 'react-native-safe-area-context'; export default function Screen() { return ( - + {(value) => ( diff --git a/example/src/app/pager-header-pan.tsx b/example/src/app/pager-header-pan.tsx index f005722..ebe4146 100644 --- a/example/src/app/pager-header-pan.tsx +++ b/example/src/app/pager-header-pan.tsx @@ -43,7 +43,7 @@ export default function Screen() { }; return ( - + {(value) => ( { + 'worklet'; + + return { + velocity: e.velocityY * 1.4, + deceleration: 0.997, + }; + }} style={[styles.headerWrapper, { paddingTop: insets.top }, containerStyle]} > diff --git a/src/components/Header.tsx b/src/components/Header.tsx index 0b17e67..e732670 100644 --- a/src/components/Header.tsx +++ b/src/components/Header.tsx @@ -9,11 +9,34 @@ import type { ViewProps } from 'react-native'; import Animated, { type AnimatedProps } from 'react-native-reanimated'; import { HeaderMotionContext, useHeaderMotionContextOrThrow } from '../context'; import { useHeaderMotionBridge } from '../hooks/useHeaderMotionBridge'; -import type { HeaderMotionBridgeValue, MotionProgress } from '../types'; +import type { + HeaderMotionBridgeValue, + HeaderPanDecayConfig, + MotionProgress, +} from '../types'; import { HeaderPanBoundary, headerOverlayStyle } from './HeaderBase'; type HeaderRenderChildren = (value: HeaderMotionBridgeValue) => ReactNode; +type HeaderPanProps = + | { + /** Enables panning directly on the header surface. + * @default false + */ + enableHeaderPan: true; + /** + * Custom momentum config used after a header pan ends. + * + * If you provide a function, it runs inside the gesture end worklet and + * **must itself be a worklet-safe function**. + */ + headerPanDecayConfig?: HeaderPanDecayConfig; + } + | { + enableHeaderPan?: false | undefined; + headerPanDecayConfig?: never; + }; + type HeaderAsChildProps = { asChild: true; children: ReactElement; @@ -24,13 +47,15 @@ type HeaderDefaultProps = AnimatedProps & { }; export type HeaderProps = - | (HeaderDefaultProps & { - overlay?: boolean; - withGestureHandlerRootView?: boolean; - }) - | (HeaderAsChildProps & { - withGestureHandlerRootView?: boolean; - }); + | (HeaderDefaultProps & + HeaderPanProps & { + overlay?: boolean; + withGestureHandlerRootView?: boolean; + }) + | (HeaderAsChildProps & + HeaderPanProps & { + withGestureHandlerRootView?: boolean; + }); export type HeaderDynamicProps = HeaderDefaultProps | HeaderAsChildProps; @@ -78,7 +103,8 @@ function HeaderMotionHeaderRoot(props: HeaderProps) { return ( & { children: ReactElement; + enableHeaderPan?: boolean; + headerPanDecayConfig?: HeaderPanDecayConfig; withGestureHandlerRootView?: boolean; }; @@ -32,7 +38,8 @@ export const headerOverlayStyle = StyleSheet.create({ export function HeaderPanBoundary({ children, - enableHeaderPan, + enableHeaderPan = false, + headerPanDecayConfig, scrollToRef, headerPanMomentumOffset, withGestureHandlerRootView = false, @@ -58,17 +65,16 @@ export function HeaderPanBoundary({ scrollToRef.current?.(dy); }) .onEnd((e) => { + const resolvedConfig = resolveHeaderPanDecayConfig( + headerPanDecayConfig, + e + ); headerPanMomentumOffset.set( - withDecay( - { - velocity: e.velocityY, - }, - () => headerPanMomentumOffset.set(null) - ) + withDecay(resolvedConfig, () => headerPanMomentumOffset.set(null)) ); }) .shouldCancelWhenOutside(false), - [isPanEnabled, scrollToRef, headerPanMomentumOffset] + [headerPanDecayConfig, headerPanMomentumOffset, isPanEnabled, scrollToRef] ); const content = {children}; @@ -79,3 +85,20 @@ export function HeaderPanBoundary({ return {content}; } + +function resolveHeaderPanDecayConfig( + headerPanDecayConfig: HeaderPanDecayConfig | undefined, + event: HeaderPanDecayEvent +) { + 'worklet'; + + const resolvedConfig = + typeof headerPanDecayConfig === 'function' + ? headerPanDecayConfig(event) + : headerPanDecayConfig; + + return { + ...resolvedConfig, + velocity: resolvedConfig?.velocity ?? event.velocityY, + }; +} diff --git a/src/components/HeaderMotion.tsx b/src/components/HeaderMotion.tsx index 7194d9b..3788c38 100644 --- a/src/components/HeaderMotion.tsx +++ b/src/components/HeaderMotion.tsx @@ -90,10 +90,6 @@ export interface HeaderMotionProps { * @default Extrapolation.CLAMP */ progressExtrapolation?: ExtrapolationType; - /** Enables panning directly on the header surface. - * @default false - */ - enableHeaderPan?: boolean; /** Child components that will have access to the header motion context */ children: ReactNode; } @@ -109,7 +105,6 @@ function HeaderMotionContextProvider({ measureDynamicMode = 'mount', activeScrollId, progressExtrapolation = Extrapolation.CLAMP, - enableHeaderPan = false, children, }: HeaderMotionProps) { const dynamicMeasurement = useSharedValue(undefined); @@ -216,7 +211,6 @@ function HeaderMotionContextProvider({ originalHeaderHeight, measureDynamic: setOrUpdateDynamicMeasurement, measureTotalHeight, - enableHeaderPan, headerPanMomentumOffset, progressThreshold: progressThresholdValue, scrollValues, @@ -227,7 +221,6 @@ function HeaderMotionContextProvider({ originalHeaderHeight, progress, measureTotalHeight, - enableHeaderPan, headerPanMomentumOffset, setOrUpdateDynamicMeasurement, scrollValues, diff --git a/src/components/__tests__/Header.test.tsx b/src/components/__tests__/Header.test.tsx index 40b0641..79512ad 100644 --- a/src/components/__tests__/Header.test.tsx +++ b/src/components/__tests__/Header.test.tsx @@ -1,5 +1,6 @@ const mockUseHeaderMotionContextOrThrow = jest.fn(); const mockUseHeaderMotionBridge = jest.fn(); +let capturedPanOnEnd: ((event: any) => void) | undefined; jest.mock('react', () => { const ReactActual = jest.requireActual('react'); @@ -31,7 +32,10 @@ jest.mock('react-native-gesture-handler', () => { const pan = { enabled: () => pan, onChange: () => pan, - onEnd: () => pan, + onEnd: (cb: (event: any) => void) => { + capturedPanOnEnd = cb; + return pan; + }, shouldCancelWhenOutside: () => pan, }; @@ -73,12 +77,11 @@ const bridgeValue = { progressThreshold: createSharedValue(120), measureTotalHeight: jest.fn(), measureDynamic: jest.fn(), - enableHeaderPan: true, headerPanMomentumOffset: createSharedValue(null), scrollValues: createSharedValue({}), activeScrollId: undefined, scrollToRef: { current: jest.fn() }, - originalHeaderHeight: createSharedValue(0), + originalHeaderHeight: 0, }; const layoutEvent = { @@ -93,8 +96,10 @@ describe('Header components', () => { beforeEach(() => { mockUseHeaderMotionContextOrThrow.mockReset(); mockUseHeaderMotionBridge.mockReset(); + capturedPanOnEnd = undefined; bridgeValue.measureTotalHeight.mockClear(); bridgeValue.measureDynamic.mockClear(); + bridgeValue.headerPanMomentumOffset.set.mockClear(); }); it('HeaderMotion.Bridge requires a render function child', () => { @@ -139,6 +144,8 @@ describe('Header components', () => { const viewElement = element.props.children; expect(element.type).toBe(HeaderPanBoundary); + expect(element.props.enableHeaderPan).toBeUndefined(); + expect(element.props.headerPanDecayConfig).toBeUndefined(); expect(viewElement.props.style).toEqual([ headerOverlayStyle, { opacity: 0.5 }, @@ -179,6 +186,20 @@ describe('Header components', () => { expect(childOnLayout).toHaveBeenCalledWith(layoutEvent); }); + it('HeaderMotion.Header forwards pan props to HeaderPanBoundary', () => { + const headerPanDecayConfig = { deceleration: 0.99 }; + mockUseHeaderMotionContextOrThrow.mockReturnValue(bridgeValue); + + const element = HeaderMotionHeader({ + enableHeaderPan: true, + headerPanDecayConfig, + children: React.createElement('Child'), + } as any) as React.ReactElement; + + expect(element.props.enableHeaderPan).toBe(true); + expect(element.props.headerPanDecayConfig).toBe(headerPanDecayConfig); + }); + it('HeaderMotion.Header rejects invalid asChild children', () => { mockUseHeaderMotionContextOrThrow.mockReturnValue(bridgeValue); @@ -234,4 +255,48 @@ describe('Header components', () => { expect((element.type as any).name).toBe('GestureHandlerRootView'); expect((element.props.children.type as any).name).toBe('GestureDetector'); }); + + it('HeaderPanBoundary uses object decay config for momentum', () => { + const element = HeaderPanBoundary({ + children: React.createElement('Child'), + enableHeaderPan: true, + headerPanDecayConfig: { deceleration: 0.991 }, + headerPanMomentumOffset: bridgeValue.headerPanMomentumOffset, + scrollToRef: bridgeValue.scrollToRef, + }) as React.ReactElement; + + expect( + element.props.gesture ?? element.props.children?.props?.gesture ?? null + ).not.toBeNull(); + expect(capturedPanOnEnd).toEqual(expect.any(Function)); + + capturedPanOnEnd?.({ velocityY: 320 }); + + expect(bridgeValue.headerPanMomentumOffset.set).toHaveBeenCalledTimes(2); + }); + + it('HeaderPanBoundary uses function decay config for momentum', () => { + const headerPanDecayConfig = jest.fn((event) => ({ + velocity: event.velocityY * 0.5, + deceleration: 0.994, + })); + + const element = HeaderPanBoundary({ + children: React.createElement('Child'), + enableHeaderPan: true, + headerPanDecayConfig, + headerPanMomentumOffset: bridgeValue.headerPanMomentumOffset, + scrollToRef: bridgeValue.scrollToRef, + }) as React.ReactElement; + + expect( + element.props.gesture ?? element.props.children?.props?.gesture ?? null + ).not.toBeNull(); + expect(capturedPanOnEnd).toEqual(expect.any(Function)); + + capturedPanOnEnd?.({ velocityY: 240 }); + + expect(headerPanDecayConfig).toHaveBeenCalledWith({ velocityY: 240 }); + expect(bridgeValue.headerPanMomentumOffset.set).toHaveBeenCalledTimes(2); + }); }); diff --git a/src/hooks/useMotionProgress.test.ts b/src/hooks/useMotionProgress.test.ts index 2080a69..257c1de 100644 --- a/src/hooks/useMotionProgress.test.ts +++ b/src/hooks/useMotionProgress.test.ts @@ -22,12 +22,11 @@ const bridgeValue = { progressThreshold: createSharedValue(120), measureTotalHeight: jest.fn(), measureDynamic: jest.fn(), - enableHeaderPan: false, headerPanMomentumOffset: createSharedValue(null), scrollValues: createSharedValue({}), activeScrollId: undefined, scrollToRef: { current: null }, - originalHeaderHeight: createSharedValue(0), + originalHeaderHeight: 0, }; describe('motion hooks', () => { diff --git a/src/types.ts b/src/types.ts index 211e90a..e8554b2 100644 --- a/src/types.ts +++ b/src/types.ts @@ -3,6 +3,11 @@ import type { LayoutChangeEvent, ScrollViewProps } from 'react-native'; import type { AnimatedRef, SharedValue } from 'react-native-reanimated'; import { DEFAULT_SCROLL_ID } from './utils/defaults'; import type { InstanceOrElement } from 'react-native-reanimated/lib/typescript/commonTypes'; +import type { + GestureStateChangeEvent, + PanGestureHandlerEventPayload, +} from 'react-native-gesture-handler'; +import type { WithDecayConfig } from 'react-native-reanimated'; export type Progress = SharedValue; export type HeaderMotionOffsetStrategy = @@ -57,10 +62,16 @@ export interface MotionProgress { progressThreshold: SharedValue; } +export type HeaderPanDecayEvent = + GestureStateChangeEvent; + +export type HeaderPanDecayConfig = + | WithDecayConfig + | ((event: HeaderPanDecayEvent) => WithDecayConfig); + export interface HeaderMotionBridgeValue extends MotionProgress { measureTotalHeight: MeasureAnimatedHeaderAndSet; measureDynamic: MeasureAnimatedHeaderAndSet; - enableHeaderPan: boolean; headerPanMomentumOffset: SharedValue; scrollValues: SharedValue; activeScrollId: SharedValue | undefined; From e16f2f8999930d788e7f3813f601138bad2b1da0 Mon Sep 17 00:00:00 2001 From: Oskar Pawica Date: Sat, 28 Mar 2026 22:36:38 +0100 Subject: [PATCH 3/8] chore: enableHeaderPan -> pannable --- README.md | 6 ++--- example/src/app/header-pan.tsx | 2 +- example/src/app/pager-header-pan.tsx | 4 ++-- src/components/Header.tsx | 20 ++++++++--------- src/components/HeaderBase.tsx | 25 ++++++++++----------- src/components/__tests__/Header.test.tsx | 28 ++++++++++++------------ 6 files changed, 41 insertions(+), 44 deletions(-) diff --git a/README.md b/README.md index d71b963..9e5c71d 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ This library is **100% a wrapper around Reanimated**. All the credit for the und ## What changed since `v0.3.0` - **Performance-focused internals:** motion threshold + header height now flow through `SharedValue`s to reduce JS-side churn. -- **Pannable header support:** `enableHeaderPan` and header-pan momentum config now live directly on `HeaderMotion.Header`. +- **Pannable header support:** `pannable` and `panDecayConfig` now live directly on `HeaderMotion.Header`. - **Ecosystem update:** example app moved to Expo 55 + Reanimated 4.2; `react-native-gesture-handler` is now a peer dependency. ## What this is (and isn’t) @@ -324,9 +324,9 @@ Props: - `overlay?: boolean` - Defaults to `true` -- `enableHeaderPan?: boolean` +- `pannable?: boolean` - Enables direct pan gestures on that specific header instance. -- `headerPanDecayConfig?: WithDecayConfig | ((event: GestureStateChangeEvent) => WithDecayConfig)` +- `panDecayConfig?: WithDecayConfig | ((event: GestureStateChangeEvent) => WithDecayConfig)` - Optional momentum config used after the header pan ends. - If you pass a function, it runs inside the gesture end worklet and must be worklet-safe. - `withGestureHandlerRootView?: boolean` diff --git a/example/src/app/header-pan.tsx b/example/src/app/header-pan.tsx index 5eed03e..3542f2b 100644 --- a/example/src/app/header-pan.tsx +++ b/example/src/app/header-pan.tsx @@ -84,7 +84,7 @@ function CollapsibleHeader() { return ( diff --git a/example/src/app/pager-header-pan.tsx b/example/src/app/pager-header-pan.tsx index ebe4146..f105302 100644 --- a/example/src/app/pager-header-pan.tsx +++ b/example/src/app/pager-header-pan.tsx @@ -141,8 +141,8 @@ function CollapsibleHeader({ return ( { + pannable + panDecayConfig={(e) => { 'worklet'; return { diff --git a/src/components/Header.tsx b/src/components/Header.tsx index e732670..191643e 100644 --- a/src/components/Header.tsx +++ b/src/components/Header.tsx @@ -23,18 +23,18 @@ type HeaderPanProps = /** Enables panning directly on the header surface. * @default false */ - enableHeaderPan: true; + pannable: true; /** * Custom momentum config used after a header pan ends. * * If you provide a function, it runs inside the gesture end worklet and * **must itself be a worklet-safe function**. */ - headerPanDecayConfig?: HeaderPanDecayConfig; + panDecayConfig?: HeaderPanDecayConfig; } | { - enableHeaderPan?: false | undefined; - headerPanDecayConfig?: never; + pannable?: false | undefined; + panDecayConfig?: never; }; type HeaderAsChildProps = { @@ -103,8 +103,8 @@ function HeaderMotionHeaderRoot(props: HeaderProps) { return ( & { children: ReactElement; - enableHeaderPan?: boolean; - headerPanDecayConfig?: HeaderPanDecayConfig; + pannable?: boolean; + panDecayConfig?: HeaderPanDecayConfig; withGestureHandlerRootView?: boolean; }; @@ -38,8 +38,8 @@ export const headerOverlayStyle = StyleSheet.create({ export function HeaderPanBoundary({ children, - enableHeaderPan = false, - headerPanDecayConfig, + pannable = false, + panDecayConfig, scrollToRef, headerPanMomentumOffset, withGestureHandlerRootView = false, @@ -54,7 +54,7 @@ export function HeaderPanBoundary({ } ); - const isPanEnabled = PLATFORM_PANNING_ENABLED && enableHeaderPan; + const isPanEnabled = PLATFORM_PANNING_ENABLED && pannable; const pan = useMemo( () => @@ -65,16 +65,13 @@ export function HeaderPanBoundary({ scrollToRef.current?.(dy); }) .onEnd((e) => { - const resolvedConfig = resolveHeaderPanDecayConfig( - headerPanDecayConfig, - e - ); + const resolvedConfig = resolveHeaderPanDecayConfig(panDecayConfig, e); headerPanMomentumOffset.set( withDecay(resolvedConfig, () => headerPanMomentumOffset.set(null)) ); }) .shouldCancelWhenOutside(false), - [headerPanDecayConfig, headerPanMomentumOffset, isPanEnabled, scrollToRef] + [headerPanMomentumOffset, isPanEnabled, panDecayConfig, scrollToRef] ); const content = {children}; @@ -87,15 +84,15 @@ export function HeaderPanBoundary({ } function resolveHeaderPanDecayConfig( - headerPanDecayConfig: HeaderPanDecayConfig | undefined, + panDecayConfig: HeaderPanDecayConfig | undefined, event: HeaderPanDecayEvent ) { 'worklet'; const resolvedConfig = - typeof headerPanDecayConfig === 'function' - ? headerPanDecayConfig(event) - : headerPanDecayConfig; + typeof panDecayConfig === 'function' + ? panDecayConfig(event) + : panDecayConfig; return { ...resolvedConfig, diff --git a/src/components/__tests__/Header.test.tsx b/src/components/__tests__/Header.test.tsx index 79512ad..f02c399 100644 --- a/src/components/__tests__/Header.test.tsx +++ b/src/components/__tests__/Header.test.tsx @@ -144,8 +144,8 @@ describe('Header components', () => { const viewElement = element.props.children; expect(element.type).toBe(HeaderPanBoundary); - expect(element.props.enableHeaderPan).toBeUndefined(); - expect(element.props.headerPanDecayConfig).toBeUndefined(); + expect(element.props.pannable).toBeUndefined(); + expect(element.props.panDecayConfig).toBeUndefined(); expect(viewElement.props.style).toEqual([ headerOverlayStyle, { opacity: 0.5 }, @@ -187,17 +187,17 @@ describe('Header components', () => { }); it('HeaderMotion.Header forwards pan props to HeaderPanBoundary', () => { - const headerPanDecayConfig = { deceleration: 0.99 }; + const panDecayConfig = { deceleration: 0.99 }; mockUseHeaderMotionContextOrThrow.mockReturnValue(bridgeValue); const element = HeaderMotionHeader({ - enableHeaderPan: true, - headerPanDecayConfig, + pannable: true, + panDecayConfig, children: React.createElement('Child'), } as any) as React.ReactElement; - expect(element.props.enableHeaderPan).toBe(true); - expect(element.props.headerPanDecayConfig).toBe(headerPanDecayConfig); + expect(element.props.pannable).toBe(true); + expect(element.props.panDecayConfig).toBe(panDecayConfig); }); it('HeaderMotion.Header rejects invalid asChild children', () => { @@ -246,7 +246,7 @@ describe('Header components', () => { const element = HeaderPanBoundary({ children: child, - enableHeaderPan: true, + pannable: true, headerPanMomentumOffset: bridgeValue.headerPanMomentumOffset, scrollToRef: bridgeValue.scrollToRef, withGestureHandlerRootView: true, @@ -259,8 +259,8 @@ describe('Header components', () => { it('HeaderPanBoundary uses object decay config for momentum', () => { const element = HeaderPanBoundary({ children: React.createElement('Child'), - enableHeaderPan: true, - headerPanDecayConfig: { deceleration: 0.991 }, + pannable: true, + panDecayConfig: { deceleration: 0.991 }, headerPanMomentumOffset: bridgeValue.headerPanMomentumOffset, scrollToRef: bridgeValue.scrollToRef, }) as React.ReactElement; @@ -276,15 +276,15 @@ describe('Header components', () => { }); it('HeaderPanBoundary uses function decay config for momentum', () => { - const headerPanDecayConfig = jest.fn((event) => ({ + const panDecayConfig = jest.fn((event) => ({ velocity: event.velocityY * 0.5, deceleration: 0.994, })); const element = HeaderPanBoundary({ children: React.createElement('Child'), - enableHeaderPan: true, - headerPanDecayConfig, + pannable: true, + panDecayConfig, headerPanMomentumOffset: bridgeValue.headerPanMomentumOffset, scrollToRef: bridgeValue.scrollToRef, }) as React.ReactElement; @@ -296,7 +296,7 @@ describe('Header components', () => { capturedPanOnEnd?.({ velocityY: 240 }); - expect(headerPanDecayConfig).toHaveBeenCalledWith({ velocityY: 240 }); + expect(panDecayConfig).toHaveBeenCalledWith({ velocityY: 240 }); expect(bridgeValue.headerPanMomentumOffset.set).toHaveBeenCalledTimes(2); }); }); From 7bf92a52e5047c08ad1161b3e5c2a32370215e99 Mon Sep 17 00:00:00 2001 From: Oskar Pawica Date: Sun, 29 Mar 2026 01:58:57 +0100 Subject: [PATCH 4/8] chore: rearrange files --- src/components/Bridge.tsx | 19 +++ src/components/FlatList.tsx | 15 +- src/components/Header.tsx | 143 +++--------------- src/components/HeaderDynamic.tsx | 37 +++++ .../{HeaderBase.tsx => HeaderPanBoundary.tsx} | 15 +- src/components/NavigationBridge.tsx | 19 +++ src/components/ScrollManager.tsx | 4 +- src/components/ScrollView.tsx | 13 +- src/components/__tests__/FlatList.test.tsx | 6 +- src/components/__tests__/Header.test.tsx | 39 ++--- src/components/__tests__/ScrollView.test.tsx | 6 +- src/components/index.ts | 2 + src/index.ts | 59 ++------ src/utils/header.tsx | 50 ++++++ src/utils/index.ts | 1 + 15 files changed, 210 insertions(+), 218 deletions(-) create mode 100644 src/components/Bridge.tsx create mode 100644 src/components/HeaderDynamic.tsx rename src/components/{HeaderBase.tsx => HeaderPanBoundary.tsx} (87%) create mode 100644 src/components/NavigationBridge.tsx create mode 100644 src/utils/header.tsx diff --git a/src/components/Bridge.tsx b/src/components/Bridge.tsx new file mode 100644 index 0000000..6c4d35f --- /dev/null +++ b/src/components/Bridge.tsx @@ -0,0 +1,19 @@ +import { useHeaderMotionBridge } from '../hooks/useHeaderMotionBridge'; +import type { ReactNode } from 'react'; +import type { HeaderMotionBridgeValue } from '../types'; + +type HeaderRenderChildren = (value: HeaderMotionBridgeValue) => ReactNode; + +export interface HeaderMotionBridgeProps { + children: HeaderRenderChildren; +} + +export function Bridge({ children }: HeaderMotionBridgeProps) { + if (typeof children !== 'function') { + throw new Error( + 'HeaderMotion.Bridge only accepts a render function as its child.' + ); + } + + return children(useHeaderMotionBridge()); +} diff --git a/src/components/FlatList.tsx b/src/components/FlatList.tsx index c0b3457..fe9d297 100644 --- a/src/components/FlatList.tsx +++ b/src/components/FlatList.tsx @@ -10,7 +10,7 @@ import { export type HeaderMotionFlatListProps = FlatListPropsWithLayout & HeaderMotionScrollableOwnProps>; -type HeaderMotionFlatListComponent = ( +type FlatListComponent = ( props: HeaderMotionFlatListProps ) => ReactElement | null; @@ -31,11 +31,8 @@ type HeaderMotionFlatListComponent = ( * * ``` */ -export const HeaderMotionFlatList = createHeaderMotionScrollable( - Animated.FlatList, - { - displayName: 'HeaderMotion.FlatList', - contentContainerMode: 'renderScrollComponent', - isComponentAnimated: true, - } -) as HeaderMotionFlatListComponent; +export const FlatList = createHeaderMotionScrollable(Animated.FlatList, { + displayName: 'HeaderMotion.FlatList', + contentContainerMode: 'renderScrollComponent', + isComponentAnimated: true, +}) as FlatListComponent; diff --git a/src/components/Header.tsx b/src/components/Header.tsx index 191643e..79ae093 100644 --- a/src/components/Header.tsx +++ b/src/components/Header.tsx @@ -1,22 +1,15 @@ -import { - Fragment, - cloneElement, - isValidElement, - type ReactElement, - type ReactNode, -} from 'react'; -import type { ViewProps } from 'react-native'; +import type { ReactElement } from 'react'; +import { StyleSheet, type ViewProps } from 'react-native'; import Animated, { type AnimatedProps } from 'react-native-reanimated'; -import { HeaderMotionContext, useHeaderMotionContextOrThrow } from '../context'; -import { useHeaderMotionBridge } from '../hooks/useHeaderMotionBridge'; -import type { - HeaderMotionBridgeValue, - HeaderPanDecayConfig, - MotionProgress, -} from '../types'; -import { HeaderPanBoundary, headerOverlayStyle } from './HeaderBase'; - -type HeaderRenderChildren = (value: HeaderMotionBridgeValue) => ReactNode; +import { useHeaderMotionContextOrThrow } from '../context'; +import type { HeaderPanDecayConfig } from '../types'; +import { + cloneWithOnLayout, + composeOnLayoutHandlers, + resolveSlottableChild, +} from '../utils'; +import { HeaderDynamic } from './HeaderDynamic'; +import { HeaderPanBoundary } from './HeaderPanBoundary'; type HeaderPanProps = | { @@ -59,41 +52,20 @@ export type HeaderProps = export type HeaderDynamicProps = HeaderDefaultProps | HeaderAsChildProps; -export interface HeaderMotionBridgeProps { - children: HeaderRenderChildren; -} - -export interface HeaderMotionNavigationBridgeProps { - value: HeaderMotionBridgeValue; - children: ReactNode; -} +const headerOverlayStyle = StyleSheet.create({ + overlay: { + position: 'absolute', + top: 0, + left: 0, + right: 0, + }, +}).overlay; type HeaderComponent = ((props: HeaderProps) => ReactElement | null) & { - Dynamic: typeof HeaderMotionHeaderDynamic; + Dynamic: typeof HeaderDynamic; }; -export function HeaderMotionBridge({ children }: HeaderMotionBridgeProps) { - if (typeof children !== 'function') { - throw new Error( - 'HeaderMotion.Bridge only accepts a render function as its child.' - ); - } - - return children(useHeaderMotionBridge()); -} - -export function HeaderMotionNavigationBridge({ - value, - children, -}: HeaderMotionNavigationBridgeProps) { - return ( - - {children} - - ); -} - -function HeaderMotionHeaderRoot(props: HeaderProps) { +function HeaderRoot(props: HeaderProps) { const ctxValue = useHeaderMotionContextOrThrow( 'HeaderMotion.Header must be used within or . If you are rendering inside a navigation header, bridge the context with and .' ); @@ -151,75 +123,6 @@ function HeaderMotionHeaderRoot(props: HeaderProps) { ); } -export function HeaderMotionHeaderDynamic(props: HeaderDynamicProps) { - const ctxValue = useHeaderMotionContextOrThrow( - 'HeaderMotion.Header.Dynamic must be used within or . If you are rendering inside a navigation header, bridge the context with and .' - ); - - if (props.asChild) { - return cloneWithOnLayout( - resolveSlottableChild('HeaderMotion.Header.Dynamic', props.children), - ctxValue.measureDynamic, - 'HeaderMotion.Header.Dynamic' - ); - } - - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const { asChild: _asChild, onLayout, ...rest } = props; - const resolvedOnLayout = onLayout as ViewProps['onLayout'] | undefined; - return ( - - ); -} - -export const HeaderMotionHeader = Object.assign(HeaderMotionHeaderRoot, { - Dynamic: HeaderMotionHeaderDynamic, +export const Header = Object.assign(HeaderRoot, { + Dynamic: HeaderDynamic, }) as HeaderComponent; - -function composeOnLayoutHandlers( - userHandler: ViewProps['onLayout'], - internalHandler: ViewProps['onLayout'] -) { - return (e: Parameters>[0]) => { - internalHandler?.(e); - userHandler?.(e); - }; -} - -function resolveSlottableChild(componentName: string, child: ReactElement) { - if (!isValidElement(child) || child.type === Fragment) { - throw new Error( - `${componentName} with \`asChild\` expects a single valid React element child that accepts \`onLayout\`.` - ); - } - - return child as ReactElement<{ - onLayout?: ViewProps['onLayout']; - }>; -} - -function cloneWithOnLayout( - child: ReactElement<{ - onLayout?: ViewProps['onLayout']; - }>, - onLayout: ViewProps['onLayout'], - componentName: string -) { - if (!isValidElement(child)) { - throw new Error( - `${componentName} with \`asChild\` expects a valid React element child.` - ); - } - - return cloneElement(child, { - onLayout: composeOnLayoutHandlers(child.props.onLayout, onLayout), - }); -} - -export type { HeaderMotionBridgeValue, MotionProgress }; diff --git a/src/components/HeaderDynamic.tsx b/src/components/HeaderDynamic.tsx new file mode 100644 index 0000000..311e310 --- /dev/null +++ b/src/components/HeaderDynamic.tsx @@ -0,0 +1,37 @@ +import type { ViewProps } from 'react-native'; +import Animated from 'react-native-reanimated'; +import { useHeaderMotionContextOrThrow } from '../context'; +import type { HeaderDynamicProps } from './Header'; +import { + cloneWithOnLayout, + composeOnLayoutHandlers, + resolveSlottableChild, +} from '../utils'; + +export function HeaderDynamic(props: HeaderDynamicProps) { + const ctxValue = useHeaderMotionContextOrThrow( + 'HeaderMotion.Header.Dynamic must be used within or . If you are rendering inside a navigation header, bridge the context with and .' + ); + + if (props.asChild) { + return cloneWithOnLayout( + resolveSlottableChild('HeaderMotion.Header.Dynamic', props.children), + ctxValue.measureDynamic, + 'HeaderMotion.Header.Dynamic' + ); + } + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { asChild: _asChild, onLayout, ...rest } = props; + const resolvedOnLayout = onLayout as ViewProps['onLayout'] | undefined; + + return ( + + ); +} diff --git a/src/components/HeaderBase.tsx b/src/components/HeaderPanBoundary.tsx similarity index 87% rename from src/components/HeaderBase.tsx rename to src/components/HeaderPanBoundary.tsx index 876c0c5..98c46ab 100644 --- a/src/components/HeaderBase.tsx +++ b/src/components/HeaderPanBoundary.tsx @@ -1,5 +1,5 @@ import { useMemo, type ReactElement } from 'react'; -import { Platform, StyleSheet } from 'react-native'; +import { Platform } from 'react-native'; import { Gesture, GestureDetector, @@ -27,15 +27,6 @@ type HeaderPanBoundaryProps = Pick< withGestureHandlerRootView?: boolean; }; -export const headerOverlayStyle = StyleSheet.create({ - overlay: { - position: 'absolute', - top: 0, - left: 0, - right: 0, - }, -}).overlay; - export function HeaderPanBoundary({ children, pannable = false, @@ -65,7 +56,7 @@ export function HeaderPanBoundary({ scrollToRef.current?.(dy); }) .onEnd((e) => { - const resolvedConfig = resolveHeaderPanDecayConfig(panDecayConfig, e); + const resolvedConfig = resolvePanDecayConfig(panDecayConfig, e); headerPanMomentumOffset.set( withDecay(resolvedConfig, () => headerPanMomentumOffset.set(null)) ); @@ -83,7 +74,7 @@ export function HeaderPanBoundary({ return {content}; } -function resolveHeaderPanDecayConfig( +function resolvePanDecayConfig( panDecayConfig: HeaderPanDecayConfig | undefined, event: HeaderPanDecayEvent ) { diff --git a/src/components/NavigationBridge.tsx b/src/components/NavigationBridge.tsx new file mode 100644 index 0000000..36fd32a --- /dev/null +++ b/src/components/NavigationBridge.tsx @@ -0,0 +1,19 @@ +import type { ReactNode } from 'react'; +import { HeaderMotionContext } from '../context'; +import type { HeaderMotionBridgeValue } from '../types'; + +export interface HeaderMotionNavigationBridgeProps { + value: HeaderMotionBridgeValue; + children: ReactNode; +} + +export function NavigationBridge({ + value, + children, +}: HeaderMotionNavigationBridgeProps) { + return ( + + {children} + + ); +} diff --git a/src/components/ScrollManager.tsx b/src/components/ScrollManager.tsx index a45ee8b..50e2463 100644 --- a/src/components/ScrollManager.tsx +++ b/src/components/ScrollManager.tsx @@ -48,9 +48,7 @@ export interface HeaderMotionScrollManagerProps< * * ``` */ -export function HeaderMotionScrollManager< - TRef extends InstanceOrElement = any ->({ +export function ScrollManager({ children, scrollId, animatedRef, diff --git a/src/components/ScrollView.tsx b/src/components/ScrollView.tsx index be11588..748f7f8 100644 --- a/src/components/ScrollView.tsx +++ b/src/components/ScrollView.tsx @@ -28,11 +28,8 @@ type HeaderMotionScrollViewComponent = ( * * ``` */ -export const HeaderMotionScrollView = createHeaderMotionScrollable( - Animated.ScrollView, - { - displayName: 'HeaderMotion.ScrollView', - contentContainerMode: 'children', - isComponentAnimated: true, - } -) as HeaderMotionScrollViewComponent; +export const ScrollView = createHeaderMotionScrollable(Animated.ScrollView, { + displayName: 'HeaderMotion.ScrollView', + contentContainerMode: 'children', + isComponentAnimated: true, +}) as HeaderMotionScrollViewComponent; diff --git a/src/components/__tests__/FlatList.test.tsx b/src/components/__tests__/FlatList.test.tsx index dee4f95..4550c7e 100644 --- a/src/components/__tests__/FlatList.test.tsx +++ b/src/components/__tests__/FlatList.test.tsx @@ -19,9 +19,9 @@ jest.mock('../createHeaderMotionScrollable', () => { }); import { createHeaderMotionScrollable } from '../createHeaderMotionScrollable'; -import { HeaderMotionFlatList } from '../FlatList'; +import { FlatList } from '../FlatList'; -describe('HeaderMotionFlatList', () => { +describe('FlatList', () => { it('creates the built-in wrapper from the shared factory', () => { expect(createHeaderMotionScrollable as jest.Mock).toHaveBeenCalledWith( expect.any(Function), @@ -35,7 +35,7 @@ describe('HeaderMotionFlatList', () => { it('passes header motion props through to the generated component', () => { const animatedRef = { current: null } as any; - const element = HeaderMotionFlatList<{ id: string; label: string }>({ + const element = FlatList<{ id: string; label: string }>({ data: [{ id: '1', label: 'Item 1' }], keyExtractor: (item: { id: string }) => item.id, renderItem: ({ item }: { item: { id: string; label: string } }) => diff --git a/src/components/__tests__/Header.test.tsx b/src/components/__tests__/Header.test.tsx index f02c399..e86444f 100644 --- a/src/components/__tests__/Header.test.tsx +++ b/src/components/__tests__/Header.test.tsx @@ -52,13 +52,11 @@ jest.mock('react-native-gesture-handler', () => { }); import React from 'react'; -import { - HeaderMotionBridge, - HeaderMotionHeader, - HeaderMotionHeaderDynamic, - HeaderMotionNavigationBridge, -} from '../Header'; -import { HeaderPanBoundary, headerOverlayStyle } from '../HeaderBase'; +import { Bridge } from '../Bridge'; +import { Header } from '../Header'; +import { HeaderDynamic } from '../HeaderDynamic'; +import { NavigationBridge } from '../NavigationBridge'; +import { HeaderPanBoundary } from '../HeaderPanBoundary'; import { HeaderMotionContext } from '../../context'; function createSharedValue(value: T) { @@ -92,6 +90,13 @@ const layoutEvent = { }, } as any; +const headerOverlayStyle = { + position: 'absolute', + top: 0, + left: 0, + right: 0, +}; + describe('Header components', () => { beforeEach(() => { mockUseHeaderMotionContextOrThrow.mockReset(); @@ -104,7 +109,7 @@ describe('Header components', () => { it('HeaderMotion.Bridge requires a render function child', () => { expect(() => - HeaderMotionBridge({ + Bridge({ children: 'invalid' as any, }) ).toThrow( @@ -116,13 +121,13 @@ describe('Header components', () => { const children = jest.fn(() => 'ok'); mockUseHeaderMotionBridge.mockReturnValue(bridgeValue); - expect(HeaderMotionBridge({ children })).toBe('ok'); + expect(Bridge({ children })).toBe('ok'); expect(children).toHaveBeenCalledWith(bridgeValue); }); it('HeaderMotion.NavigationBridge returns the main context provider', () => { const child = React.createElement('Child'); - const element = HeaderMotionNavigationBridge({ + const element = NavigationBridge({ value: bridgeValue, children: child, }) as React.ReactElement; @@ -136,7 +141,7 @@ describe('Header components', () => { const userOnLayout = jest.fn(); mockUseHeaderMotionContextOrThrow.mockReturnValue(bridgeValue); - const element = HeaderMotionHeader({ + const element = Header({ onLayout: userOnLayout, style: { opacity: 0.5 }, children: React.createElement('Child'), @@ -159,7 +164,7 @@ describe('Header components', () => { it('HeaderMotion.Header omits overlay styles when overlay is false', () => { mockUseHeaderMotionContextOrThrow.mockReturnValue(bridgeValue); - const element = HeaderMotionHeader({ + const element = Header({ overlay: false, style: { opacity: 1 }, children: React.createElement('Child'), @@ -175,7 +180,7 @@ describe('Header components', () => { const childOnLayout = jest.fn(); mockUseHeaderMotionContextOrThrow.mockReturnValue(bridgeValue); - const element = HeaderMotionHeader({ + const element = Header({ asChild: true, children: React.createElement('Child', { onLayout: childOnLayout }), }) as React.ReactElement; @@ -190,7 +195,7 @@ describe('Header components', () => { const panDecayConfig = { deceleration: 0.99 }; mockUseHeaderMotionContextOrThrow.mockReturnValue(bridgeValue); - const element = HeaderMotionHeader({ + const element = Header({ pannable: true, panDecayConfig, children: React.createElement('Child'), @@ -204,7 +209,7 @@ describe('Header components', () => { mockUseHeaderMotionContextOrThrow.mockReturnValue(bridgeValue); expect(() => - HeaderMotionHeader({ + Header({ asChild: true, children: React.createElement(React.Fragment, null), }) @@ -217,7 +222,7 @@ describe('Header components', () => { const userOnLayout = jest.fn(); mockUseHeaderMotionContextOrThrow.mockReturnValue(bridgeValue); - const element = HeaderMotionHeaderDynamic({ + const element = HeaderDynamic({ onLayout: userOnLayout, children: React.createElement('Child'), } as any) as React.ReactElement; @@ -231,7 +236,7 @@ describe('Header components', () => { const childOnLayout = jest.fn(); mockUseHeaderMotionContextOrThrow.mockReturnValue(bridgeValue); - const element = HeaderMotionHeaderDynamic({ + const element = HeaderDynamic({ asChild: true, children: React.createElement('Child', { onLayout: childOnLayout }), }) as React.ReactElement; diff --git a/src/components/__tests__/ScrollView.test.tsx b/src/components/__tests__/ScrollView.test.tsx index 46ef47f..4f1e3fa 100644 --- a/src/components/__tests__/ScrollView.test.tsx +++ b/src/components/__tests__/ScrollView.test.tsx @@ -20,9 +20,9 @@ jest.mock('../createHeaderMotionScrollable', () => { }); import { createHeaderMotionScrollable } from '../createHeaderMotionScrollable'; -import { HeaderMotionScrollView } from '../ScrollView'; +import { ScrollView } from '../ScrollView'; -describe('HeaderMotionScrollView', () => { +describe('ScrollView', () => { it('creates the built-in wrapper from the shared factory', () => { expect(createHeaderMotionScrollable as jest.Mock).toHaveBeenCalledWith( expect.any(Function), @@ -36,7 +36,7 @@ describe('HeaderMotionScrollView', () => { it('passes header motion props through to the generated component', () => { const animatedRef = { current: null } as any; - const element = HeaderMotionScrollView({ + const element = ScrollView({ animatedRef, headerOffsetStrategy: 'margin', ensureScrollableContentMinHeight: false, diff --git a/src/components/index.ts b/src/components/index.ts index f9a4b00..dc87f24 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -1,5 +1,7 @@ +export * from './Bridge'; export * from './FlatList'; export * from './Header'; +export * from './NavigationBridge'; export * from './HeaderMotion'; export * from './ScrollManager'; export * from './ScrollView'; diff --git a/src/index.ts b/src/index.ts index 8ad886c..e6c4484 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,16 +1,16 @@ import { createHeaderMotionScrollable, - HeaderMotionBridge, + Bridge, HeaderMotionContextProvider, - HeaderMotionFlatList, - HeaderMotionHeader, - HeaderMotionNavigationBridge, - HeaderMotionScrollManager, - HeaderMotionScrollView, + FlatList, + Header, + NavigationBridge, + ScrollManager, + ScrollView, type CreateHeaderMotionScrollableOptions, type HeaderDynamicProps, - type HeaderMotionFlatListProps, type HeaderMotionBridgeProps, + type HeaderMotionFlatListProps, type HeaderMotionNavigationBridgeProps, type HeaderMotionProps, type HeaderMotionScrollManagerProps, @@ -18,30 +18,6 @@ import { type HeaderMotionScrollViewProps, type HeaderProps, } from './components'; -import type { ReactElement } from 'react'; - -/** - * Compound component type for HeaderMotion. - * Provides the main context provider and sub-components for building collapsible headers. - */ -type HeaderMotionComponent = { - /** Main context provider component */ - (props: HeaderMotionProps): ReactElement; - /** Header container component with built-in measurement wiring. */ - Header: typeof HeaderMotionHeader; - /** Render-prop bridge for navigation-installed headers. */ - Bridge: typeof HeaderMotionBridge; - /** Context provider for navigation-installed headers. */ - NavigationBridge: typeof HeaderMotionNavigationBridge; - /** Component for custom scroll implementations. - * Use when you want render-prop composition instead of calling {@link useScrollManager} directly. - */ - ScrollManager: typeof HeaderMotionScrollManager; - /** Animated ScrollView component with header motion integration */ - ScrollView: typeof HeaderMotionScrollView; - /** Animated FlatList component with header motion integration */ - FlatList: typeof HeaderMotionFlatList; -}; /** * Main HeaderMotion component. @@ -69,23 +45,20 @@ type HeaderMotionComponent = { * * ``` */ -const HeaderMotion = HeaderMotionContextProvider as HeaderMotionComponent; -HeaderMotion.Header = HeaderMotionHeader; -HeaderMotion.Bridge = HeaderMotionBridge; -HeaderMotion.NavigationBridge = HeaderMotionNavigationBridge; -HeaderMotion.ScrollManager = HeaderMotionScrollManager; -HeaderMotion.ScrollView = HeaderMotionScrollView; -HeaderMotion.FlatList = HeaderMotionFlatList; +const HeaderMotion = Object.assign(HeaderMotionContextProvider, { + Header, + Bridge, + NavigationBridge, + ScrollManager, + ScrollView, + FlatList, +}); export default HeaderMotion; export * from './hooks'; export type * from './types'; export { createHeaderMotionScrollable }; -export { - HeaderMotionBridge as Bridge, - HeaderMotionHeader as Header, - HeaderMotionNavigationBridge as NavigationBridge, -}; +export { Bridge, Header, NavigationBridge }; export type { CreateHeaderMotionScrollableOptions, HeaderDynamicProps, diff --git a/src/utils/header.tsx b/src/utils/header.tsx new file mode 100644 index 0000000..c8759b5 --- /dev/null +++ b/src/utils/header.tsx @@ -0,0 +1,50 @@ +import { + Fragment, + cloneElement, + isValidElement, + type ReactElement, +} from 'react'; +import type { ViewProps } from 'react-native'; + +export function composeOnLayoutHandlers( + userHandler: ViewProps['onLayout'], + internalHandler: ViewProps['onLayout'] +) { + return (e: Parameters>[0]) => { + internalHandler?.(e); + userHandler?.(e); + }; +} + +export function resolveSlottableChild( + componentName: string, + child: ReactElement +) { + if (!isValidElement(child) || child.type === Fragment) { + throw new Error( + `${componentName} with \`asChild\` expects a single valid React element child that accepts \`onLayout\`.` + ); + } + + return child as ReactElement<{ + onLayout?: ViewProps['onLayout']; + }>; +} + +export function cloneWithOnLayout( + child: ReactElement<{ + onLayout?: ViewProps['onLayout']; + }>, + onLayout: ViewProps['onLayout'], + componentName: string +) { + if (!isValidElement(child)) { + throw new Error( + `${componentName} with \`asChild\` expects a valid React element child.` + ); + } + + return cloneElement(child, { + onLayout: composeOnLayoutHandlers(child.props.onLayout, onLayout), + }); +} diff --git a/src/utils/index.ts b/src/utils/index.ts index cf8d3d8..17c7ddf 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -1,4 +1,5 @@ export * from './defaults'; +export * from './header'; export * from './headerOffsetStyle'; export * from './values'; export * from './refreshControl'; From dea8ed09a90e8bad343ab8bf0a50d793fa732103 Mon Sep 17 00:00:00 2001 From: Oskar Pawica Date: Sun, 29 Mar 2026 11:42:40 +0200 Subject: [PATCH 5/8] chore: better JS Doc --- src/components/Bridge.tsx | 10 +++ src/components/Header.tsx | 58 +++++++++++++++- src/components/HeaderDynamic.tsx | 8 +++ src/components/HeaderMotion.tsx | 57 ++++++++++------ src/components/NavigationBridge.tsx | 11 +++ src/components/ScrollManager.tsx | 23 ++++--- .../createHeaderMotionScrollable.tsx | 14 ++-- src/hooks/useActiveScrollId.ts | 13 ++-- src/hooks/useHeaderMotionBridge.ts | 8 ++- src/hooks/useMotionProgress.ts | 15 ++--- src/hooks/useScrollManager.ts | 63 ++++++++++------- src/index.ts | 67 ++++++++++++++++--- src/types.ts | 19 ++++-- 13 files changed, 271 insertions(+), 95 deletions(-) diff --git a/src/components/Bridge.tsx b/src/components/Bridge.tsx index 6c4d35f..6361fba 100644 --- a/src/components/Bridge.tsx +++ b/src/components/Bridge.tsx @@ -5,9 +5,19 @@ import type { HeaderMotionBridgeValue } from '../types'; type HeaderRenderChildren = (value: HeaderMotionBridgeValue) => ReactNode; export interface HeaderMotionBridgeProps { + /** + * Render function that receives the current HeaderMotion context value. + * + * Use this when you need to pass the library's context across a React tree + * boundary, most commonly into a navigation-rendered header. + */ children: HeaderRenderChildren; } +/** + * Reads the current HeaderMotion context and exposes it through a render + * function so it can be forwarded into another subtree. + */ export function Bridge({ children }: HeaderMotionBridgeProps) { if (typeof children !== 'function') { throw new Error( diff --git a/src/components/Header.tsx b/src/components/Header.tsx index 79ae093..38aabfa 100644 --- a/src/components/Header.tsx +++ b/src/components/Header.tsx @@ -13,15 +13,23 @@ import { HeaderPanBoundary } from './HeaderPanBoundary'; type HeaderPanProps = | { - /** Enables panning directly on the header surface. + /** Enables dragging the header itself to scroll the active scrollable. + * + * This is useful when the header covers a large portion of the screen + * and you want the gesture to feel continuous between header and content. + * * @default false */ pannable: true; /** - * Custom momentum config used after a header pan ends. + * Customizes the momentum animation that runs after a header pan ends. + * + * Use an object for a fixed decay profile. Use a function when the decay + * should depend on the end event, for example to dampen or amplify + * certain velocities. * * If you provide a function, it runs inside the gesture end worklet and - * **must itself be a worklet-safe function**. + * **must itself be marked with the 'worklet' directive.**. */ panDecayConfig?: HeaderPanDecayConfig; } @@ -42,11 +50,38 @@ type HeaderDefaultProps = AnimatedProps & { export type HeaderProps = | (HeaderDefaultProps & HeaderPanProps & { + /** + * Applies the default absolute-positioned header layout. + * + * Leave this enabled for navigation headers and any header that should + * visually float above the scrollable content. Disable it only when you + * intentionally want the header to participate in normal layout flow. + * + * @default true + */ overlay?: boolean; + /** + * Wraps the pan gesture in `GestureHandlerRootView`. + * + * Only use this when the rendered header subtree is not already under a + * gesture-handler root. + * + * @default + * false + */ withGestureHandlerRootView?: boolean; }) | (HeaderAsChildProps & HeaderPanProps & { + /** + * Wraps the pan gesture in `GestureHandlerRootView`. + * + * Only use this when the rendered header subtree is not already under a + * gesture-handler root. + * + * @default + * false + */ withGestureHandlerRootView?: boolean; }); @@ -62,6 +97,14 @@ const headerOverlayStyle = StyleSheet.create({ }).overlay; type HeaderComponent = ((props: HeaderProps) => ReactElement | null) & { + /** + * Marks the part of the header whose measured layout should define the + * collapsible distance. + * + * In most designs, this is the section that visually disappears while the + * header collapses. Its measured value feeds `measureDynamic`, which can in + * turn drive `progressThreshold`. + */ Dynamic: typeof HeaderDynamic; }; @@ -123,6 +166,15 @@ function HeaderRoot(props: HeaderProps) { ); } +/** + * Header container that measures the total header height for scroll offsetting. + * + * It renders an `Animated.View` by default, wires the outer header measurement + * automatically, and can optionally make the header surface pannable. + * + * Pair it with `Header.Dynamic` to mark the part of the header that should + * drive the collapse threshold. + */ export const Header = Object.assign(HeaderRoot, { Dynamic: HeaderDynamic, }) as HeaderComponent; diff --git a/src/components/HeaderDynamic.tsx b/src/components/HeaderDynamic.tsx index 311e310..a0960b1 100644 --- a/src/components/HeaderDynamic.tsx +++ b/src/components/HeaderDynamic.tsx @@ -8,6 +8,14 @@ import { resolveSlottableChild, } from '../utils'; +/** + * Marks the part of the header whose layout should define the collapsible + * distance. + * + * In most designs, this is the section that visually disappears while the + * header collapses. Its measured value feeds `measureDynamic`, which in turn + * can drive `progressThreshold`. + */ export function HeaderDynamic(props: HeaderDynamicProps) { const ctxValue = useHeaderMotionContextOrThrow( 'HeaderMotion.Header.Dynamic must be used within or . If you are rendering inside a navigation header, bridge the context with and .' diff --git a/src/components/HeaderMotion.tsx b/src/components/HeaderMotion.tsx index 3788c38..f14ffda 100644 --- a/src/components/HeaderMotion.tsx +++ b/src/components/HeaderMotion.tsx @@ -52,51 +52,68 @@ const resolveScrollIdForProgress = ( export interface HeaderMotionProps { /** - * The threshold at which the header animation completes (reaches progress = 1). - * Can be a fixed number or a function that calculates based on the result of {@link measureDynamic}. + * Distance that maps the active scrollable from `progress = 0` + * to `progress = 1`. * - * Defaults to a function that returns the return value of `measureDynamic` unchanged. + * Use a number when the collapse distance is fixed. Use a function when the + * distance should depend on what `measureDynamic` reads from + * `HeaderMotion.Header.Dynamic`. + * + * A common pattern is to measure the height of the part of the header that + * should disappear and use that as the threshold. */ progressThreshold?: ProgressThreshold; /** - * Function to measure a dimension of choice of the animated element of the header. - * - * Receives the layout change event from React Native. + * Reads the value that should define the "collapsible" part of the header. * - * This function is used internally by `HeaderMotion.Header.Dynamic`. - * It is also available through `HeaderMotion.Bridge`, `HeaderMotion.NavigationBridge`, - * and `useHeaderMotionBridge()` for advanced integrations. + * This is called from `HeaderMotion.Header.Dynamic` on layout. The returned + * number feeds `progressThreshold` when you provide that prop as a function. * - * Defaults to measuring the height from the event. + * By default, the library measures the dynamic section's height. Override + * this when the collapse distance should be based on something else, for + * example width or a derived value from the layout event. */ measureDynamic?: MeasureAnimatedHeader; /** - * Mode for measuring dynamic header height. + * Controls when `measureDynamic` is allowed to update. + * * - 'mount': Only measure once on mount - * - 'update': Update measurement on every layout recalculation of the component that {@link measureDynamic} was provided to as the `onLayout` property + * - 'update': Re-measure whenever `HeaderMotion.Header.Dynamic` lays out again + * + * Use `'mount'` for stable headers. Use `'update'` when the dynamic section + * can change size after mount, for example after async data loads or content + * expansion. + * * @default 'mount' */ measureDynamicMode?: 'update' | 'mount'; /** - * Shared value for tracking the active scroll ID in multi-scroll scenarios (e.g. tabs). - * When provided, the header animation will sync across multiple scroll views. + * Shared value that tells HeaderMotion which scrollable currently owns the + * header progress in multi-scroll setups. + * + * Pass this when one header is shared across multiple scrollables, such as + * tabs or pager pages. Each scrollable should also get its own `scrollId`. */ activeScrollId?: SharedValue; /** - * Extrapolation type for the progress animation. - * Controls how the progress value behaves outside the threshold range. + * Controls how `progress` behaves outside the `[0, threshold]` range. + * + * The default clamps the value between `0` and `1`. Relax this if you want + * to animate overscroll or other out-of-range states. * - * You may want to modify it to achieve some animations for the overscroll scenarios. * @default Extrapolation.CLAMP */ progressExtrapolation?: ExtrapolationType; - /** Child components that will have access to the header motion context */ + /** Descendants that should participate in the shared header-motion state. */ children: ReactNode; } /** - * Context provider component for HeaderMotion. - * Manages header animation state and provides it to child components via context. + * Root provider for a header-motion setup. + * + * It tracks the measured header layout, the active scroll position, and the + * derived `progress` shared value consumed by your animated header UI. + * * @template T - The type of scroll ID string */ function HeaderMotionContextProvider({ diff --git a/src/components/NavigationBridge.tsx b/src/components/NavigationBridge.tsx index 36fd32a..5d7fd9f 100644 --- a/src/components/NavigationBridge.tsx +++ b/src/components/NavigationBridge.tsx @@ -3,10 +3,21 @@ import { HeaderMotionContext } from '../context'; import type { HeaderMotionBridgeValue } from '../types'; export interface HeaderMotionNavigationBridgeProps { + /** + * Previously captured HeaderMotion context value to re-provide in another + * subtree. + */ value: HeaderMotionBridgeValue; + /** Subtree that should regain access to HeaderMotion context. */ children: ReactNode; } +/** + * Re-provides HeaderMotion context in a different part of the React tree. + * + * This is primarily useful for navigation libraries that render headers outside + * the screen subtree where `HeaderMotion` itself lives. + */ export function NavigationBridge({ value, children, diff --git a/src/components/ScrollManager.tsx b/src/components/ScrollManager.tsx index 50e2463..86b6252 100644 --- a/src/components/ScrollManager.tsx +++ b/src/components/ScrollManager.tsx @@ -12,26 +12,27 @@ export interface HeaderMotionScrollManagerProps< TRef extends InstanceOrElement = any > extends UseScrollManagerOptions { /** - * Optional unique identifier for this scroll view. - * Use this when you have multiple scroll views (e.g., in tabs) to track them separately. + * Unique identifier for this scrollable in multi-scroll setups. + * + * Omit it for single-scroll screens. */ scrollId?: string; /** - * Render function that receives scroll props and header context. - * Use this to create custom scroll implementations that integrate with HeaderMotion. + * Render function that receives: + * - the props to spread onto your scrollable + * - the layout values needed to offset content below the header */ children: ScrollManagerRenderChildren; } /** - * ScrollManager component that provides scroll tracking functionality for - * custom scroll implementations. Uses {@link useScrollManager} under the hood. - * Must be used within a HeaderMotion component. + * Render-prop wrapper around `useScrollManager()`. * - * This is useful when you need to use a scroll component that isn't directly supported - * (like a custom scroll view or third-party list components). - * If you would rather compose the same functionality in a hook-based API, - * use {@link useScrollManager} directly. + * **Most code should prefer `createHeaderMotionScrollable()` instead.** + * + * Use `ScrollManager` only when the factory approach is not enough and you + * still need HeaderMotion to manage a custom scrollable through render-prop + * composition. * * @example * ```tsx diff --git a/src/components/createHeaderMotionScrollable.tsx b/src/components/createHeaderMotionScrollable.tsx index 6cff026..72e50ee 100644 --- a/src/components/createHeaderMotionScrollable.tsx +++ b/src/components/createHeaderMotionScrollable.tsx @@ -23,13 +23,12 @@ export type HeaderMotionScrollableOwnProps< TRef extends InstanceOrElement = any > = HeaderMotionOffsetProps & { /** - * Optional unique identifier for this scroll view. - * Use this when you have multiple scroll views (e.g. in tabs) to track them separately. + * Unique identifier for this scrollable when one header is shared across + * multiple scrollables. */ scrollId?: string; /** - * Optional animated ref to use for the scroll view. - * When provided, the scroll manager will use this ref instead of creating its own. + * Animated ref to reuse instead of letting HeaderMotion create one. */ animatedRef?: AnimatedRef | AnimatedRef; }; @@ -47,11 +46,14 @@ export interface CreateHeaderMotionScrollableOptions< */ isComponentAnimated?: TIsComponentAnimated; /** - * Strategy used to apply header spacing and min-height handling. + * Controls how HeaderMotion injects content-container spacing. + * * - `children`: wraps `children` in an inner `Animated.View` * - `renderScrollComponent`: injects a custom scroll component that wraps the content * - * Use `renderScrollComponent` for FlatList-like implementations. + * Use `children` for ScrollView-like components. Use + * `renderScrollComponent` for FlatList-like components that own their + * internal scroll container. * * @default 'renderScrollComponent' */ diff --git a/src/hooks/useActiveScrollId.ts b/src/hooks/useActiveScrollId.ts index a5f18a1..a4a48f0 100644 --- a/src/hooks/useActiveScrollId.ts +++ b/src/hooks/useActiveScrollId.ts @@ -3,17 +3,18 @@ import { useSharedValue } from 'react-native-reanimated'; import type { ActiveScrollIdValues, SetActiveScrollId } from '../types'; /** - * Hook to manage active scroll ID for multi-scroll scenarios (e.g. tabs with different scroll views). - * Returns both a state value and a shared value, along with a setter function. + * Keeps a React state value and a shared value in sync for the currently active + * scrollable. * - * Use this when you have multiple scroll views (like in a tabbed interface) and need to - * track which one is currently active. Pass the shared value to `HeaderMotion`'s `activeScrollId` prop. + * Use this when one header is shared across multiple scroll views, for example + * pager pages or tabs. Pass `values.sv` to `HeaderMotion` and use the setter + * whenever the active page changes. * * @template T - The type of the scroll ID string * @param initialActiveScrollId - The initial active scroll ID * @returns A tuple containing: - * - `[0]`: Object with `state` (React state) and `sv` (shared value) for the active scroll ID - * - `[1]`: Function to set the active scroll ID + * - an object with both the React `state` and shared-value `sv` + * - a setter that updates both in lockstep * * @example * ```tsx diff --git a/src/hooks/useHeaderMotionBridge.ts b/src/hooks/useHeaderMotionBridge.ts index cd525bc..e578563 100644 --- a/src/hooks/useHeaderMotionBridge.ts +++ b/src/hooks/useHeaderMotionBridge.ts @@ -2,9 +2,11 @@ import { useHeaderMotionContextOrThrow } from '../context'; import type { HeaderMotionBridgeValue } from '../types'; /** - * Hook to access the full HeaderMotion bridge value. - * Use this only when you need to bridge header motion context into another - * part of the tree, such as a navigation-rendered header. + * Returns the full internal HeaderMotion context value. + * + * Most app code should use `useMotionProgress()` instead. Reach for this hook + * only when you need to carry HeaderMotion context across a tree boundary and + * re-provide it somewhere else. */ export function useHeaderMotionBridge(): HeaderMotionBridgeValue { return useHeaderMotionContextOrThrow( diff --git a/src/hooks/useMotionProgress.ts b/src/hooks/useMotionProgress.ts index 63691ca..a3ea8d8 100644 --- a/src/hooks/useMotionProgress.ts +++ b/src/hooks/useMotionProgress.ts @@ -2,16 +2,15 @@ import { useHeaderMotionContextOrThrow } from '../context'; import type { MotionProgress } from '../types'; /** - * Hook to access motion progress values for header animations. - * Returns the progress value (0-1) and threshold. + * Returns the two shared values most header animations actually need: + * `progress` and `progressThreshold`. * - * Must be used within a {@link HeaderMotion} component. + * Use this inside your animated header components to derive transforms, + * opacity, scale, parallax, or any other visual response to scroll. * - * @returns Motion progress values: - * - `progress`: Shared value from 0 to 1 - * - `progressThreshold`: The threshold at which animation completes - * - * @throws Error if used outside of a {@link HeaderMotion} component + * `progress` usually lives in the `0..1` range, where `0` is the expanded + * state and `1` is the fully collapsed state. `progressThreshold` is the pixel + * distance that corresponds to that transition. * * @example * ```tsx diff --git a/src/hooks/useScrollManager.ts b/src/hooks/useScrollManager.ts index d3b97a1..cbc618c 100644 --- a/src/hooks/useScrollManager.ts +++ b/src/hooks/useScrollManager.ts @@ -291,50 +291,65 @@ export interface UseScrollManagerOptions extends Omit, ConsumerScrollEventHandlers { /** - * Optional animated ref to use instead of creating one internally. - * Useful when you need access to the scroll view ref from outside. + * Animated ref for the managed scrollable. + * + * Provide this when the caller also needs imperative access to the same + * scrollable instance. Otherwise the hook creates one internally. */ animatedRef?: AnimatedRef; /** - * Optional refresh progress offset override. - * When provided, it takes precedence over the automatic offset based on header height. + * Overrides the refresh indicator offset. + * + * By default, HeaderMotion derives this from the measured header height so + * pull-to-refresh starts below the header. Override it only when you need a + * custom refresh placement. */ progressViewOffset?: ResolveRefreshControlOptions['progressViewOffset']; /** - * Experimental: opt-in fallback for short content that cannot scroll far enough - * to fully collapse the header. + * Ensures short content can still scroll far enough to fully collapse the + * header. + * + * **Experimental: this relies on extra layout measurement and may still be + * refined.** + * + * Enable this when your content is sometimes shorter than the viewport and + * you still want the header to reach the collapsed state. */ ensureScrollableContentMinHeight?: boolean; } /** - * Manages scroll tracking, synchronization, and scrollable wiring for a - * collapsible header. + * Wires a custom scrollable into HeaderMotion. * - * Use this hook inside `HeaderMotion` when integrating a custom scrollable - * component instead of one of the built-in `HeaderMotion.*` wrappers. - * If you prefer the same functionality from a render function instead of - * calling a hook directly, use {@link HeaderMotionScrollManager}. + * Most code should not use this hook directly. * - * Responsibilities: - * - tracks the active scroll position used to drive header progress - * - synchronizes inactive scrollables in multi-scroll setups - * - wires refresh-related props through the library's refresh-control helper - * - optionally computes a plain `contentContainerMinHeight` fallback for short - * content when `ensureScrollableContentMinHeight` is enabled + * **Prefer `createHeaderMotionScrollable()` whenever possible.** It gives + * you the same integration in a reusable component wrapper with less manual + * wiring. Reach for `useScrollManager()` only in more complex cases where the + * factory API is not enough, for example when a third-party scrollable needs + * highly custom composition. * - * @param scrollId Optional unique identifier for the related scrollable. - * Use this when tracking multiple scrollables, for example inside tabs. - * @param options Optional configuration for refs, refresh handling, consumer - * scroll callbacks, and the experimental short-content min-height fallback. + * It returns two things: + * - `scrollableProps`: the event handlers / ref / refresh-control props that + * should go on the scrollable itself + * - `headerMotionContext`: layout values you can use to offset the content + * below the measured header + * + * In multi-scroll setups, pass a unique `scrollId` for each scrollable. + * In single-scroll setups, you usually do not need one. + * + * If you need the same fallback behavior but prefer render-prop composition + * over a hook, use `HeaderMotion.ScrollManager`. + * + * @param scrollId Optional unique identifier for the managed scrollable. + * @param options Optional configuration for refs, refresh handling, user + * scroll callbacks, and short-content fallback behavior. * @returns Object containing: * - `scrollableProps`: props to spread onto the scrollable (`ref`, managed * `onScroll`, optional `onLayout`, and resolved `refreshControl`) * - `headerMotionContext`: layout values for offsetting the content container * (`originalHeaderHeight` and optional `contentContainerMinHeight`) * - * @throws Error when used outside of a `HeaderMotion` provider. - * * @example * ```tsx * function CustomScrollComponent() { diff --git a/src/index.ts b/src/index.ts index e6c4484..70ce790 100644 --- a/src/index.ts +++ b/src/index.ts @@ -19,9 +19,55 @@ import { type HeaderProps, } from './components'; +type HeaderMotionCompound = typeof HeaderMotionContextProvider & { + /** + * Header container that measures the total header height and can optionally + * make the header surface pannable. + * + * Use `HeaderMotion.Header.Dynamic` inside it to mark the part of the header + * that should define the collapse distance. + */ + Header: typeof Header; + /** + * Captures the current HeaderMotion context and exposes it through a render + * function so it can be forwarded across a React tree boundary. + * + * This is primarily useful for navigation-rendered headers. + */ + Bridge: typeof Bridge; + /** + * Re-provides a previously captured HeaderMotion context value in another + * subtree. + * + * This is primarily useful for navigation libraries that render headers + * outside the screen subtree where `HeaderMotion` lives. + */ + NavigationBridge: typeof NavigationBridge; + /** + * Render-prop wrapper for managing a custom scrollable. + * + * Prefer `createHeaderMotionScrollable()` for most custom integrations. Use + * `ScrollManager` only when the factory approach is not flexible enough. + */ + ScrollManager: typeof ScrollManager; + /** + * Pre-wired `Animated.ScrollView` that participates in HeaderMotion's scroll + * tracking and header offsetting. + */ + ScrollView: typeof ScrollView; + /** + * Pre-wired `Animated.FlatList` that participates in HeaderMotion's scroll + * tracking and header offsetting. + */ + FlatList: typeof FlatList; +}; + /** * Main HeaderMotion component. - * A compound component that provides context for collapsible header animations. + * Root provider and compound entrypoint for the library. + * + * It tracks header measurements, derives the shared `progress` value, and + * exposes the pre-wired subcomponents used to connect headers and scrollables. * * @example * ```tsx @@ -45,14 +91,17 @@ import { * * ``` */ -const HeaderMotion = Object.assign(HeaderMotionContextProvider, { - Header, - Bridge, - NavigationBridge, - ScrollManager, - ScrollView, - FlatList, -}); +const HeaderMotion: HeaderMotionCompound = Object.assign( + HeaderMotionContextProvider, + { + Header, + Bridge, + NavigationBridge, + ScrollManager, + ScrollView, + FlatList, + } +); export default HeaderMotion; export * from './hooks'; diff --git a/src/types.ts b/src/types.ts index e8554b2..f16d39a 100644 --- a/src/types.ts +++ b/src/types.ts @@ -19,18 +19,27 @@ export type HeaderMotionOffsetStrategy = export interface HeaderMotionOffsetProps { /** - * Strategy used to offset the scrollable content by the measured original header height. + * How the scrollable content should be pushed below the measured header. * - * `top` and `translate` keep the bottom of the content reachable by compensating with extra bottom space. + * `padding` is the safest default for most screens. `margin`, `top`, and + * `translate` can be useful when the scrollable or its children need a + * different layout behavior. + * + * `top` and `translate` add bottom compensation so the end of the content + * remains reachable. * * @default 'padding' */ headerOffsetStrategy?: HeaderMotionOffsetStrategy; /** - * Ensures the content container gets a minimum height large enough for short - * content to still scroll far enough to drive the header to its collapsed state. + * Adds a minimum content height so scrollables with short content can still collapse the + * header completely. + * + * **Experimental: this relies on extra layout measurement and may still be + * refined.** * - * Experimental: this relies on extra layout measurement and may be refined in a future release. + * Enable this when some screens do not have enough content to naturally + * scroll through the full collapse distance. * * @default false */ From 63faf401236227a48a7d3206942fb9aca673caa2 Mon Sep 17 00:00:00 2001 From: Oskar Pawica Date: Sun, 29 Mar 2026 11:46:37 +0200 Subject: [PATCH 6/8] chore: types adjustments --- src/components/Header.tsx | 50 ++++++++++++-------------------- src/components/HeaderDynamic.tsx | 2 +- src/index.ts | 4 +-- src/types.ts | 23 +++++++++++++-- src/utils/header.tsx | 14 +++++---- 5 files changed, 50 insertions(+), 43 deletions(-) diff --git a/src/components/Header.tsx b/src/components/Header.tsx index 38aabfa..8936d12 100644 --- a/src/components/Header.tsx +++ b/src/components/Header.tsx @@ -1,8 +1,11 @@ -import type { ReactElement } from 'react'; import { StyleSheet, type ViewProps } from 'react-native'; -import Animated, { type AnimatedProps } from 'react-native-reanimated'; +import Animated from 'react-native-reanimated'; import { useHeaderMotionContextOrThrow } from '../context'; -import type { HeaderPanDecayConfig } from '../types'; +import type { + HeaderAsChildProps, + HeaderDefaultProps, + HeaderPanDecayConfig, +} from '../types'; import { cloneWithOnLayout, composeOnLayoutHandlers, @@ -29,7 +32,7 @@ type HeaderPanProps = * certain velocities. * * If you provide a function, it runs inside the gesture end worklet and - * **must itself be marked with the 'worklet' directive.**. + * **must itself be marked with the 'worklet' directive.** */ panDecayConfig?: HeaderPanDecayConfig; } @@ -38,15 +41,6 @@ type HeaderPanProps = panDecayConfig?: never; }; -type HeaderAsChildProps = { - asChild: true; - children: ReactElement; -}; - -type HeaderDefaultProps = AnimatedProps & { - asChild?: false; -}; - export type HeaderProps = | (HeaderDefaultProps & HeaderPanProps & { @@ -66,8 +60,7 @@ export type HeaderProps = * Only use this when the rendered header subtree is not already under a * gesture-handler root. * - * @default - * false + * @default false */ withGestureHandlerRootView?: boolean; }) @@ -79,14 +72,11 @@ export type HeaderProps = * Only use this when the rendered header subtree is not already under a * gesture-handler root. * - * @default - * false + * @default false */ withGestureHandlerRootView?: boolean; }); -export type HeaderDynamicProps = HeaderDefaultProps | HeaderAsChildProps; - const headerOverlayStyle = StyleSheet.create({ overlay: { position: 'absolute', @@ -96,18 +86,6 @@ const headerOverlayStyle = StyleSheet.create({ }, }).overlay; -type HeaderComponent = ((props: HeaderProps) => ReactElement | null) & { - /** - * Marks the part of the header whose measured layout should define the - * collapsible distance. - * - * In most designs, this is the section that visually disappears while the - * header collapses. Its measured value feeds `measureDynamic`, which can in - * turn drive `progressThreshold`. - */ - Dynamic: typeof HeaderDynamic; -}; - function HeaderRoot(props: HeaderProps) { const ctxValue = useHeaderMotionContextOrThrow( 'HeaderMotion.Header must be used within or . If you are rendering inside a navigation header, bridge the context with and .' @@ -176,5 +154,13 @@ function HeaderRoot(props: HeaderProps) { * drive the collapse threshold. */ export const Header = Object.assign(HeaderRoot, { + /** + * Marks the part of the header whose measured layout should define the + * collapsible distance. + * + * In most designs, this is the section that visually disappears while the + * header collapses. Its measured value feeds `measureDynamic`, which can in + * turn drive `progressThreshold`. + */ Dynamic: HeaderDynamic, -}) as HeaderComponent; +}); diff --git a/src/components/HeaderDynamic.tsx b/src/components/HeaderDynamic.tsx index a0960b1..2e50831 100644 --- a/src/components/HeaderDynamic.tsx +++ b/src/components/HeaderDynamic.tsx @@ -1,7 +1,7 @@ import type { ViewProps } from 'react-native'; import Animated from 'react-native-reanimated'; import { useHeaderMotionContextOrThrow } from '../context'; -import type { HeaderDynamicProps } from './Header'; +import type { HeaderDynamicProps } from '../types'; import { cloneWithOnLayout, composeOnLayoutHandlers, diff --git a/src/index.ts b/src/index.ts index 70ce790..3e0abff 100644 --- a/src/index.ts +++ b/src/index.ts @@ -8,7 +8,7 @@ import { ScrollManager, ScrollView, type CreateHeaderMotionScrollableOptions, - type HeaderDynamicProps, + type HeaderProps, type HeaderMotionBridgeProps, type HeaderMotionFlatListProps, type HeaderMotionNavigationBridgeProps, @@ -16,8 +16,8 @@ import { type HeaderMotionScrollManagerProps, type HeaderMotionScrollableOwnProps, type HeaderMotionScrollViewProps, - type HeaderProps, } from './components'; +import type { HeaderDynamicProps } from './types'; type HeaderMotionCompound = typeof HeaderMotionContextProvider & { /** diff --git a/src/types.ts b/src/types.ts index f16d39a..6d1a682 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,6 +1,14 @@ import type { ReactElement } from 'react'; -import type { LayoutChangeEvent, ScrollViewProps } from 'react-native'; -import type { AnimatedRef, SharedValue } from 'react-native-reanimated'; +import type { + LayoutChangeEvent, + ScrollViewProps, + ViewProps, +} from 'react-native'; +import type { + AnimatedProps, + AnimatedRef, + SharedValue, +} from 'react-native-reanimated'; import { DEFAULT_SCROLL_ID } from './utils/defaults'; import type { InstanceOrElement } from 'react-native-reanimated/lib/typescript/commonTypes'; import type { @@ -78,6 +86,17 @@ export type HeaderPanDecayConfig = | WithDecayConfig | ((event: HeaderPanDecayEvent) => WithDecayConfig); +export type HeaderAsChildProps = { + asChild: true; + children: ReactElement; +}; + +export type HeaderDefaultProps = AnimatedProps & { + asChild?: false; +}; + +export type HeaderDynamicProps = HeaderDefaultProps | HeaderAsChildProps; + export interface HeaderMotionBridgeValue extends MotionProgress { measureTotalHeight: MeasureAnimatedHeaderAndSet; measureDynamic: MeasureAnimatedHeaderAndSet; diff --git a/src/utils/header.tsx b/src/utils/header.tsx index c8759b5..4579cb9 100644 --- a/src/utils/header.tsx +++ b/src/utils/header.tsx @@ -6,6 +6,12 @@ import { } from 'react'; import type { ViewProps } from 'react-native'; +export type SlottableElementProps = { + onLayout?: ViewProps['onLayout']; +}; + +export type SlottableElement = ReactElement; + export function composeOnLayoutHandlers( userHandler: ViewProps['onLayout'], internalHandler: ViewProps['onLayout'] @@ -26,15 +32,11 @@ export function resolveSlottableChild( ); } - return child as ReactElement<{ - onLayout?: ViewProps['onLayout']; - }>; + return child as SlottableElement; } export function cloneWithOnLayout( - child: ReactElement<{ - onLayout?: ViewProps['onLayout']; - }>, + child: SlottableElement, onLayout: ViewProps['onLayout'], componentName: string ) { From 9dd93e4e8df540d0adb176afddf2dd0b7c646c10 Mon Sep 17 00:00:00 2001 From: Oskar Pawica Date: Sun, 29 Mar 2026 12:46:45 +0200 Subject: [PATCH 7/8] chore: updated docs --- MIGRATION-v1.md | 374 ++++++++++++++++++++++---- README.md | 698 +++++++++++++++++++++++++----------------------- 2 files changed, 695 insertions(+), 377 deletions(-) diff --git a/MIGRATION-v1.md b/MIGRATION-v1.md index 25414fe..47549ea 100644 --- a/MIGRATION-v1.md +++ b/MIGRATION-v1.md @@ -1,32 +1,175 @@ -# Migration to `v1.0.0-alpha.x` +# Migration to `v1` -This guide covers migration from `v0.3.x` to the `v1` alpha line. +This guide covers migration from `v0.3.x` to the current `v1` API. -> `v1.0.0-alpha.x` is intentionally unstable. More API adjustments (including breaking changes) can happen before stable `1.0.0`. +The API change is quite substantial, but in practice the migration is usually straightforward and the new shape provides a much better developer experience. -## High-level changes +If you are not migrating yet and need the old docs, use the `v0` README: +[README on branch `v0`](https://github.com/pawicao/react-native-header-motion/blob/v0/README.md) -- Motion values that used to be plain numbers are now exposed as `SharedValue` in runtime APIs. -- `AnimatedHeaderBase` now requires `animatedHeaderBaseProps`. -- `react-native-gesture-handler` is now a peer dependency. -- Optional pannable headers were added via `enableHeaderPan`. To use header panning, you must use `AnimatedHeaderBase`. +## What changed at a high level -## 1) Update peer dependencies +The biggest conceptual shift is this: -Make sure your app has: +- `v0.3.x` used a prop-passing header API +- `v1` uses a context-first header API -- `react-native-gesture-handler` `>= 2.0.0` -- `react-native-reanimated` `>= 4.0.0` -- `react-native-worklets` `>= 0.4.0` +In practice that means: -## 2) Treat `progressThreshold` as `SharedValue` in runtime usage +- `HeaderMotion.Header` is no longer a render-prop bridge +- `AnimatedHeaderBase` / `HeaderBase` are gone from the public API +- `HeaderMotion.Header` and `HeaderMotion.Header.Dynamic` now own measurement wiring +- navigation headers now use `Bridge` + `NavigationBridge` +- `useMotionProgress()` is intentionally narrower -`HeaderMotion` still accepts: +## Upgrade checklist -- `progressThreshold={number}` -- `progressThreshold={(measuredDynamic) => number}` +- [ ] Add `react-native-gesture-handler` as a peer in your app if you do not already have it +- [ ] Replace render-prop `HeaderMotion.Header` with `HeaderMotion.Bridge` + `HeaderMotion.NavigationBridge` if you place your animated headers in the navigation context. +- [ ] Replace `AnimatedHeaderBase` / `HeaderBase` with `HeaderMotion.Header` +- [ ] Replace manual `measureDynamic` wiring with `HeaderMotion.Header.Dynamic` +- [ ] Remove usage of `WithCollapsibleHeaderProps` / `WithCollapsiblePagedHeaderProps` and use the `useMotionProgress` in your header components directly to get progress and progress threshold. +- [ ] Update `useMotionProgress()` usage to read `progressThreshold` as a `SharedValue` +- [ ] Review custom scrollable integrations if you were using `ScrollManager` / `useScrollManager` -But values returned from `useMotionProgress()` / `HeaderMotion.Header` changed from number to `SharedValue`. +## 1. Peer dependencies changed + +`v1` expects your app to provide: + +- `react-native-gesture-handler >= 2.0.0` +- `react-native-reanimated >= 4.0.0` +- `react-native-worklets >= 0.4.0` + +`react-native-gesture-handler` is now part of the peer surface because header panning is built on it. + +## 2. `HeaderMotion.Header` is no longer a render prop + +### Before (`v0.3.x`) + +`HeaderMotion.Header` was the navigation bridge: + +```tsx + + {(headerProps) => ( + , + }} + /> + )} + +``` + +### After (`v1`) + +You now bridge in two explicit steps: + +```tsx + + {(ctx) => ( + ( + + + + ), + }} + /> + )} + +``` + +Inside `MyHeader`, call `useMotionProgress()` normally. + +Why this changed: + +- `HeaderMotion.Header` is now reserved for the actual header container primitive +- the bridge behavior is now explicit and easier to reason about + +## 3. `AnimatedHeaderBase` and `HeaderBase` were removed + +### Removed exports + +- `AnimatedHeaderBase` +- `HeaderBase` + +### Before (`v0.3.x`) + +```tsx +function MyHeader({ + progress, + progressThreshold, + measureTotalHeight, + measureDynamic, +}: WithCollapsibleHeaderProps) { + return ( + + + {/* collapsible part */} + + {/* sticky part */} + + ); +} +``` + +### After (`v1`) + +```tsx +function MyHeader() { + const { progress, progressThreshold } = useMotionProgress(); + + return ( + + + {/* collapsible part */} + + {/* sticky part */} + + ); +} +``` + +What changed: + +- total-height measurement is wired by `HeaderMotion.Header` +- dynamic measurement is wired by `HeaderMotion.Header.Dynamic` +- you no longer manually attach `measureTotalHeight` / `measureDynamic` in the common case + +## 4. `useMotionProgress()` is narrower + +### Before (`v0.3.x`) + +`useMotionProgress()` returned: + +- `progress` +- `progressThreshold` +- `measureTotalHeight` +- `measureDynamic` + +### After (`v1`) + +`useMotionProgress()` returns only: + +- `progress` +- `progressThreshold` + +If you need the full bridged value for advanced context-bridging use cases, use `useHeaderMotionBridge()`. + +Why this changed: + +- the common animation API should expose only what header components usually need +- measurement wiring now lives in `HeaderMotion.Header` / `HeaderMotion.Header.Dynamic` + +## 5. `progressThreshold` is now a `SharedValue` at runtime + +At the provider level, `progressThreshold` is still configured the same way: + +- a number +- or `(measuredDynamic) => number` + +But once you read it from `useMotionProgress()`, it is a `SharedValue`. ### Before (`v0.3.x`) @@ -39,63 +182,200 @@ const translateY = interpolate( ); ``` -### After (`v1 alpha`) +### After (`v1`) ```tsx -const threshold = progressThreshold.get(); // or progressThreshold.value +const threshold = progressThreshold.get(); + const translateY = interpolate( - progress.get(), // or progress.value + progress.get(), [0, 1], [0, -threshold], Extrapolation.CLAMP ); ``` -## 3) Pass `animatedHeaderBaseProps` to `AnimatedHeaderBase` +## 6. Motion-prop helper types were removed -`AnimatedHeaderBase` now expects `animatedHeaderBaseProps`, available from `useMotionProgress()` and `HeaderMotion.Header`. +### Removed exports -### Before (`v0.3.x`) +- `WithCollapsibleHeaderProps` +- `WithCollapsiblePagedHeaderProps` + +These existed because the old API pushed motion data into headers as props. + +In v1, headers usually read from context with: + +- `useMotionProgress()` +- `useHeaderMotionBridge()` only when explicitly bridging context + +So the old prop helper types are no longer the right abstraction. + +## 7. `HeaderMotion.Header.Dynamic` replaces manual dynamic measurement + +If you previously attached `measureDynamic` manually to some inner element, the best migration is to wrap that part in `HeaderMotion.Header.Dynamic`. + +### Before ```tsx - +{/* dynamic content */} ``` -### After (`v1 alpha`) +### After ```tsx - + + {/* dynamic content */} + ``` -## 4) `originalHeaderHeight` in `useScrollManager` is now `SharedValue` +You can still use `asChild` when you need to preserve a specific element. + +## 8. Navigation headers should use `useMotionProgress()` again after bridging + +Under the old API, the usual flow was: + +- bridge through `HeaderMotion.Header` +- receive motion props directly in the navigation header + +Under v1, the usual flow is: + +- bridge with `HeaderMotion.Bridge` +- re-provide with `HeaderMotion.NavigationBridge` +- call `useMotionProgress()` inside the navigation header component + +That keeps the navigation header code looking the same as an inline header. + +## 9. Custom scrollable integrations changed + +If you were using `HeaderMotion.ScrollManager` or `useScrollManager()` before, review these changes: + +- prefer `createHeaderMotionScrollable()` for most reusable custom integrations +- `useScrollManager()` now returns: + - `scrollableProps` + - `headerMotionContext` +- `headerMotionContext` now exposes: + - `originalHeaderHeight` + - `contentContainerMinHeight` + +### Before (`v0.3.x`) + +The old hook exposed `minHeightContentContainerStyle`, which you could pass directly into styles. + +### After (`v1`) + +You now get the plain value `contentContainerMinHeight` instead. + +So instead of: + +```tsx + +``` -When using `HeaderMotion.ScrollManager` / `useScrollManager`, handle `originalHeaderHeight` as a shared value. +you should do: ```tsx - - {/* content */} - + ``` -## 5) Optional: enable pannable headers +You normally won't be doing that manually, since it is under the hood in exported scrollables and ones created by `createHeaderMotionScrollable`. + +Also note: + +- `ensureScrollableContentMinHeight` is now the explicit opt-in for that behavior +- this feature is still experimental + +## 10. Recommended migration path + +For most apps, the best migration is: -Use the new prop only if you want direct pan gestures on the header: +1. Replace old navigation render-prop usage with `Bridge` + `NavigationBridge` +2. Move header measurement wiring into `Header` and `Header.Dynamic` +3. Remove old collapsible-prop helper types and rely on `useMotionProgress()` to get progress and progress threshold. +4. Update animation code to read `progressThreshold` from a shared value + +## Side-by-side migration example + +### Before (`v0.3.x`) ```tsx -{/* ... */} +import HeaderMotion, { + AnimatedHeaderBase, + type WithCollapsibleHeaderProps, +} from 'react-native-header-motion'; + +function Screen() { + return ( + + + {(headerProps) => ( + , + }} + /> + )} + + + {/* content */} + + ); +} + +function MyHeader({ + progress, + progressThreshold, + measureTotalHeight, + measureDynamic, +}: WithCollapsibleHeaderProps) { + return ( + + + + ); +} ``` -If your app does not already wrap the root with `GestureHandlerRootView`, you can set `withGestureHandlerRootView` on `AnimatedHeaderBase`. Using `AnimatedHeaderBase` is required for header panning to work. +### After (`v1`) -## Migration checklist +```tsx +import HeaderMotion, { useMotionProgress } from 'react-native-header-motion'; + +function Screen() { + return ( + + + {(ctx) => ( + ( + + + + ), + }} + /> + )} + + + {/* content */} + + ); +} -- [ ] Add/verify peer dependencies (including gesture handler). -- [ ] Update runtime `progressThreshold` usage to `SharedValue` access. -- [ ] Pass `animatedHeaderBaseProps` into every `AnimatedHeaderBase`. -- [ ] Verify custom `ScrollManager` integrations against shared-value header height. +function MyHeader() { + const { progress, progressThreshold } = useMotionProgress(); + + return ( + + + + ); +} +``` diff --git a/README.md b/README.md index 9e5c71d..094bd5b 100644 --- a/README.md +++ b/README.md @@ -1,48 +1,67 @@ # React Native Header Motion -High-level APIs for **orchestrating header motion** driven by scroll — built on top of [**React Native Reanimated**](https://docs.swmansion.com/react-native-reanimated/). +High-level APIs for orchestrating scroll-driven header motion in React Native. -This library is **100% a wrapper around Reanimated**. All the credit for the underlying animation engine, worklets, and primitives goes to **Reanimated** (and `react-native-worklets`). This package focuses on a specific use case: **header motion + scroll orchestration** (including multi-scroll/tab scenarios). +This library is a wrapper around: -
- -
+- [React Native Reanimated](https://docs.swmansion.com/react-native-reanimated/) & [React Native Worklets](https://docs.swmansion.com/react-native-worklets/docs/) +- [React Native Gesture Handler](https://docs.swmansion.com/react-native-gesture-handler/docs/) + +All credit for the underlying animation engine, worklets, gestures, and low-level primitives goes to those libraries. This package focuses on composing them into a specific higher-level use case: header motion and scroll orchestration. + +This library does not ship a predesigned "collapsible header" UI. It gives you the pieces to: -## v1 alpha status +- measure the parts of a header that matter +- derive a shared `progress` value from scroll +- keep multiple scrollables in sync when one header is shared across them +- bridge that state into navigation-rendered headers -`v1.0.0-alpha.x` is pre-release quality. +You build the visuals yourself on top of that. -- Expect additional API changes (including breaking ones) before stable `1.0.0`. -- If you are upgrading from `0.3.x`, use the migration doc: [MIGRATION-v1.md](./MIGRATION-v1.md). +
+ +
-## What changed since `v0.3.0` +## Version notes -- **Performance-focused internals:** motion threshold + header height now flow through `SharedValue`s to reduce JS-side churn. -- **Pannable header support:** `pannable` and `panDecayConfig` now live directly on `HeaderMotion.Header`. -- **Ecosystem update:** example app moved to Expo 55 + Reanimated 4.2; `react-native-gesture-handler` is now a peer dependency. +- If you are upgrading from `v0.3.x`, read [MIGRATION-v1.md](./MIGRATION-v1.md). +- If you are still on the pre-v1 API and need the old docs, use the `v0` README: + [README on branch `v0`](https://github.com/pawicao/react-native-header-motion/blob/v0/README.md) -## What this is (and isn’t) +## What's new in v1 -**✅ This is** +The API change in v1 is quite substantial, but the migration is usually straightforward and the end result gives a much better developer experience. -- A small set of components + hooks that expose a single `progress` shared value and a few measurement helpers. -- A scroll orchestration layer that can keep multiple scrollables in sync (e.g. tabs + pager). +- Header panning built on top of `react-native-gesture-handler`. Dragging on the header itself can initiate or continue the scroll interaction naturally instead of forcing the user to only use the scrollables. +- Context-first header API built around `HeaderMotion.Header` and `HeaderMotion.Header.Dynamic` +- Explicit navigation bridging with `HeaderMotion.Bridge` and `HeaderMotion.NavigationBridge` +- Narrower `useMotionProgress()` that focuses on `progress` and `progressThreshold` +- Reusable custom-scrollable factory via `createHeaderMotionScrollable()` + - It's now easier than ever to wire up LegendList and FlashList to Header Motion! +- `react-native-gesture-handler` added to the peer dependency surface -**❌ This is NOT** +## What this library is good at -- An out-of-the-box “collapsible header” component with a baked-in look. +- Scroll-driven animated headers +- Shared header state across tabs / pagers / multiple scrollables +- Navigation headers rendered outside the provider subtree +- Reusable wrappers around custom scrollables -You build any header motion you want by animating based on `progress`. +## What this library is not trying to be -## Requirements (peer dependencies) +- A fully styled header component +- A page layout framework +- A general-purpose animation abstraction on top of Reanimated -You must have these installed in your app: +## Requirements -- `react-native-gesture-handler` **>= 2.0.0** -- `react-native-reanimated` **>= 4.0.0** -- `react-native-worklets` **>= 0.4.0** +Your app must provide: -This package declares them as peer dependencies, so your app owns those versions. Remember to install a version of Worklets compatible with your version of Reanimated. +- `react-native-gesture-handler >= 2.0.0` +- `react-native-reanimated >= 4.0.0` +- `react-native-worklets >= 0.4.0` + +These are peer dependencies. ## Installation @@ -56,119 +75,109 @@ or yarn add react-native-header-motion ``` -### Reanimated setup +Then follow the normal setup instructions for: -Follow the official Reanimated [installation instructions](https://docs.swmansion.com/react-native-reanimated/docs/fundamentals/getting-started/#installation) for your environment (Expo / bare RN). +- [Reanimated](https://docs.swmansion.com/react-native-reanimated/docs/fundamentals/getting-started/#installation) +- [Gesture Handler](https://docs.swmansion.com/react-native-gesture-handler/docs/fundamentals/installation) +- [Worklets](https://docs.swmansion.com/react-native-reanimated/docs/fundamentals/getting-started/#installation) ## Mental model -There are three key concepts: - -### 1) `progress` (SharedValue) +There are four concepts to understand: -`progress` is a Reanimated `SharedValue` that represents the normalized progress of your header animation. +### 1. `progress` -- `0` → animation start (initial state) -- `1` → animation end (final state) +`progress` is a `SharedValue`. -### 2) `progressThreshold` (prop vs runtime value) +- `0` means "expanded" +- `1` means "collapsed" -`progressThreshold` is the distance needed for `progress` to move from `0 → 1`. +Most header animations should be derived from this value. -As a `HeaderMotion` prop, you can provide: +### 2. `progressThreshold` -- a number, or -- a function `(measuredDynamic) => threshold` +`progressThreshold` is the collapse distance in pixels. -If you provide a function, it uses the value measured by `measureDynamic`. +It can be: -When you read `progressThreshold` from `useMotionProgress()`, it is a `SharedValue`. -Read it inside worklets via `progressThreshold.get()` (or `progressThreshold.value`). +- a fixed number +- a function derived from the measured dynamic part of the header -### 3) Measurement functions +At runtime, `useMotionProgress()` gives you `progressThreshold` as a `SharedValue`. -The library gives you two measurement callbacks that you pass to your header layout: +In practice, `progress` is calculated by mapping scroll distance across that threshold: -- `measureTotalHeight` – attach to the _outer_ header container to measure the total header height. Scrollables use this to offset content so it starts below the header. -- `measureDynamic` – attach to the part of the header that determines the threshold (often the animated/dynamic portion). +- before the threshold, `progress` moves from `0` toward `1` +- at the threshold, `progress` reaches `1` +- past the threshold, behavior depends on `progressExtrapolation` -## Why `HeaderMotion.Bridge` / `HeaderMotion.NavigationBridge` exist +### 3. Total header height vs dynamic header height -When you pass a `header` component to React Navigation / Expo Router, that header is rendered by the navigator in a different part of the React tree. +The library measures two different things: -Because of that, the navigation header **cannot read the `HeaderMotion` context** directly. +- the total header height +- the dynamic part of the header that should define the collapse distance -Use: - -- `HeaderMotion.Bridge` to read the full context inside the provider tree -- `HeaderMotion.NavigationBridge` to re-provide that same context inside the navigation-rendered header subtree - -Once bridged, your header component can call `useMotionProgress()` normally. - -## Why `HeaderMotion.Header` uses absolute positioning +`HeaderMotion.Header` wires the total-height measurement. -Navigation headers are special: +`HeaderMotion.Header.Dynamic` wires the dynamic measurement. -- Even with `headerTransparent: true`, the navigator can still reserve layout space for the header container. -- If you animate with translations without absolute positioning, you can end up with: - - content below becoming unclickable (an invisible parent header still sits on top), or - - content hidden under the header container. +In many designs: -`HeaderMotion.Header` is **absolutely positioned by default** (`overlay={true}`) to avoid those layout traps, which is especially important when you use transforms/translations. +- the sticky/top part stays visible +- the dynamic part slides away +- the dynamic part is what should feed `progressThreshold` -## When to use components vs hooks +### 4. Navigation headers are a separate tree -You can use either style; pick based on your integration needs: +When a navigation library renders a header outside your screen subtree, it cannot read the `HeaderMotion` context directly. -- Prefer **components** when you want a “batteries included” wiring: +That is why the library has: - - `HeaderMotion.ScrollView` / `HeaderMotion.FlatList` for common scrollables - - `createHeaderMotionScrollable()` for reusable wrappers around custom scrollables - - `HeaderMotion.ScrollManager` for one-off custom scrollables via render-props +- `HeaderMotion.Bridge` +- `HeaderMotion.NavigationBridge` -- Prefer **hooks** when you want to build your own wrappers: - - `useScrollManager()` (same engine as `HeaderMotion.ScrollManager`, but hook-based) - - `useMotionProgress()` when your header is inside the provider tree +Use them only to move HeaderMotion context across that boundary. -Also: +## Recommended integration order -- Use `HeaderMotion.Bridge` + `HeaderMotion.NavigationBridge` when your header is rendered by navigation. -- Use `HeaderMotion.Header` + `HeaderMotion.Header.Dynamic` to wire header measurement automatically. -- Use `useMotionProgress` when you only need `progress` and `progressThreshold`. +The library allows (and requires) you to integrate your scrollables with headers to provide animation behavior. -## Examples +Use the simplest integration that fits your case: -### Example app +1. `HeaderMotion.ScrollView` or `HeaderMotion.FlatList` - exported directly from the library +2. `createHeaderMotionScrollable()` - to easily create custom integrated scrollables on top of other scrollables (e.g. LegendList or FlashList) +3. `HeaderMotion.ScrollManager` / `useScrollManager()` - for even more custom scenarios -Examples live in the example app: `example/`. They demonstrate a few cases, from simple animations, to scroll orchestration and persisted header animation state between different tabs (e.g. with `react-native-pager-view`). +For custom scrollables, prefer `createHeaderMotionScrollable()` first. -Those examples use Expo Router as the navigation library, but it should be fairly simple to do the same with plain React Navigation. +Use the scroll managers only when the factory approach is not flexible enough. -### Expo Router / React Navigation +## Quick start: navigation header -This is the core pattern used in the example app (`example/src/app/new-api.tsx`). +This is the core v1 pattern when your header is rendered by Expo Router / React Navigation. ```tsx import HeaderMotion, { useMotionProgress } from 'react-native-header-motion'; import { Stack } from 'expo-router'; +import { StyleSheet, View } from 'react-native'; import Animated, { Extrapolation, interpolate, useAnimatedStyle, } from 'react-native-reanimated'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; -import { View } from 'react-native'; export default function Screen() { return ( - {(value) => ( + {(ctx) => ( ( - - + + ), }} @@ -176,240 +185,270 @@ export default function Screen() { )} - - {/* your scrollable content */} - + {/* content */} ); } -function MyHeader() { +function AppHeader() { const { progress, progressThreshold } = useMotionProgress(); const insets = useSafeAreaInsets(); const containerStyle = useAnimatedStyle(() => { const threshold = progressThreshold.get(); - const translateY = interpolate( - progress.get(), - [0, 1], - [0, -threshold], - Extrapolation.CLAMP - ); - return { transform: [{ translateY }] }; + + return { + transform: [ + { + translateY: interpolate( + progress.get(), + [0, 1], + [0, -threshold], + Extrapolation.CLAMP + ), + }, + ], + }; }); return ( - + - {/* “dynamic” part of the header */} + {/* collapsible part */} - {/* "regular" part of the header */} + {/* sticky part */} ); } + +const styles = StyleSheet.create({ + header: { + backgroundColor: '#304077', + }, +}); ``` -### Tabs / pager: synchronizing multiple scrollables +## Quick start: inline header inside the screen -If you have multiple scrollables (e.g. pages in `react-native-pager-view`), you can keep a single header progress by: +If your animated header lives in the same subtree as `HeaderMotion`, you do not need bridging at all. -1. Creating a shared “active scroll id” using `useActiveScrollId()` -2. Passing `activeScrollId.sv` to `` -3. Rendering each page scrollable with a unique `scrollId` +```tsx +function Screen() { + return ( + + + {/* content */} + + ); +} -The example app shows this pattern in `example/src/app/collapsible-pager.tsx` using `HeaderMotion.ScrollManager`. +function InlineHeader() { + const { progress, progressThreshold } = useMotionProgress(); -### Keeping the native header + custom animated content below + return ( + + + {/* collapsible section */} + + + ); +} +``` -If you want to keep the native header for back buttons/title, render your animated content _inside the screen_ under the native header: +## Shared header across multiple scrollables + +If one header is shared across tabs or pager pages: + +1. Create an active scroll id with `useActiveScrollId()` +2. Pass `activeScrollId.sv` to `HeaderMotion` +3. Give each scrollable a unique `scrollId` ```tsx -import HeaderMotion, { useMotionProgress } from 'react-native-header-motion'; -import { Stack } from 'expo-router'; -import Animated, { - Extrapolation, - interpolate, - useAnimatedStyle, -} from 'react-native-reanimated'; -import { View } from 'react-native'; +import { useRef } from 'react'; +import PagerView from 'react-native-pager-view'; + +const indexToKey = new Map([ + [0, 'A'], + [1, 'B'], +]); + +function Screen() { + const [activeScrollId, setActiveScrollId] = useActiveScrollId<'A' | 'B'>('A'); + const pagerRef = useRef(null); -export default function Screen() { return ( - <> - - - - - {/* rest of content */} - - - + + + {(ctx) => ( + ( + +
+ + ), + }} + /> + )} + + + { + setActiveScrollId(indexToKey.get(e.nativeEvent.position)!); + }} + > + + + {/* page A content */} + + + + + + {/* page B content */} + + + + ); } +``` -function InlineAnimatedHeader() { - const { progress, progressThreshold } = useMotionProgress(); +## Header panning - const containerStyle = useAnimatedStyle(() => { - const threshold = progressThreshold.get(); - const translateY = interpolate( - progress.get(), - [0, 1], - [0, -threshold], - Extrapolation.CLAMP - ); - return { transform: [{ translateY }] }; - }); +Sometimes the header itself takes up a large part of the screen, so forcing the user to move their finger back down to the scrollable can feel awkward. + +In those cases, you can make the header surface itself drive the scroll interaction as well: +```tsx +function Header() { return ( - + - {/* custom animated content below the native header */} + {/* collapsible content */} - {/* sticky part */} ); } ``` -## API +## Public API -The package exports a default compound component plus hooks, types, and named aliases. +### Default export: `HeaderMotion` -### `HeaderMotion` (default export) +Compound component with: -`HeaderMotion` is a compound component: +- `HeaderMotion.Header` +- `HeaderMotion.Bridge` +- `HeaderMotion.NavigationBridge` +- `HeaderMotion.ScrollView` +- `HeaderMotion.FlatList` +- `HeaderMotion.ScrollManager` -- `HeaderMotion` (provider) -- `HeaderMotion.Header` (header container) -- `HeaderMotion.Bridge` (bridge render-prop for navigation headers) -- `HeaderMotion.NavigationBridge` (re-provides context into navigation headers) -- `HeaderMotion.ScrollView` (pre-wired Animated.ScrollView) -- `HeaderMotion.FlatList` (pre-wired Animated.FlatList) -- `createHeaderMotionScrollable` (factory for reusable custom scrollables) -- `HeaderMotion.ScrollManager` (render-prop API for custom scrollables) +Provider props: -#### Props +- `progressThreshold?: number | ((measuredDynamic: number) => number)`: collapse distance in pixels; when passed as a function, it is derived from the value measured by `HeaderMotion.Header.Dynamic` +- `measureDynamic?: (e) => number`: controls what value is read from the dynamic section's layout event; defaults to its height +- `measureDynamicMode?: 'mount' | 'update'`: `'mount'` measures once; `'update'` re-measures when the dynamic section lays out again +- `activeScrollId?: SharedValue`: identifies which scrollable currently owns header progress in multi-scroll setups +- `progressExtrapolation?: ExtrapolationType`: controls how `progress` behaves outside the normal collapse range -- `progressThreshold?: number | (measuredDynamic: number) => number` - - Defines how many pixels correspond to `progress` going from `0` to `1`. - - If you pass a function, it uses the value measured from `measureDynamic`. -- `measureDynamic?: (e) => number` - - What value to read from the `onLayout` event (defaults to `height`). -- `measureDynamicMode?: 'mount' | 'update'` - - Whether `measureDynamic` updates only once or on every layout recalculation. -- `activeScrollId?: SharedValue` - - Enables multi-scroll orchestration (tabs/pager). -- `progressExtrapolation?: ExtrapolationType` - - Controls how progress behaves outside the threshold range (useful for overscroll). -#### `HeaderMotion.Header` +### `HeaderMotion.Header` -Header container component that: +Main header container. -- measures total header height automatically -- supports direct header pan gestures -- renders an `Animated.View` by default -- applies absolute overlay positioning by default +Responsibilities: -```tsx - - - {/* dynamic measured section */} - - -``` +- measures total header height +- applies overlay positioning by default +- can make the header surface pannable Props: -- `overlay?: boolean` - - Defaults to `true` -- `pannable?: boolean` - - Enables direct pan gestures on that specific header instance. -- `panDecayConfig?: WithDecayConfig | ((event: GestureStateChangeEvent) => WithDecayConfig)` - - Optional momentum config used after the header pan ends. - - If you pass a function, it runs inside the gesture end worklet and must be worklet-safe. -- `withGestureHandlerRootView?: boolean` -- `asChild?: boolean` +- all normal `Animated.View` props in default mode: styles, accessibility props, pointer events, and other normal animated view props work as expected +- `overlay?: boolean`: keeps the header absolutely positioned above content; disable only if you intentionally want it in normal layout flow +- `pannable?: boolean`: allows dragging directly on the header surface to continue the scroll interaction +- `panDecayConfig?: WithDecayConfig | ((event) => WithDecayConfig)`: customizes the momentum animation after a header pan ends +- `withGestureHandlerRootView?: boolean`: wraps the gesture subtree in `GestureHandlerRootView` when that part of the tree is not already under one +- `asChild?: boolean`: injects the total-height measurement into a single child instead of rendering the default `Animated.View` -When `asChild` is `true`, pass a single child that accepts `onLayout`. +Use `asChild` when you want to inject the total-height measurement into a single child instead of rendering the default `Animated.View`. -#### `HeaderMotion.Header.Dynamic` +### `HeaderMotion.Header.Dynamic` -Dynamic measured section of the header. +Marks the part of the header whose layout should define the collapsible distance. -- renders an `Animated.View` by default -- wires `measureDynamic` automatically -- supports `asChild` +Use this for the section that visually disappears during collapse. -#### `HeaderMotion.Bridge` +Props: -Render-prop bridge for navigation-rendered headers. +- all normal `Animated.View` props in default mode: use these as you would on any animated view +- `asChild?: boolean`: injects the dynamic measurement into a single child instead of rendering the default `Animated.View` -```tsx - - {(value) => /* pass value into HeaderMotion.NavigationBridge */} - -``` +### `HeaderMotion.Bridge` + +Reads the current HeaderMotion context and exposes it through a render function. -#### `HeaderMotion.NavigationBridge` +Use it to move the context into a navigation-rendered header subtree. -Re-provides header motion context in a separate subtree, typically inside a navigation header. +Props: -#### `HeaderMotion.ScrollView` +- `children: (value) => ReactNode`: receives the bridged HeaderMotion context value that should usually be passed into `HeaderMotion.NavigationBridge` -Animated ScrollView wired with: +### `HeaderMotion.NavigationBridge` -- `onScroll` handler -- `ref` -- automatic content offset based on measured header height +Re-provides a previously captured HeaderMotion context value in another subtree. -Supports: +Use it together with `HeaderMotion.Bridge`. -- `scrollId?: string` for multi-scroll scenarios -- `headerOffsetStrategy?: 'padding' | 'margin' | 'top' | 'translate' | 'none'` -- `ensureScrollableContentMinHeight?: boolean` - Experimental. Defaults to `false`. +Props: -`padding` is the default and recommended option. `top` and `translate` also add bottom compensation internally so the end of the content remains reachable. +- `value`: the bridged HeaderMotion context captured by `HeaderMotion.Bridge` +- `children`: the subtree that should regain access to HeaderMotion context -#### `HeaderMotion.FlatList` +### `HeaderMotion.ScrollView` -Animated FlatList wired similarly to the ScrollView. +Pre-wired `Animated.ScrollView`. Supports: -- `scrollId?: string` for multi-scroll scenarios -- `headerOffsetStrategy?: 'padding' | 'margin' | 'top' | 'translate' | 'none'` -- `ensureScrollableContentMinHeight?: boolean` - Experimental. Defaults to `false`. +- `scrollId?: string`: unique id for this scrollable when one header is shared across multiple scrollables +- `headerOffsetStrategy?: 'padding' | 'margin' | 'top' | 'translate' | 'none'`: controls how content is pushed below the measured header +- `ensureScrollableContentMinHeight?: boolean`: experimental fallback for short content that otherwise could not scroll far enough to collapse the header fully +- `animatedRef?: AnimatedRef`: lets you reuse your own animated ref instead of letting HeaderMotion create one -#### `createHeaderMotionScrollable(Component, options?)` +### `HeaderMotion.FlatList` -Named export for building reusable scrollable wrappers on top of `useScrollManager()`. -This is the same abstraction used internally by `HeaderMotion.ScrollView` and `HeaderMotion.FlatList`. +Pre-wired `Animated.FlatList`. -Returned components support: +Supports the same HeaderMotion-specific props as `HeaderMotion.ScrollView`. -- `scrollId?: string` -- `headerOffsetStrategy?: 'padding' | 'margin' | 'top' | 'translate' | 'none'` -- `ensureScrollableContentMinHeight?: boolean` - Experimental. Defaults to `false`. +### `createHeaderMotionScrollable(Component, options?)` -Use: +Factory for creating reusable HeaderMotion-aware wrappers around custom scrollables. -- `contentContainerMode: 'children'` for ScrollView-like components -- `contentContainerMode: 'renderScrollComponent'` for FlatList-like components -- `isComponentAnimated: true` when you pass an already animated component +Prefer this over the scroll managers whenever it is enough. + +Useful options: + +- `displayName`: custom component name shown in React DevTools +- `isComponentAnimated`: set this when the input component is already animated and should not be wrapped again +- `contentContainerMode: 'children' | 'renderScrollComponent'`: tells HeaderMotion how to inject content offsetting for that scrollable shape + +Use: -The returned component keeps the wrapped component's prop shape, and list-like -generic components preserve item inference at usage time. Users do not need to -pass generics to `createHeaderMotionScrollable()` itself. +- `'children'` for ScrollView-like components +- `'renderScrollComponent'` for FlatList-like components -By default, the factory wraps the provided component with -`Animated.createAnimatedComponent()`. +Examples: -Example: +`FlashList` ```tsx import { FlashList } from '@shopify/flash-list'; @@ -417,71 +456,36 @@ import { createHeaderMotionScrollable } from 'react-native-header-motion'; const HeaderMotionFlashList = createHeaderMotionScrollable(FlashList, { displayName: 'HeaderMotionFlashList', + contentContainerMode: 'renderScrollComponent', }); ``` -#### `HeaderMotion.ScrollManager` +`LegendList` -Render-prop API for custom scrollables (pager pages, 3rd party lists, etc.). +```tsx +import { LegendList } from '@legendapp/list'; +import { createHeaderMotionScrollable } from 'react-native-header-motion'; -If you use `HeaderMotion.ScrollManager` directly for custom integrations, pass refresh-related props to `ScrollManager` (instead of your inner scrollable): +const HeaderMotionLegendList = createHeaderMotionScrollable(LegendList, { + displayName: 'HeaderMotionLegendList', + isComponentAnimated: true, + contentContainerMode: 'renderScrollComponent', +}); +``` -- `refreshControl` -- `refreshing` -- `onRefresh` -- optional `progressViewOffset` if you want to force your offset. +### `HeaderMotion.ScrollManager` -This is required, as the positioning of scrollables is affecting Refresh Control and has to be coupled with the header heights. +Render-prop fallback for complex custom integrations. -```tsx - - {( - scrollableProps, - { originalHeaderHeight, minHeightContentContainerStyle } - ) => ( - - - {/* content */} - - - )} - -``` +Most code should prefer `createHeaderMotionScrollable()`. -Refresh example with explicit props on `ScrollManager`: +Use `ScrollManager` only when you need a custom composition that the factory API cannot express cleanly. -```tsx - - {( - { onScroll, refreshControl: managedRefreshControl, ...scrollableProps }, - { originalHeaderHeight, minHeightContentContainerStyle } - ) => ( - - - {/* content */} - - - )} - -``` +Props: + +- `scrollId?: string`: unique id for this scrollable when one header is shared across multiple scrollables +- `children`: render function that receives `scrollableProps` and `headerMotionContext` +- plus the same refresh / ref options accepted by `useScrollManager()` ### Hooks @@ -489,63 +493,86 @@ Refresh example with explicit props on `ScrollManager`: Returns: -- `progress` (`SharedValue`) -- `progressThreshold` (`SharedValue`) +- `progress`: `SharedValue` that typically moves from `0` at expanded state to `1` at collapsed state +- `progressThreshold`: `SharedValue` representing the collapse distance in pixels -Only use inside the `HeaderMotion` / `HeaderMotion.NavigationBridge` context tree. +This is the primary animation hook for header UI. #### `useHeaderMotionBridge()` -Returns the full bridge value used by `HeaderMotion.Bridge`. - -Use this only for advanced integrations that need to move HeaderMotion context into another subtree manually. +Returns the full internal bridge value. -#### `useScrollManager(scrollId?)` +Most app code should not need this. Prefer `useMotionProgress()` unless you are explicitly bridging context across a tree boundary. -Lower-level orchestration hook that powers the component APIs. Returns: +Returns: -- `scrollableProps`: `{ onScroll, ref }` -- `headerMotionContext`: - - `originalHeaderHeight` (`SharedValue`) - - `minHeightContentContainerStyle` (helps when content is shorter than the threshold) +- full HeaderMotion context value, including measurement callbacks and scroll synchronization internals #### `useActiveScrollId(initialId)` -Helper for multi-scroll scenarios (tabs/pager). Returns: +Returns: + +- `{ state, sv }`: `state` is the React value for UI logic, `sv` is the matching shared value for HeaderMotion +- setter function: updates both in sync + +Use this for multi-scroll setups. + +#### `useScrollManager(scrollId?, options?)` + +Hook-level fallback for complex custom scrollables. + +Most code should prefer `createHeaderMotionScrollable()`. + +Parameters: + +- `scrollId`: unique id for this scrollable when one header is shared across multiple scrollables +- `options`: optional ref, refresh, and event-handler configuration + +Returns: + +- `scrollableProps`: props to spread onto the scrollable itself, including the managed ref, scroll handlers, and resolved refresh control +- `headerMotionContext`: layout values for offsetting content below the measured header, including `originalHeaderHeight` and optional `contentContainerMinHeight` + +## Notes + +### Why `HeaderMotion.Header` is absolute by default -- `[active, setActive]` -- `active.state` (React state) -- `active.sv` (SharedValue) +Headers rendered by navigation are often easier to animate and interact with when they are visually overlayed above content rather than participating in normal layout flow. -### Types +That is why `overlay` defaults to `true`. -- `HeaderMotionBridgeValue` – full bridge value passed through `HeaderMotion.Bridge` -- `HeaderProps` – props for `HeaderMotion.Header` -- `HeaderDynamicProps` – props for `HeaderMotion.Header.Dynamic` +Disable it only when you intentionally want the header in normal layout flow. -## Additional notes +### `ensureScrollableContentMinHeight` (experimental) + +This is available on the pre-wired scrollables and the custom-scrollable APIs. + +It is useful when content is too short to naturally scroll through the full collapse distance. + +This feature is still experimental. ### Scroll event frequency `scrollEventThrottle` is intentionally not managed by this library. -- Pass it directly to your scrollable when you need it. -- If you run into performance issues, try adjusting `scrollEventThrottle` to reduce how many scroll events this library processes. +Pass it directly to your scrollable when you need it. + +If you run into performance issues, try adjusting `scrollEventThrottle` to reduce how many scroll events this library processes. -### Refresh Control (v.0.3.0+) +### Refresh control -Refresh control support was improved in `v0.3.0+`. +If you use `HeaderMotion.ScrollView` or `HeaderMotion.FlatList`, your refresh-control usage stays the same as in React Native. -- If you use `HeaderMotion.ScrollView` or `HeaderMotion.FlatList`, your refresh-control usage stays the same as in React Native. -- If you use `HeaderMotion.ScrollManager` directly for custom integrations, pass refresh-related props to `ScrollManager`: - - `refreshControl` - - `refreshing` - - `onRefresh` - - optional `progressViewOffset` +If you use `HeaderMotion.ScrollManager` directly for custom integrations, pass refresh-related props to `ScrollManager` itself: + +- `refreshControl` +- `refreshing` +- `onRefresh` +- optional `progressViewOffset` -This is important because scrollable positioning affects refresh-control behavior and needs to stay coupled with measured header height. +This matters because scrollable positioning affects refresh-control behavior and needs to stay coupled with the measured header height. -#### Platform support note: +Platform support note: - Support for Refresh Control is currently partial. - Android works well with the current implementation. @@ -554,15 +581,26 @@ This is important because scrollable positioning affects refresh-control behavio - Other iOS approaches tried so far introduced different issues. - Additional iOS support improvements are planned for future releases. +## Examples + +See the example app in [`example/`](./example/). + +Useful files: + +- [`example/src/app/simple.tsx`](./example/src/app/simple.tsx) +- [`example/src/app/flashlist.tsx`](./example/src/app/flashlist.tsx) +- [`example/src/app/legend-list.tsx`](./example/src/app/legend-list.tsx) +- [`example/src/app/pager-header-pan.tsx`](./example/src/app/pager-header-pan.tsx) +- [`example/src/app/collapsible-pager.tsx`](./example/src/app/collapsible-pager.tsx) + ## Contributing -- Development workflow: see [CONTRIBUTING.md](CONTRIBUTING.md) -- Code of conduct: see [CODE_OF_CONDUCT.md](CODE_OF_CONDUCT.md) +Development workflow: see [CONTRIBUTING.md](./CONTRIBUTING.md) + +Code of conduct: see [CODE_OF_CONDUCT.md](./CODE_OF_CONDUCT.md) ## License MIT ---- - -Made with [create-react-native-library](https://github.com/callstack/react-native-builder-bob) +Made with [`create-react-native-library`](https://github.com/callstack/react-native-builder-bob) From b41ca46a82c85e79404e36316ef7ad2ac831d004 Mon Sep 17 00:00:00 2001 From: Oskar Pawica Date: Sun, 29 Mar 2026 13:40:13 +0200 Subject: [PATCH 8/8] chore: added asChild example --- example/src/app/{new-api.tsx => as-child.tsx} | 107 ++++++++++-------- example/src/app/index.tsx | 1 + 2 files changed, 59 insertions(+), 49 deletions(-) rename example/src/app/{new-api.tsx => as-child.tsx} (56%) diff --git a/example/src/app/new-api.tsx b/example/src/app/as-child.tsx similarity index 56% rename from example/src/app/new-api.tsx rename to example/src/app/as-child.tsx index 39ce9b0..309ae26 100644 --- a/example/src/app/new-api.tsx +++ b/example/src/app/as-child.tsx @@ -13,12 +13,12 @@ export default function Screen() { return ( - {(ctx) => ( + {(value) => ( ( - -
+ + ), }} @@ -30,12 +30,10 @@ export default function Screen() { ); } -function Header() { +function AsChildHeader() { const { progress, progressThreshold } = useMotionProgress(); - const insets = useSafeAreaInsets(); - // 1. Container Animation (Moves UP) const containerStyle = useAnimatedStyle(() => { const threshold = progressThreshold.get(); const translateY = interpolate( @@ -44,10 +42,10 @@ function Header() { [0, -threshold], Extrapolation.CLAMP ); + return { transform: [{ translateY }] }; }); - // 2. Title Animation (Counter-Moves DOWN to stay sticky) const titleStyle = useAnimatedStyle(() => { const threshold = progressThreshold.get(); const translateY = interpolate( @@ -56,79 +54,90 @@ function Header() { [0, threshold], Extrapolation.CLAMP ); + return { transform: [{ translateY }] }; }); - // 3. Content Animation (Parallax + Opacity + Scale) - const boxSectionStyle = useAnimatedStyle(() => { + const dynamicStyle = useAnimatedStyle(() => { const threshold = progressThreshold.get(); - const parallaxTranslateY = interpolate( - progress.get(), - [0, 1], - [0, threshold * 0.5], - Extrapolation.CLAMP - ); const opacity = interpolate( progress.get(), - [0, 1 * 0.6], - [1, 0], - Extrapolation.CLAMP - ); - const scale = interpolate( - progress.get(), - [0, 1], - [1, 0.8], + [0, 0.65, 1], + [1, 0.25, 0], Extrapolation.CLAMP ); + return { opacity, - transform: [{ translateY: parallaxTranslateY }, { scale }], + transform: [ + { + translateY: interpolate( + progress.get(), + [0, 1], + [0, threshold * 0.45], + Extrapolation.CLAMP + ), + }, + ], }; }); return ( - - - - + + + + + - - - - - - + + + + + + + + + ); } const styles = StyleSheet.create({ headerWrapper: { - backgroundColor: '#304077', + backgroundColor: '#1D4ED8', borderBottomWidth: 1, - borderBottomColor: 'rgba(0,0,0,0.1)', + borderBottomColor: 'rgba(15, 23, 42, 0.14)', + }, + absoluteHeaderWrapper: { + top: 0, + left: 0, + right: 0, + position: 'absolute', }, dynamicContent: { overflow: 'hidden', }, boxContainer: { flexDirection: 'row', - gap: 6, - padding: 12, + gap: 10, + paddingHorizontal: 12, + paddingBottom: 12, alignItems: 'stretch', - overflow: 'hidden', }, }); const content = generateContent({ - count: 500, - backgroundColor: '#E3CBFC', - textColor: '#304077', + count: 120, + backgroundColor: '#DBEAFE', + textColor: '#1E3A8A', }); diff --git a/example/src/app/index.tsx b/example/src/app/index.tsx index 9390750..9c334d8 100644 --- a/example/src/app/index.tsx +++ b/example/src/app/index.tsx @@ -111,6 +111,7 @@ const SECTIONS: ShowcaseSection[] = [ href: '/short-content-no-min-height', icon: '📄', }, + { title: 'Header asChild', href: '/as-child', icon: '🧩' }, ], }, {