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 afb8d3a..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:** new `enableHeaderPan` on `HeaderMotion` and required `animatedHeaderBaseProps` on `AnimatedHeaderBase`. -- **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,429 +75,380 @@ 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: +There are four concepts to understand: -### 1) `progress` (SharedValue) +### 1. `progress` -`progress` is a Reanimated `SharedValue` that represents the normalized progress of your header animation. +`progress` is a `SharedValue`. -- `0` → animation start (initial state) -- `1` → animation end (final state) +- `0` means "expanded" +- `1` means "collapsed" -### 2) `progressThreshold` (prop vs runtime value) +Most header animations should be derived from this value. -`progressThreshold` is the distance needed for `progress` to move from `0 → 1`. +### 2. `progressThreshold` -As a `HeaderMotion` prop, you can provide: +`progressThreshold` is the collapse distance in pixels. -- a number, or -- a function `(measuredDynamic) => threshold` +It can be: -If you provide a function, it uses the value measured by `measureDynamic`. +- a fixed number +- a function derived from the measured dynamic part of the header -When you read `progressThreshold` from `useMotionProgress()` / `HeaderMotion.Header`, it is a `SharedValue`. -Read it inside worklets via `progressThreshold.get()` (or `progressThreshold.value`). +At runtime, `useMotionProgress()` gives you `progressThreshold` as a `SharedValue`. -### 3) Measurement functions +In practice, `progress` is calculated by mapping scroll distance across that threshold: -The library gives you two measurement callbacks that you pass to your header layout: +- before the threshold, `progress` moves from `0` toward `1` +- at the threshold, `progress` reaches `1` +- past the threshold, behavior depends on `progressExtrapolation` -- `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). +### 3. Total header height vs dynamic header height -## Why `HeaderMotion.Header` exists +The library measures two different things: -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 total header height +- the dynamic part of the header that should define the collapse distance -Because of that, the navigation header **cannot read the `HeaderMotion` context**, so calling `useMotionProgress()` inside that header would throw. +`HeaderMotion.Header` wires the total-height measurement. -`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. +`HeaderMotion.Header.Dynamic` wires the dynamic measurement. -## Why `HeaderBase` / `AnimatedHeaderBase` uses absolute positioning +In many designs: -Navigation headers are special: +- the sticky/top part stays visible +- the dynamic part slides away +- the dynamic part is what should feed `progressThreshold` -- 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. +### 4. Navigation headers are a separate tree -`HeaderBase` and `AnimatedHeaderBase` are **absolutely positioned** to avoid those layout traps, which is especially important when you use transforms/translations. +When a navigation library renders a header outside your screen subtree, it cannot read the `HeaderMotion` context directly. -## When to use components vs hooks +That is why the library has: -You can use either style; pick based on your integration needs: +- `HeaderMotion.Bridge` +- `HeaderMotion.NavigationBridge` -- Prefer **components** when you want a “batteries included” wiring: +Use them only to move HeaderMotion context across that boundary. - - `HeaderMotion.ScrollView` / `HeaderMotion.FlatList` for common scrollables - - `createHeaderMotionScrollable()` for reusable wrappers around custom scrollables - - `HeaderMotion.ScrollManager` for one-off custom scrollables via render-props +## Recommended integration order -- 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 +The library allows (and requires) you to integrate your scrollables with headers to provide animation behavior. -Also: +Use the simplest integration that fits your case: -- Use `HeaderMotion.Header` when your header is rendered by navigation. -- Use `useMotionProgress` when your header is rendered inside the same tree as `HeaderMotion`. +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 +For custom scrollables, prefer `createHeaderMotionScrollable()` first. -### Example app +Use the scroll managers only when the factory approach is not flexible enough. -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`). +## Quick start: navigation header -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 - -This is the core pattern used in the example app (`example/src/app/simple.tsx`). +This is the core v1 pattern when your header is rendered by Expo Router / React Navigation. ```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 { 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 ( - - {(headerProps) => ( + + {(ctx) => ( , + header: () => ( + + + + ), }} /> )} - + - - {/* your scrollable content */} - + {/* content */} ); } -function MyHeader({ - progress, - measureTotalHeight, - measureDynamic, - progressThreshold, - animatedHeaderBaseProps, -}: WithCollapsibleHeaderProps) { +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 */} + ); } -``` -### React Navigation +const styles = StyleSheet.create({ + header: { + backgroundColor: '#304077', + }, +}); +``` -In React Navigation you typically configure headers via `navigation.setOptions()`. +## Quick start: inline header inside the screen -Important: the header itself can’t call `useMotionProgress()`, so we still use `HeaderMotion.Header` as a bridge. +If your animated header lives in the same subtree as `HeaderMotion`, you do not need bridging at all. ```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() { +function Screen() { return ( - - {(headerProps) => ( - - )} - + {/* content */} ); } -function NavigationHeaderInstaller({ - headerProps, -}: { - headerProps: WithCollapsibleHeaderProps; -}) { - const navigation = useNavigation(); - - React.useLayoutEffect(() => { - navigation.setOptions({ - header: () => , - }); - }, [navigation, headerProps]); +function InlineHeader() { + const { progress, progressThreshold } = useMotionProgress(); - return null; + return ( + + + {/* collapsible section */} + + + ); } +``` -function MyHeader({ - progress, - measureTotalHeight, - measureDynamic, - progressThreshold, - animatedHeaderBaseProps, -}: WithCollapsibleHeaderProps) { - const insets = useSafeAreaInsets(); +## Shared header across multiple scrollables - const containerStyle = useAnimatedStyle(() => { - const threshold = progressThreshold.get(); - const translateY = interpolate( - progress.get(), - [0, 1], - [0, -threshold], - Extrapolation.CLAMP - ); - return { transform: [{ translateY }] }; - }); +If one header is shared across tabs or pager pages: - return ( - - - {/* “dynamic” part of the header */} - +1. Create an active scroll id with `useActiveScrollId()` +2. Pass `activeScrollId.sv` to `HeaderMotion` +3. Give each scrollable a unique `scrollId` + +```tsx +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); - {/* "regular" part of the header */} - + return ( + + + {(ctx) => ( + ( + +
+ + ), + }} + /> + )} + + + { + setActiveScrollId(indexToKey.get(e.nativeEvent.position)!); + }} + > + + + {/* page A content */} + + + + + + {/* page B content */} + + + + ); } ``` -### Tabs / pager: synchronizing multiple scrollables +## Header panning -If you have multiple scrollables (e.g. pages in `react-native-pager-view`), you can keep a single header progress by: +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. -1. Creating a shared “active scroll id” using `useActiveScrollId()` -2. Passing `activeScrollId.sv` to `` -3. Rendering each page scrollable with a unique `scrollId` +In those cases, you can make the header surface itself drive the scroll interaction as well: -The example app shows this pattern in `example/src/app/collapsible-pager.tsx` using `HeaderMotion.ScrollManager`. +```tsx +function Header() { + return ( + + + {/* collapsible content */} + + + ); +} +``` -### Keeping the native header (back button/title) + custom animated header below +## Public API -Sometimes you want to keep the native navigation header for back buttons + title, but still animate a custom header section below it. +### Default export: `HeaderMotion` -In that case: +Compound component with: -- set `headerTransparent: true` -- do **not** provide a custom `header` component -- render your animated header content _inside the screen_ under the native header +- `HeaderMotion.Header` +- `HeaderMotion.Bridge` +- `HeaderMotion.NavigationBridge` +- `HeaderMotion.ScrollView` +- `HeaderMotion.FlatList` +- `HeaderMotion.ScrollManager` -Sketch: +Provider props: -```tsx -import HeaderMotion, { - AnimatedHeaderBase, - 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'; +- `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 -export default function Screen() { - return ( - <> - - - - - {/* rest of content */} - - - - ); -} +### `HeaderMotion.Header` -function InlineAnimatedHeader() { - const { - progress, - measureTotalHeight, - measureDynamic, - progressThreshold, - animatedHeaderBaseProps, - } = useMotionProgress(); +Main header container. - const containerStyle = useAnimatedStyle(() => { - const threshold = progressThreshold.get(); - const translateY = interpolate( - progress.get(), - [0, 1], - [0, -threshold], - Extrapolation.CLAMP - ); - return { transform: [{ translateY }] }; - }); +Responsibilities: - return ( - - - {/* custom animated header content below the native header */} - - {/* sticky part */} - - ); -} -``` +- measures total header height +- applies overlay positioning by default +- can make the header surface pannable -## API +Props: -The package exports a default compound component plus hooks, types, and a couple base components. +- 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` -### `HeaderMotion` (default export) +Use `asChild` when you want to inject the total-height measurement into a single child instead of rendering the default `Animated.View`. -`HeaderMotion` is a compound component: +### `HeaderMotion.Header.Dynamic` -- `HeaderMotion` (provider) -- `HeaderMotion.Header` (bridge for 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) +Marks the part of the header whose layout should define the collapsible distance. -#### Props +Use this for the section that visually disappears during collapse. -- `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). -- `enableHeaderPan?: boolean` - - Enables direct pan gestures on `AnimatedHeaderBase` (`false` by default). +Props: -#### `HeaderMotion.Header` +- 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` -Render-prop component that passes motion progress props to a header you render via navigation. +### `HeaderMotion.Bridge` -```tsx - - {(headerProps) => /* pass headerProps into navigation header */} - -``` +Reads the current HeaderMotion context and exposes it through a render function. -Use this instead of `useMotionProgress()` when your header is rendered by React Navigation / Expo Router. +Use it to move the context into a navigation-rendered header subtree. -#### `HeaderMotion.ScrollView` +Props: -Animated ScrollView wired with: +- `children: (value) => ReactNode`: receives the bridged HeaderMotion context value that should usually be passed into `HeaderMotion.NavigationBridge` -- `onScroll` handler -- `ref` -- automatic content offset based on measured header height +### `HeaderMotion.NavigationBridge` -Supports: +Re-provides a previously captured HeaderMotion context value in another subtree. + +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. + +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 -- `contentContainerMode: 'children'` for ScrollView-like components -- `contentContainerMode: 'renderScrollComponent'` for FlatList-like components -- `isComponentAnimated: true` when you pass an already animated component +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'; @@ -486,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 @@ -558,74 +493,86 @@ Refresh example with explicit props on `ScrollManager`: Returns: -- `progress` (`SharedValue`) -- `progressThreshold` (`SharedValue`) -- `measureTotalHeight` (`onLayout` callback) -- `measureDynamic` (`onLayout` callback) -- `animatedHeaderBaseProps` (required by `AnimatedHeaderBase`) -- `activeScrollId` (`SharedValue | undefined`) +- `progress`: `SharedValue` that typically moves from `0` at expanded state to `1` at collapsed state +- `progressThreshold`: `SharedValue` representing the collapse distance in pixels + +This is the primary animation hook for header UI. -Only use inside the `HeaderMotion` provider tree. +#### `useHeaderMotionBridge()` -#### `useScrollManager(scrollId?)` +Returns the full internal bridge value. -Lower-level orchestration hook that powers the component APIs. Returns: +Most app code should not need this. Prefer `useMotionProgress()` unless you are explicitly bridging context across a tree boundary. + +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: -- `[active, setActive]` -- `active.state` (React state) -- `active.sv` (SharedValue) +- `scrollId`: unique id for this scrollable when one header is shared across multiple scrollables +- `options`: optional ref, refresh, and event-handler configuration -### Base components +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` -#### `HeaderBase` +## Notes -Non-animated absolutely positioned header base. +### Why `HeaderMotion.Header` is absolute by default -#### `AnimatedHeaderBase` +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. -Reanimated-powered, absolutely positioned header base. +That is why `overlay` defaults to `true`. -- Requires `animatedHeaderBaseProps` from `useMotionProgress()` / `HeaderMotion.Header`. -- It is required for header panning functionality. -- Optional `withGestureHandlerRootView` can wrap this header in `GestureHandlerRootView` when needed. +Disable it only when you intentionally want the header in normal layout flow. -### Types +### `ensureScrollableContentMinHeight` (experimental) -- `WithCollapsibleHeaderProps` – convenience type for headers using motion progress props. -- `WithCollapsiblePagedHeaderProps` – like above, plus `activeTab` and `onTabChange`. +This is available on the pre-wired scrollables and the custom-scrollable APIs. -## Additional notes +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. @@ -634,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) 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/as-child.tsx b/example/src/app/as-child.tsx new file mode 100644 index 0000000..309ae26 --- /dev/null +++ b/example/src/app/as-child.tsx @@ -0,0 +1,143 @@ +import { DynamicBox, TitleWithSubtitle, generateContent } from '@/components'; +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'; + +export default function Screen() { + return ( + + + {(value) => ( + ( + + + + ), + }} + /> + )} + + {content} + + ); +} + +function AsChildHeader() { + 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 }] }; + }); + + const titleStyle = useAnimatedStyle(() => { + const threshold = progressThreshold.get(); + const translateY = interpolate( + progress.get(), + [0, 1], + [0, threshold], + Extrapolation.CLAMP + ); + + return { transform: [{ translateY }] }; + }); + + const dynamicStyle = useAnimatedStyle(() => { + const threshold = progressThreshold.get(); + const opacity = interpolate( + progress.get(), + [0, 0.65, 1], + [1, 0.25, 0], + Extrapolation.CLAMP + ); + + return { + opacity, + transform: [ + { + translateY: interpolate( + progress.get(), + [0, 1], + [0, threshold * 0.45], + Extrapolation.CLAMP + ), + }, + ], + }; + }); + + return ( + + + + + + + + + + + + + + + + + ); +} + +const styles = StyleSheet.create({ + headerWrapper: { + backgroundColor: '#1D4ED8', + borderBottomWidth: 1, + borderBottomColor: 'rgba(15, 23, 42, 0.14)', + }, + absoluteHeaderWrapper: { + top: 0, + left: 0, + right: 0, + position: 'absolute', + }, + dynamicContent: { + overflow: 'hidden', + }, + boxContainer: { + flexDirection: 'row', + gap: 10, + paddingHorizontal: 12, + paddingBottom: 12, + alignItems: 'stretch', + }, +}); + +const content = generateContent({ + count: 120, + backgroundColor: '#DBEAFE', + textColor: '#1E3A8A', +}); 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..3542f2b 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, { @@ -14,28 +11,27 @@ 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,8 @@ function CollapsibleHeader({ }); return ( - @@ -97,15 +92,14 @@ function CollapsibleHeader({ - - + - + ); } 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: '🧩' }, ], }, { 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) => ( ( - + + + ), }} /> )} - + - - {(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..f105302 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'; @@ -44,22 +43,23 @@ export default function Screen() { }; return ( - - - {(headerProps) => ( + + + {(value) => ( ( - + + + ), }} /> )} - + void; +}) { + const { progress, progressThreshold } = useMotionProgress(); const insets = useSafeAreaInsets(); const containerStyle = useAnimatedStyle(() => { @@ -141,9 +140,16 @@ function CollapsibleHeader({ }); return ( - { + 'worklet'; + + return { + velocity: e.velocityY * 1.4, + deceleration: 0.997, + }; + }} style={[styles.headerWrapper, { paddingTop: insets.top }, containerStyle]} > @@ -154,13 +160,12 @@ function CollapsibleHeader({ - - + @@ -175,7 +180,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/Bridge.tsx b/src/components/Bridge.tsx new file mode 100644 index 0000000..6361fba --- /dev/null +++ b/src/components/Bridge.tsx @@ -0,0 +1,29 @@ +import { useHeaderMotionBridge } from '../hooks/useHeaderMotionBridge'; +import type { ReactNode } from 'react'; +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( + '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 11efba5..8936d12 100644 --- a/src/components/Header.tsx +++ b/src/components/Header.tsx @@ -1,30 +1,166 @@ -import { useMotionProgress } from '../hooks/useMotionProgress'; -import type { MotionProgress } from '../types'; -import type { ReactNode } from 'react'; +import { StyleSheet, type ViewProps } from 'react-native'; +import Animated from 'react-native-reanimated'; +import { useHeaderMotionContextOrThrow } from '../context'; +import type { + HeaderAsChildProps, + HeaderDefaultProps, + HeaderPanDecayConfig, +} from '../types'; +import { + cloneWithOnLayout, + composeOnLayoutHandlers, + resolveSlottableChild, +} from '../utils'; +import { HeaderDynamic } from './HeaderDynamic'; +import { HeaderPanBoundary } from './HeaderPanBoundary'; -type HeaderRenderChildren = (props: MotionProgress) => ReactNode; +type HeaderPanProps = + | { + /** 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; + /** + * 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 marked with the 'worklet' directive.** + */ + panDecayConfig?: HeaderPanDecayConfig; + } + | { + pannable?: false | undefined; + panDecayConfig?: never; + }; -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. - */ - children: HeaderRenderChildren; -} +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; + }); -/** - * 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) { - if (typeof children !== 'function') { - throw new Error( - 'HeaderMotion.Header only accepts render function as the only child.' +const headerOverlayStyle = StyleSheet.create({ + overlay: { + position: 'absolute', + top: 0, + left: 0, + right: 0, + }, +}).overlay; + +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 .' + ); + + 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, + pannable, + panDecayConfig, + onLayout, + style, + withGestureHandlerRootView, + ...rest + } = props; + const resolvedOnLayout = onLayout as ViewProps['onLayout'] | undefined; + + return ( + + + + ); } + +/** + * 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, { + /** + * 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, +}); diff --git a/src/components/HeaderBase.tsx b/src/components/HeaderBase.tsx deleted file mode 100644 index f518ea7..0000000 --- a/src/components/HeaderBase.tsx +++ /dev/null @@ -1,140 +0,0 @@ -import { useMemo } from 'react'; -import { Platform, StyleSheet, View, type ViewProps } from 'react-native'; -import { - Gesture, - GestureDetector, - GestureHandlerRootView, -} from 'react-native-gesture-handler'; -import Animated, { - useAnimatedReaction, - withDecay, - type AnimatedProps, -} from 'react-native-reanimated'; - -const PLATFORM_PANNING_ENABLED = Platform.select({ - default: true, - android: false, -}); - -import type { MotionProgress } from '../types'; - -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 - * - * ... - * - * ``` - */ - -// 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, - 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; - - useAnimatedReaction( - () => momentumScrollOffset.get(), - (offset, prevOffset) => { - if (offset !== null) { - const dy = offset - (prevOffset ?? 0); - scrollToRef.current?.(dy); - } - } - ); - - const isPanEnabled = PLATFORM_PANNING_ENABLED && enableHeaderPan; - - const pan = useMemo( - () => - Gesture.Pan() - .enabled(isPanEnabled) - .onChange((e) => { - const dy = e.changeY; - scrollToRef.current?.(dy); - }) - .onEnd((e) => { - momentumScrollOffset.set( - withDecay( - { - velocity: e.velocityY, - }, - () => momentumScrollOffset.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] - ); - - const content = ( - - - - ); - - if (!withGestureHandlerRootView) { - return content; - } - - 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/HeaderDynamic.tsx b/src/components/HeaderDynamic.tsx new file mode 100644 index 0000000..2e50831 --- /dev/null +++ b/src/components/HeaderDynamic.tsx @@ -0,0 +1,45 @@ +import type { ViewProps } from 'react-native'; +import Animated from 'react-native-reanimated'; +import { useHeaderMotionContextOrThrow } from '../context'; +import type { HeaderDynamicProps } from '../types'; +import { + cloneWithOnLayout, + composeOnLayoutHandlers, + 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 .' + ); + + 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/HeaderMotion.tsx b/src/components/HeaderMotion.tsx index 0aaa65a..f14ffda 100644 --- a/src/components/HeaderMotion.tsx +++ b/src/components/HeaderMotion.tsx @@ -52,53 +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 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 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; - /** Enables panning directly on the header surface. - * @default false - */ - enableHeaderPan?: boolean; - /** 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({ @@ -107,7 +122,6 @@ function HeaderMotionContextProvider({ measureDynamicMode = 'mount', activeScrollId, progressExtrapolation = Extrapolation.CLAMP, - enableHeaderPan = false, children, }: HeaderMotionProps) { const dynamicMeasurement = useSharedValue(undefined); @@ -208,24 +222,13 @@ 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, originalHeaderHeight, measureDynamic: setOrUpdateDynamicMeasurement, measureTotalHeight, - enableHeaderPan, headerPanMomentumOffset, - animatedHeaderBaseProps, progressThreshold: progressThresholdValue, scrollValues, scrollToRef, @@ -235,9 +238,7 @@ function HeaderMotionContextProvider({ originalHeaderHeight, progress, measureTotalHeight, - enableHeaderPan, headerPanMomentumOffset, - animatedHeaderBaseProps, setOrUpdateDynamicMeasurement, scrollValues, activeScrollId, diff --git a/src/components/HeaderPanBoundary.tsx b/src/components/HeaderPanBoundary.tsx new file mode 100644 index 0000000..98c46ab --- /dev/null +++ b/src/components/HeaderPanBoundary.tsx @@ -0,0 +1,92 @@ +import { useMemo, type ReactElement } from 'react'; +import { Platform } from 'react-native'; +import { + Gesture, + GestureDetector, + GestureHandlerRootView, +} from 'react-native-gesture-handler'; +import { useAnimatedReaction, withDecay } from 'react-native-reanimated'; +import type { + HeaderPanDecayConfig, + HeaderPanDecayEvent, + HeaderMotionBridgeValue, +} from '../types'; + +const PLATFORM_PANNING_ENABLED = Platform.select({ + default: true, + android: false, +}); + +type HeaderPanBoundaryProps = Pick< + HeaderMotionBridgeValue, + 'scrollToRef' | 'headerPanMomentumOffset' +> & { + children: ReactElement; + pannable?: boolean; + panDecayConfig?: HeaderPanDecayConfig; + withGestureHandlerRootView?: boolean; +}; + +export function HeaderPanBoundary({ + children, + pannable = false, + panDecayConfig, + scrollToRef, + headerPanMomentumOffset, + withGestureHandlerRootView = false, +}: HeaderPanBoundaryProps) { + useAnimatedReaction( + () => headerPanMomentumOffset.get(), + (offset, prevOffset) => { + if (offset !== null) { + const dy = offset - (prevOffset ?? 0); + scrollToRef.current?.(dy); + } + } + ); + + const isPanEnabled = PLATFORM_PANNING_ENABLED && pannable; + + const pan = useMemo( + () => + Gesture.Pan() + .enabled(isPanEnabled) + .onChange((e) => { + const dy = e.changeY; + scrollToRef.current?.(dy); + }) + .onEnd((e) => { + const resolvedConfig = resolvePanDecayConfig(panDecayConfig, e); + headerPanMomentumOffset.set( + withDecay(resolvedConfig, () => headerPanMomentumOffset.set(null)) + ); + }) + .shouldCancelWhenOutside(false), + [headerPanMomentumOffset, isPanEnabled, panDecayConfig, scrollToRef] + ); + + const content = {children}; + + if (!withGestureHandlerRootView) { + return content; + } + + return {content}; +} + +function resolvePanDecayConfig( + panDecayConfig: HeaderPanDecayConfig | undefined, + event: HeaderPanDecayEvent +) { + 'worklet'; + + const resolvedConfig = + typeof panDecayConfig === 'function' + ? panDecayConfig(event) + : panDecayConfig; + + return { + ...resolvedConfig, + velocity: resolvedConfig?.velocity ?? event.velocityY, + }; +} diff --git a/src/components/NavigationBridge.tsx b/src/components/NavigationBridge.tsx new file mode 100644 index 0000000..5d7fd9f --- /dev/null +++ b/src/components/NavigationBridge.tsx @@ -0,0 +1,30 @@ +import type { ReactNode } from 'react'; +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, +}: HeaderMotionNavigationBridgeProps) { + return ( + + {children} + + ); +} diff --git a/src/components/ScrollManager.tsx b/src/components/ScrollManager.tsx index a45ee8b..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 @@ -48,9 +49,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 new file mode 100644 index 0000000..e86444f --- /dev/null +++ b/src/components/__tests__/Header.test.tsx @@ -0,0 +1,307 @@ +const mockUseHeaderMotionContextOrThrow = jest.fn(); +const mockUseHeaderMotionBridge = jest.fn(); +let capturedPanOnEnd: ((event: any) => void) | undefined; + +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: (cb: (event: any) => void) => { + capturedPanOnEnd = cb; + return 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 { 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) { + 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(), + headerPanMomentumOffset: createSharedValue(null), + scrollValues: createSharedValue({}), + activeScrollId: undefined, + scrollToRef: { current: jest.fn() }, + originalHeaderHeight: 0, +}; + +const layoutEvent = { + nativeEvent: { + layout: { + height: 120, + }, + }, +} as any; + +const headerOverlayStyle = { + position: 'absolute', + top: 0, + left: 0, + right: 0, +}; + +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', () => { + expect(() => + Bridge({ + 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(Bridge({ children })).toBe('ok'); + expect(children).toHaveBeenCalledWith(bridgeValue); + }); + + it('HeaderMotion.NavigationBridge returns the main context provider', () => { + const child = React.createElement('Child'); + const element = NavigationBridge({ + 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 = Header({ + 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(element.props.pannable).toBeUndefined(); + expect(element.props.panDecayConfig).toBeUndefined(); + 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 = Header({ + 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 = Header({ + 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 forwards pan props to HeaderPanBoundary', () => { + const panDecayConfig = { deceleration: 0.99 }; + mockUseHeaderMotionContextOrThrow.mockReturnValue(bridgeValue); + + const element = Header({ + pannable: true, + panDecayConfig, + children: React.createElement('Child'), + } as any) as React.ReactElement; + + expect(element.props.pannable).toBe(true); + expect(element.props.panDecayConfig).toBe(panDecayConfig); + }); + + it('HeaderMotion.Header rejects invalid asChild children', () => { + mockUseHeaderMotionContextOrThrow.mockReturnValue(bridgeValue); + + expect(() => + Header({ + 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 = HeaderDynamic({ + 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 = HeaderDynamic({ + 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, + pannable: 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'); + }); + + it('HeaderPanBoundary uses object decay config for momentum', () => { + const element = HeaderPanBoundary({ + children: React.createElement('Child'), + pannable: true, + panDecayConfig: { 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 panDecayConfig = jest.fn((event) => ({ + velocity: event.velocityY * 0.5, + deceleration: 0.994, + })); + + const element = HeaderPanBoundary({ + children: React.createElement('Child'), + pannable: true, + panDecayConfig, + 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(panDecayConfig).toHaveBeenCalledWith({ velocityY: 240 }); + expect(bridgeValue.headerPanMomentumOffset.set).toHaveBeenCalledTimes(2); + }); +}); 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/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/components/index.ts b/src/components/index.ts index d3f7e40..dc87f24 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -1,6 +1,7 @@ +export * from './Bridge'; export * from './FlatList'; export * from './Header'; -export * from './HeaderBase'; +export * from './NavigationBridge'; 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/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 new file mode 100644 index 0000000..e578563 --- /dev/null +++ b/src/hooks/useHeaderMotionBridge.ts @@ -0,0 +1,15 @@ +import { useHeaderMotionContextOrThrow } from '../context'; +import type { HeaderMotionBridgeValue } from '../types'; + +/** + * 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( + '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..257c1de --- /dev/null +++ b/src/hooks/useMotionProgress.test.ts @@ -0,0 +1,67 @@ +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(), + headerPanMomentumOffset: createSharedValue(null), + scrollValues: createSharedValue({}), + activeScrollId: undefined, + scrollToRef: { current: null }, + originalHeaderHeight: 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..a3ea8d8 100644 --- a/src/hooks/useMotionProgress.ts +++ b/src/hooks/useMotionProgress.ts @@ -1,64 +1,31 @@ -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. + * 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 and measuring functions: - * - `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 + * `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 * 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/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 b655847..3e0abff 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,84 +1,122 @@ import { - AnimatedHeaderBase, - HeaderBase, createHeaderMotionScrollable, + Bridge, HeaderMotionContextProvider, - HeaderMotionFlatList, - HeaderMotionHeader, - HeaderMotionScrollManager, - HeaderMotionScrollView, + FlatList, + Header, + NavigationBridge, + ScrollManager, + ScrollView, type CreateHeaderMotionScrollableOptions, + type HeaderProps, + type HeaderMotionBridgeProps, type HeaderMotionFlatListProps, - type HeaderMotionHeaderProps, + type HeaderMotionNavigationBridgeProps, type HeaderMotionProps, type HeaderMotionScrollManagerProps, type HeaderMotionScrollableOwnProps, type HeaderMotionScrollViewProps, } from './components'; -import type { ReactElement } from 'react'; +import type { HeaderDynamicProps } from './types'; -/** - * 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; - /** 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. +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. */ - Header: typeof HeaderMotionHeader; - /** Component for custom scroll implementations. - * Use when you want render-prop composition instead of calling {@link useScrollManager} directly. + 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 HeaderMotionScrollManager; - /** Animated ScrollView component with header motion integration */ - ScrollView: typeof HeaderMotionScrollView; - /** Animated FlatList component with header motion integration */ - FlatList: typeof HeaderMotionFlatList; + 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 * - * - * {(headerProps) => ( + * + * {(value) => ( * ( - * + * + * + * * ), * }} * /> * )} - * + * * * * * * ``` */ -const HeaderMotion = HeaderMotionContextProvider as HeaderMotionComponent; -HeaderMotion.Header = HeaderMotionHeader; -HeaderMotion.ScrollManager = HeaderMotionScrollManager; -HeaderMotion.ScrollView = HeaderMotionScrollView; -HeaderMotion.FlatList = HeaderMotionFlatList; +const HeaderMotion: HeaderMotionCompound = Object.assign( + HeaderMotionContextProvider, + { + Header, + Bridge, + NavigationBridge, + ScrollManager, + ScrollView, + FlatList, + } +); export default HeaderMotion; export * from './hooks'; export type * from './types'; -export { AnimatedHeaderBase, HeaderBase }; export { createHeaderMotionScrollable }; +export { Bridge, Header, 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..6d1a682 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,8 +1,21 @@ 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 { + GestureStateChangeEvent, + PanGestureHandlerEventPayload, +} from 'react-native-gesture-handler'; +import type { WithDecayConfig } from 'react-native-reanimated'; export type Progress = SharedValue; export type HeaderMotionOffsetStrategy = @@ -14,18 +27,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 */ @@ -52,31 +74,37 @@ 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; +} + +export type HeaderPanDecayEvent = + GestureStateChangeEvent; + +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; - animatedHeaderBaseProps: AnimatedHeaderBaseMotionProps; + headerPanMomentumOffset: SharedValue; + scrollValues: SharedValue; activeScrollId: SharedValue | undefined; -} - -export interface AnimatedHeaderBaseMotionProps { - enableHeaderPan: boolean; scrollToRef: React.RefObject; - headerPanMomentumOffset: SharedValue; + originalHeaderHeight: number; } export interface ScrollManagerHeaderMotionContext { diff --git a/src/utils/header.tsx b/src/utils/header.tsx new file mode 100644 index 0000000..4579cb9 --- /dev/null +++ b/src/utils/header.tsx @@ -0,0 +1,52 @@ +import { + Fragment, + cloneElement, + isValidElement, + type ReactElement, +} 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'] +) { + 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 SlottableElement; +} + +export function cloneWithOnLayout( + child: SlottableElement, + 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';