diff --git a/.github/workflows/docs-build.yml b/.github/workflows/docs-build.yml new file mode 100644 index 0000000..b13851b --- /dev/null +++ b/.github/workflows/docs-build.yml @@ -0,0 +1,27 @@ +name: Docs build + +on: + pull_request: + paths: + - 'docs/**' + - '.github/workflows/docs-build.yml' + - '.github/workflows/docs-publish.yml' + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + docs-build: + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + + - name: Setup + uses: ./.github/actions/setup + + - name: Build docs + run: yarn --cwd docs build diff --git a/.github/workflows/docs-publish.yml b/.github/workflows/docs-publish.yml new file mode 100644 index 0000000..d421166 --- /dev/null +++ b/.github/workflows/docs-publish.yml @@ -0,0 +1,37 @@ +name: Docs publish + +on: + push: + branches: + - main + paths: + - 'docs/**' + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: write + +jobs: + docs-publish: + if: github.repository == 'pawicao/react-native-header-motion' + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + + - name: Setup + uses: ./.github/actions/setup + + - name: Build docs + run: yarn --cwd docs build + + - name: Deploy to GitHub Pages + uses: JamesIves/github-pages-deploy-action@v4 + with: + folder: docs/build + branch: gh-pages diff --git a/docs/.gitignore b/docs/.gitignore new file mode 100644 index 0000000..b2d6de3 --- /dev/null +++ b/docs/.gitignore @@ -0,0 +1,20 @@ +# Dependencies +/node_modules + +# Production +/build + +# Generated files +.docusaurus +.cache-loader + +# Misc +.DS_Store +.env.local +.env.development.local +.env.test.local +.env.production.local + +npm-debug.log* +yarn-debug.log* +yarn-error.log* diff --git a/docs/docs/api/create-header-motion-scrollable.md b/docs/docs/api/create-header-motion-scrollable.md new file mode 100644 index 0000000..cb9ade0 --- /dev/null +++ b/docs/docs/api/create-header-motion-scrollable.md @@ -0,0 +1,57 @@ +--- +sidebar_position: 9 +title: createHeaderMotionScrollable +--- + +# createHeaderMotionScrollable + +Factory function for creating reusable Header Motion-aware wrappers around custom scrollable components. + +This is the recommended way to integrate third-party scrollables like FlashList or LegendList. + +## Signature + +```tsx +function createHeaderMotionScrollable( + Component: ScrollableComponent, + options?: CreateHeaderMotionScrollableOptions +): HeaderMotionScrollableComponent; +``` + +## Options + +| Option | Type | Default | Description | +| ---------------------- | --------------------------------------- | ------------------------- | -------------------------------------------------------------------------------------------------------------------------------- | +| `displayName` | `string` | Auto-generated | Component name shown in React DevTools. | +| `isComponentAnimated` | `boolean` | `false` | Set to `true` when the component is already animated (skips `Animated.createAnimatedComponent`). | +| `contentContainerMode` | `'children' \| 'renderScrollComponent'` | `'renderScrollComponent'` | How Header Motion injects content offsetting. Use `'children'` for ScrollView-like, `'renderScrollComponent'` for FlatList-like. | + +## Examples + +### FlashList + +```tsx +import { FlashList } from '@shopify/flash-list'; +import { createHeaderMotionScrollable } from 'react-native-header-motion'; + +const HeaderMotionFlashList = createHeaderMotionScrollable(FlashList, { + displayName: 'HeaderMotionFlashList', +}); +``` + +### LegendList + +```tsx +import { AnimatedLegendList } from '@legendapp/list/reanimated'; +import { createHeaderMotionScrollable } from 'react-native-header-motion'; + +const HeaderMotionLegendList = createHeaderMotionScrollable( + AnimatedLegendList, + { + displayName: 'HeaderMotionLegendList', + isComponentAnimated: true, + } +); +``` + +The returned component accepts all the original component's props plus Header Motion-specific props (`scrollId`, `headerOffsetStrategy`, `ensureScrollableContentMinHeight`, `animatedRef`). diff --git a/docs/docs/api/header-motion-bridge.md b/docs/docs/api/header-motion-bridge.md new file mode 100644 index 0000000..96ac57c --- /dev/null +++ b/docs/docs/api/header-motion-bridge.md @@ -0,0 +1,32 @@ +--- +sidebar_position: 6 +title: HeaderMotion.Bridge +--- + +# HeaderMotion.Bridge + +Captures the current HeaderMotion context and exposes it through a render function. Use it to forward context into a navigation-rendered header subtree. + +## Usage + +```tsx + + {(ctx) => ( + ( + + + + ), + }} + /> + )} + +``` + +## Props + +| Prop | Type | Description | +| ---------- | ----------------------------------------------- | -------------------------------------------------------- | +| `children` | `(value: HeaderMotionBridgeValue) => ReactNode` | Render function that receives the bridged context value. | diff --git a/docs/docs/api/header-motion-flatlist.md b/docs/docs/api/header-motion-flatlist.md new file mode 100644 index 0000000..86ae33e --- /dev/null +++ b/docs/docs/api/header-motion-flatlist.md @@ -0,0 +1,31 @@ +--- +sidebar_position: 5 +title: HeaderMotion.FlatList +--- + +# HeaderMotion.FlatList + +A pre-wired `Animated.FlatList` that participates in Header Motion's scroll tracking and header offsetting. + +Works identically to a standard `FlatList` with the same additional Header Motion props as `HeaderMotion.ScrollView`. + +## Usage + +```tsx + } + keyExtractor={(item) => item.id} +/> +``` + +## Props + +Accepts all `Animated.FlatList` props, plus the same Header Motion-specific props: + +| Prop | Type | Default | Description | +| ---------------------------------- | --------------------------------------------------------- | ----------- | ---------------------------------------------------------- | +| `scrollId` | `string` | — | Unique identifier for multi-scroll setups. | +| `headerOffsetStrategy` | `'padding' \| 'margin' \| 'top' \| 'translate' \| 'none'` | `'padding'` | Controls how content is pushed below the header. | +| `ensureScrollableContentMinHeight` | `boolean` | `false` | Experimental. Adds minimum content height for short lists. | +| `animatedRef` | `AnimatedRef` | — | Your own animated ref for programmatic control. | diff --git a/docs/docs/api/header-motion-header-dynamic.md b/docs/docs/api/header-motion-header-dynamic.md new file mode 100644 index 0000000..ac5d7fa --- /dev/null +++ b/docs/docs/api/header-motion-header-dynamic.md @@ -0,0 +1,29 @@ +--- +sidebar_position: 3 +title: HeaderMotion.Header.Dynamic +--- + +# HeaderMotion.Header.Dynamic + +Marks the part of the header whose layout defines the collapse distance (`progressThreshold`). + +Wrap the section that visually disappears during collapse with this component. + +## Usage + +```tsx + + + {/* This section's height defines progressThreshold */} + + {/* Sticky section */} + +``` + +## Props + +Accepts all `Animated.View` props, plus: + +| Prop | Type | Default | Description | +| --------- | --------- | ------- | ------------------------------------------------------------------------------------------------------------- | +| `asChild` | `boolean` | `false` | Injects the dynamic measurement into a single child element instead of rendering the default `Animated.View`. | diff --git a/docs/docs/api/header-motion-header.md b/docs/docs/api/header-motion-header.md new file mode 100644 index 0000000..a30c119 --- /dev/null +++ b/docs/docs/api/header-motion-header.md @@ -0,0 +1,33 @@ +--- +sidebar_position: 2 +title: HeaderMotion.Header +--- + +# HeaderMotion.Header + +The main header container. It measures the total header height and can optionally make the header surface pannable. + +By default, the header is absolutely positioned above content (`overlay: true`). + +## Usage + +```tsx + + + {/* collapsible section */} + + {/* sticky section */} + +``` + +## Props + +Accepts all `Animated.View` props, plus: + +| Prop | Type | Default | Description | +| ---------------------------- | -------------------------------------------------------------------- | ------- | ---------------------------------------------------------------------------------------------------------- | +| `overlay` | `boolean` | `true` | Absolutely positions the header above content. Disable only if you want the header in normal layout flow. | +| `pannable` | `boolean` | `false` | Allows dragging on the header surface to drive the scroll interaction. | +| `panDecayConfig` | `WithDecayConfig \| (event: HeaderPanDecayEvent) => WithDecayConfig` | — | Customizes momentum animation after a header pan ends. | +| `withGestureHandlerRootView` | `boolean` | `false` | Wraps the gesture subtree in `GestureHandlerRootView` when needed. | +| `asChild` | `boolean` | `false` | Injects the total-height measurement into a single child instead of rendering the default `Animated.View`. | diff --git a/docs/docs/api/header-motion-navigation-bridge.md b/docs/docs/api/header-motion-navigation-bridge.md new file mode 100644 index 0000000..d0bebf4 --- /dev/null +++ b/docs/docs/api/header-motion-navigation-bridge.md @@ -0,0 +1,42 @@ +--- +sidebar_position: 7 +title: HeaderMotion.NavigationBridge +--- + +# HeaderMotion.NavigationBridge + +Re-provides a previously captured HeaderMotion context value in another subtree. Use together with `HeaderMotion.Bridge`. + +## Usage + +```tsx + + {(ctx) => ( + ( + + + + ), + }} + /> + )} + +``` + +Inside `MyHeader`, all Header Motion hooks work normally: + +```tsx +function MyHeader() { + const { progress, progressThreshold } = useMotionProgress(); + // ... +} +``` + +## Props + +| Prop | Type | Description | +| ---------- | ------------------------- | ------------------------------------------------------------ | +| `value` | `HeaderMotionBridgeValue` | The context value captured by `HeaderMotion.Bridge`. | +| `children` | `ReactNode` | The subtree that should have access to HeaderMotion context. | diff --git a/docs/docs/api/header-motion-scroll-manager.md b/docs/docs/api/header-motion-scroll-manager.md new file mode 100644 index 0000000..1a8481f --- /dev/null +++ b/docs/docs/api/header-motion-scroll-manager.md @@ -0,0 +1,50 @@ +--- +sidebar_position: 8 +title: HeaderMotion.ScrollManager +--- + +# HeaderMotion.ScrollManager + +A render-prop component for managing custom scrollables. Prefer `createHeaderMotionScrollable()` for most integrations. + +Use `ScrollManager` only when the factory approach doesn't give you enough flexibility. + +## Usage + +```tsx + + {(scrollableProps, { originalHeaderHeight, contentContainerMinHeight }) => ( + + + {/* content */} + + + )} + +``` + +## Props + +| Prop | Type | Default | Description | +| -------------------- | ----------------------------------------------------- | ------- | ----------------------------------------------------------- | +| `scrollId` | `string` | — | Unique identifier for multi-scroll setups. | +| `children` | `(scrollableProps, headerMotionContext) => ReactNode` | — | Render function receiving managed props and layout context. | +| `refreshControl` | `ReactElement` | — | Refresh control element. | +| `refreshing` | `boolean` | — | Whether refresh is active. | +| `onRefresh` | `() => void` | — | Refresh callback. | +| `progressViewOffset` | `number` | — | Custom offset for the refresh indicator. | +| `animatedRef` | `AnimatedRef` | — | External animated ref. | + +### Render function arguments + +**`scrollableProps`** contains: + +- `onScroll` — managed scroll handler +- `onLayout` — layout handler for min-height calculations +- `refreshControl` — resolved refresh control (if applicable) +- `ref` — animated ref for the scrollable + +**`headerMotionContext`** contains: + +- `originalHeaderHeight: number` — measured header height for content offsetting +- `contentContainerMinHeight?: number` — computed min height (when `ensureScrollableContentMinHeight` is used) diff --git a/docs/docs/api/header-motion-scrollview.md b/docs/docs/api/header-motion-scrollview.md new file mode 100644 index 0000000..bd1a703 --- /dev/null +++ b/docs/docs/api/header-motion-scrollview.md @@ -0,0 +1,31 @@ +--- +sidebar_position: 4 +title: HeaderMotion.ScrollView +--- + +# HeaderMotion.ScrollView + +A pre-wired `Animated.ScrollView` that participates in Header Motion's scroll tracking and header offsetting. + +It's a drop-in replacement for `Animated.ScrollView` with additional Header Motion capabilities. + +## Usage + +```tsx +{/* scrollable content */} +``` + +## Props + +Accepts all `Animated.ScrollView` props, plus: + +| Prop | Type | Default | Description | +| ---------------------------------- | --------------------------------------------------------- | ----------- | ----------------------------------------------------------------------------------------------- | +| `scrollId` | `string` | — | Unique identifier for multi-scroll setups where one header is shared across scrollables. | +| `headerOffsetStrategy` | `'padding' \| 'margin' \| 'top' \| 'translate' \| 'none'` | `'padding'` | Controls how content is pushed below the measured header. | +| `ensureScrollableContentMinHeight` | `boolean` | `false` | Experimental. Adds minimum content height so short content can still collapse the header. | +| `animatedRef` | `AnimatedRef` | — | Lets you reuse your own animated ref (from `useAnimatedRef()`) for programmatic scroll control. | + +Standard scroll callbacks (`onScroll`, `onScrollBeginDrag`, `onScrollEndDrag`, `onMomentumScrollBegin`, `onMomentumScrollEnd`) work as expected alongside the internal handlers. + +Refresh control props (`refreshing`, `onRefresh`, `refreshControl`, `progressViewOffset`) are also supported. diff --git a/docs/docs/api/header-motion.md b/docs/docs/api/header-motion.md new file mode 100644 index 0000000..ea2ea6b --- /dev/null +++ b/docs/docs/api/header-motion.md @@ -0,0 +1,39 @@ +--- +sidebar_position: 1 +title: HeaderMotion +--- + +# HeaderMotion + +The root provider for a header-motion setup. It tracks header measurements, derives the shared `progress` value, and exposes the compound subcomponents. + +## Usage + +```tsx +import HeaderMotion from 'react-native-header-motion'; + +{/* Header, Bridge, ScrollView, etc. */}; +``` + +## Props + +| Prop | Type | Default | Description | +| ----------------------- | ----------------------------------------------- | --------------------- | -------------------------------------------------------------------------------------- | +| `progressThreshold` | `number \| (measuredDynamic: number) => number` | `(h) => h` | Collapse distance in pixels. When a function, it receives the measured dynamic height. | +| `measureDynamic` | `(e: LayoutChangeEvent) => number` | height from layout | Controls what value is read from `Header.Dynamic`'s layout event. | +| `measureDynamicMode` | `'mount' \| 'update'` | `'mount'` | `'mount'` measures once. `'update'` re-measures on subsequent layouts. | +| `activeScrollId` | `SharedValue` | — | Identifies which scrollable owns progress in multi-scroll setups. | +| `progressExtrapolation` | `ExtrapolationType` | `Extrapolation.CLAMP` | Controls how `progress` behaves outside `[0, 1]`. | +| `children` | `ReactNode` | — | Components participating in header motion. | + +## Compound components + +Access subcomponents as properties of the default export: + +- `HeaderMotion.Header` +- `HeaderMotion.Header.Dynamic` +- `HeaderMotion.Bridge` +- `HeaderMotion.NavigationBridge` +- `HeaderMotion.ScrollView` +- `HeaderMotion.FlatList` +- `HeaderMotion.ScrollManager` diff --git a/docs/docs/api/use-active-scroll-id.md b/docs/docs/api/use-active-scroll-id.md new file mode 100644 index 0000000..df95eb4 --- /dev/null +++ b/docs/docs/api/use-active-scroll-id.md @@ -0,0 +1,42 @@ +--- +sidebar_position: 11 +title: useActiveScrollId +--- + +# useActiveScrollId + +Creates a synchronized pair of React state and Reanimated shared value for tracking the active scroll ID in multi-scroll setups. + +## Signature + +```tsx +function useActiveScrollId( + initialId: T +): [ActiveScrollIdValues, SetActiveScrollId]; +``` + +## Returns + +A tuple of: + +| Index | Type | Description | +| ----- | ---------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------- | +| `[0]` | `{ state: T, sv: SharedValue }` | `state` is for React UI logic (e.g. active tab highlight). `sv` is the shared value to pass to `HeaderMotion`'s `activeScrollId` prop. | +| `[1]` | `(newId: T) => void` | Setter that updates both `state` and `sv` in sync. | + +## Usage + +```tsx +import { useActiveScrollId } from 'react-native-header-motion'; + +const [activeScrollId, setActiveScrollId] = useActiveScrollId<'A' | 'B'>('A'); + +// Pass to HeaderMotion + + +// Use state for UI +const isTabAActive = activeScrollId.state === 'A'; + +// Update on page change +setActiveScrollId('B'); +``` diff --git a/docs/docs/api/use-header-motion-bridge.md b/docs/docs/api/use-header-motion-bridge.md new file mode 100644 index 0000000..ddddc75 --- /dev/null +++ b/docs/docs/api/use-header-motion-bridge.md @@ -0,0 +1,34 @@ +--- +sidebar_position: 12 +title: useHeaderMotionBridge +--- + +# useHeaderMotionBridge + +Returns the full internal HeaderMotion bridge value. This includes everything from `useMotionProgress()` plus measurement callbacks and scroll synchronization internals. + +:::caution +Most app code should use `useMotionProgress()` instead. Only use this hook when you need direct access to the internal bridge value for advanced context-bridging scenarios. +::: + +## Signature + +```tsx +function useHeaderMotionBridge(): HeaderMotionBridgeValue; +``` + +## Returns + +| Property | Type | Description | +| ------------------------- | ---------------------------------- | ----------------------------------------------------- | +| `progress` | `SharedValue` | The collapse progress (`0` to `1`). | +| `progressThreshold` | `SharedValue` | Collapse distance in pixels. | +| `measureTotalHeight` | `(e: LayoutChangeEvent) => void` | Callback wired to `HeaderMotion.Header`. | +| `measureDynamic` | `(e: LayoutChangeEvent) => void` | Callback wired to `HeaderMotion.Header.Dynamic`. | +| `headerPanMomentumOffset` | `SharedValue` | Pan momentum tracking value. | +| `scrollValues` | `SharedValue` | Internal scroll state for all registered scrollables. | +| `activeScrollId` | `SharedValue \| undefined` | Currently active scroll ID. | +| `scrollToRef` | `RefObject` | Imperative scroll function ref. | +| `originalHeaderHeight` | `number` | Measured total header height. | + +Must be called within a `HeaderMotion` or `HeaderMotion.NavigationBridge` subtree. diff --git a/docs/docs/api/use-motion-progress.md b/docs/docs/api/use-motion-progress.md new file mode 100644 index 0000000..1e7f1ce --- /dev/null +++ b/docs/docs/api/use-motion-progress.md @@ -0,0 +1,59 @@ +--- +sidebar_position: 10 +title: useMotionProgress +--- + +# useMotionProgress + +The primary hook for reading header animation state. Returns the shared `progress` value and the `progressThreshold`. + +## Signature + +```tsx +function useMotionProgress(): { + progress: SharedValue; + progressThreshold: SharedValue; +}; +``` + +## Returns + +| Property | Type | Description | +| ------------------- | --------------------- | -------------------------------------------------------------------------------------------------------------- | +| `progress` | `SharedValue` | Ranges from `0` (expanded) to `1` (collapsed). Behavior outside this range depends on `progressExtrapolation`. | +| `progressThreshold` | `SharedValue` | The collapse distance in pixels. Read it inside worklets with `.get()`. | + +## Usage + +```tsx +import { useMotionProgress } from 'react-native-header-motion'; +import { + useAnimatedStyle, + interpolate, + Extrapolation, +} from 'react-native-reanimated'; + +function MyHeader() { + const { progress, progressThreshold } = useMotionProgress(); + + const style = useAnimatedStyle(() => { + const threshold = progressThreshold.get(); + return { + transform: [ + { + translateY: interpolate( + progress.get(), + [0, 1], + [0, -threshold], + Extrapolation.CLAMP + ), + }, + ], + }; + }); + + return {/* ... */}; +} +``` + +Must be called within a `HeaderMotion` or `HeaderMotion.NavigationBridge` subtree. diff --git a/docs/docs/api/use-scroll-manager.md b/docs/docs/api/use-scroll-manager.md new file mode 100644 index 0000000..a0ff9e2 --- /dev/null +++ b/docs/docs/api/use-scroll-manager.md @@ -0,0 +1,68 @@ +--- +sidebar_position: 13 +title: useScrollManager +--- + +# useScrollManager + +Hook-level API for managing a custom scrollable. This is the hook equivalent of `HeaderMotion.ScrollManager`. + +Prefer `createHeaderMotionScrollable()` for most cases. + +## Signature + +```tsx +function useScrollManager( + scrollId?: string, + options?: UseScrollManagerOptions +): ScrollManagerConfig; +``` + +## Parameters + +| Parameter | Type | Description | +| ------------------------------------------ | -------------- | ------------------------------------------ | +| `scrollId` | `string` | Unique identifier for multi-scroll setups. | +| `options.animatedRef` | `AnimatedRef` | External animated ref. | +| `options.refreshControl` | `ReactElement` | Refresh control element. | +| `options.refreshing` | `boolean` | Whether refresh is active. | +| `options.onRefresh` | `() => void` | Refresh callback. | +| `options.progressViewOffset` | `number` | Custom refresh indicator offset. | +| `options.onScroll` | scroll handler | Consumer scroll handler (JS or worklet). | +| `options.onScrollBeginDrag` | callback | Drag begin handler. | +| `options.onScrollEndDrag` | callback | Drag end handler. | +| `options.onMomentumScrollBegin` | callback | Momentum begin handler. | +| `options.onMomentumScrollEnd` | callback | Momentum end handler. | +| `options.ensureScrollableContentMinHeight` | `boolean` | Experimental min-height behavior. | + +## Returns + +| Property | Type | Description | +| ----------------------------------------------- | --------------------------- | ----------------------------------------------------- | +| `scrollableProps.onScroll` | handler | Managed scroll handler to spread onto the scrollable. | +| `scrollableProps.onLayout` | handler | Layout handler for min-height calculations. | +| `scrollableProps.refreshControl` | `ReactElement \| undefined` | Resolved refresh control. | +| `scrollableProps.ref` | `AnimatedRef` | Animated ref for the scrollable. | +| `headerMotionContext.originalHeaderHeight` | `number` | Header height for content offsetting. | +| `headerMotionContext.contentContainerMinHeight` | `number \| undefined` | Computed minimum content height. | + +## Usage + +```tsx +import { useScrollManager } from 'react-native-header-motion'; +import Animated from 'react-native-reanimated'; + +function CustomScrollable() { + const { scrollableProps, headerMotionContext } = useScrollManager('custom'); + + return ( + + + {/* content */} + + + ); +} +``` + +Must be called within a `HeaderMotion` subtree. diff --git a/docs/docs/guides/animating-with-progress.md b/docs/docs/guides/animating-with-progress.md new file mode 100644 index 0000000..aba5bcc --- /dev/null +++ b/docs/docs/guides/animating-with-progress.md @@ -0,0 +1,115 @@ +--- +sidebar_position: 2 +title: Animating with progress +description: Use the progress shared value to build any scroll-driven animation. +--- + +# Animating with progress + +This is where the fun begins. You've wired up your header with `HeaderMotion.Header` and `HeaderMotion.Header.Dynamic` — now you can use the `progress` shared value to animate anything you want. + +## Getting the values + +The `useMotionProgress()` hook returns two shared values: + +```tsx +const { progress, progressThreshold } = useMotionProgress(); +``` + +- **`progress`** — a `SharedValue` going from `0` (header fully expanded) to `1` (header fully collapsed) +- **`progressThreshold`** — a `SharedValue` containing the collapse distance in pixels + +Both are Reanimated shared values, so you use them inside `useAnimatedStyle` and other Reanimated APIs. + +## Common animation patterns + +Below are building blocks you can mix and match. Each one is a `useAnimatedStyle` call — combine as many as you need. + +### Slide the header up + +The most fundamental pattern. Move the entire header container upward by the threshold distance so the dynamic section scrolls off screen: + +```tsx +const containerStyle = useAnimatedStyle(() => { + const threshold = progressThreshold.get(); + return { + transform: [ + { translateY: interpolate(progress.get(), [0, 1], [0, -threshold]) }, + ], + }; +}); +``` + +### Sticky element (counter-translate) + +Make an element appear to stay fixed while the rest of the header moves. Apply an equal-and-opposite translation: + +```tsx +const stickyStyle = useAnimatedStyle(() => { + const threshold = progressThreshold.get(); + return { + transform: [ + { translateY: interpolate(progress.get(), [0, 1], [0, threshold]) }, + ], + }; +}); +``` + +### Opacity fade + +Fade content out before the header fully collapses — in this example, it disappears by the time `progress` reaches `0.6`: + +```tsx +const fadeStyle = useAnimatedStyle(() => ({ + opacity: interpolate(progress.get(), [0, 0.6], [1, 0]), +})); +``` + +### Scale + +Shrink content as the header collapses: + +```tsx +const scaleStyle = useAnimatedStyle(() => ({ + transform: [{ scale: interpolate(progress.get(), [0, 1], [1, 0.8]) }], +})); +``` + +### Parallax + +Move content at a fraction of the scroll speed for a depth effect: + +```tsx +const parallaxStyle = useAnimatedStyle(() => { + const threshold = progressThreshold.get(); + return { + transform: [ + { translateY: interpolate(progress.get(), [0, 1], [0, threshold * 0.5]) }, + ], + }; +}); +``` + +### Color transition + +Use `interpolateColor` for smooth color shifts: + +```tsx +import { interpolateColor } from 'react-native-reanimated'; + +const backgroundStyle = useAnimatedStyle(() => ({ + backgroundColor: interpolateColor( + progress.get(), + [0, 1], + ['#304077', '#1a1a2e'] + ), +})); +``` + +## Combining patterns + +It's just Reanimated. Because `progress` is a standard Reanimated `SharedValue`, you're not limited to the patterns above — anything Reanimated can do, you can drive with `progress`. These patterns are designed to compose; a typical header might combine a container slide, a sticky element, and a fade + scale on the dynamic section — exactly like the [Quick Start](../quick-start) example. + +## What's next? + +Learn about the scrollable components that drive the progress value in [Default scrollables](./default-scrollables). diff --git a/docs/docs/guides/composing-with-native-headers.md b/docs/docs/guides/composing-with-native-headers.md new file mode 100644 index 0000000..2284e38 --- /dev/null +++ b/docs/docs/guides/composing-with-native-headers.md @@ -0,0 +1,143 @@ +--- +sidebar_position: 5 +title: Composing with native headers +description: Combine native navigation elements with custom animated headers. +--- + +# Composing with native headers + +Replacing the navigation header entirely with a custom one means you lose native elements you might want to keep — the back button, the title, `headerRight` actions, and so on. Recreating those from scratch is possible (using `@react-navigation/elements`, for instance), but it's tedious and easy to get wrong. + +A better approach is to **compose** the native header with your animated header so you get the best of both worlds. + +## The idea: transparent overlay + +The trick is simple: + +1. Set the navigation header to `headerTransparent: true` so it floats over the content without taking up layout space +2. Place your `HeaderMotion.Header` inline on the screen — it renders _underneath_ the transparent native header +3. The native elements (back button, title, `headerRight`, `headerLeft`) appear on top, while your custom animated header does its thing behind them + +Because the header is inline rather than in a separate navigation subtree, there's no need for bridging — `useMotionProgress()` works directly inside it. + +## Example + +```tsx +import HeaderMotion, { useMotionProgress } from 'react-native-header-motion'; +import { Stack } from 'expo-router'; +import { StyleSheet, View } from 'react-native'; +import Animated, { + Extrapolation, + interpolate, + useAnimatedStyle, +} from 'react-native-reanimated'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; + +const NATIVE_HEADER_HEIGHT = 48; + +export default function Screen() { + return ( + + , + }} + /> + + + {/* your scrollable content */} + + + ); +} + +function CollapsibleHeader() { + const { progress, progressThreshold } = useMotionProgress(); + const insets = useSafeAreaInsets(); + + const containerStyle = useAnimatedStyle(() => { + const threshold = progressThreshold.get(); + return { + transform: [ + { + translateY: interpolate( + progress.get(), + [0, 1], + [0, -threshold], + Extrapolation.CLAMP + ), + }, + ], + }; + }); + + const dynamicStyle = useAnimatedStyle(() => ({ + opacity: interpolate(progress.get(), [0, 0.6], [1, 0], Extrapolation.CLAMP), + transform: [ + { + scale: interpolate( + progress.get(), + [0, 1], + [1, 0.8], + Extrapolation.CLAMP + ), + }, + ], + })); + + return ( + + + + + {/* your collapsible content */} + + + + + ); +} + +const styles = StyleSheet.create({ + header: { + backgroundColor: '#304077', + zIndex: 1, + }, + dynamicWrapper: { + overflow: 'hidden', + }, + dynamicContent: { + padding: 16, + }, +}); +``` + +:::info +When using `headerTransparent`, the native header no longer pushes your content down. You'll need to add `paddingTop` to your custom header to account for the safe area insets **plus** the native header height. The native header height can be obtained from `useHeaderHeight()` (from `@react-navigation/elements`) or estimated as a constant. +::: + +:::caution +Setting `zIndex: 1` on `HeaderMotion.Header` is important here. Since the header and the scrollable are siblings in the same layout (unlike the native header which renders in its own layer), you need the z-index to ensure the header draws on top of the scrollable content as they overlap during animation. +::: + +## Why this works well + +- **Native back button** — you get the platform-correct back gesture and button for free +- **Native title** — the title animates with the standard navigation transitions +- **`headerRight` / `headerLeft`** — any actions you configure through navigation options just work +- **Custom animations** — your `HeaderMotion.Header` still slides, fades, and scales as you've defined + +This is especially useful for screens that need to feel "native" (with proper navigation transitions and gestures) while still having a rich, animated header area. + +## What's next? + +Learn how to share a single header across multiple tabs or pager pages in [Multiple tabs/pages](./multiple-tabs-pages). diff --git a/docs/docs/guides/consumer-scroll-handlers.md b/docs/docs/guides/consumer-scroll-handlers.md new file mode 100644 index 0000000..b947e05 --- /dev/null +++ b/docs/docs/guides/consumer-scroll-handlers.md @@ -0,0 +1,49 @@ +--- +sidebar_position: 15 +title: Consumer scroll handlers +--- + +# Consumer scroll handlers + +Header Motion internally uses an animated scroll handler to track scroll position and drive header animations. Your own scroll callbacks still work alongside it — you don't have to choose between the library's behavior and your own logic. + +## Standard React Native callbacks + +You can pass the familiar React Native scroll callbacks directly. These are bridged via `scheduleOnRN` so they fire on the JS thread: + +```tsx + console.log('offset:', e.nativeEvent.contentOffset.y)} + onScrollBeginDrag={(e) => console.log('drag started')} + onScrollEndDrag={(e) => console.log('drag ended')} + onMomentumScrollBegin={(e) => console.log('momentum started')} + onMomentumScrollEnd={(e) => console.log('momentum ended')} +> + {/* content */} + +``` + +## Reanimated animated scroll handler + +If you need worklet-based scroll handling, pass a `useAnimatedScrollHandler` as the `onScroll` prop. Header Motion composes it with its internal handler via `useComposedEventHandler`, so both run on the UI thread: + +```tsx +const onScroll = useAnimatedScrollHandler({ + onScroll: () => { + 'worklet'; + console.log('consumer onScroll worklet'); + }, +}); + + + {/* content */} +; +``` + +## Mixing approaches + +Both approaches are compatible. Header Motion detects whether your `onScroll` is a standard callback or an animated handler and handles each accordingly. The library's internal scroll tracking continues to work regardless of which approach you use. + +--- + +One more edge case to cover: learn how to handle screens where the content is too short to fully collapse the header in [Short scrollable content](./short-scrollable-content). diff --git a/docs/docs/guides/custom-scrollables.md b/docs/docs/guides/custom-scrollables.md new file mode 100644 index 0000000..3bd8e20 --- /dev/null +++ b/docs/docs/guides/custom-scrollables.md @@ -0,0 +1,59 @@ +--- +sidebar_position: 8 +title: Custom scrollables +--- + +# Custom scrollables + +Header Motion ships with `HeaderMotion.ScrollView` and `HeaderMotion.FlatList`, but you can integrate **any** scrollable component via the `createHeaderMotionScrollable()` factory. The resulting component works just like the built-in ones — it accepts all the original props plus Header Motion-specific ones like `scrollId` and `headerOffsetStrategy`. + +## FlashList + +```tsx +import { FlashList } from '@shopify/flash-list'; +import { createHeaderMotionScrollable } from 'react-native-header-motion'; + +const HeaderMotionFlashList = createHeaderMotionScrollable(FlashList, { + displayName: 'HeaderMotionFlashList', +}); +``` + +Use it anywhere you'd use a regular `FlashList`: + +```tsx + `${item.index}`} + renderItem={({ item }) => } + estimatedItemSize={80} +/> +``` + +## LegendList + +LegendList exports a pre-animated variant, `AnimatedLegendList`, which is what you should pass to the factory. Set `isComponentAnimated: true` to skip the internal `Animated.createAnimatedComponent` wrapping: + +```tsx +import { AnimatedLegendList } from '@legendapp/list/reanimated'; +import { createHeaderMotionScrollable } from 'react-native-header-motion'; + +const HeaderMotionLegendList = createHeaderMotionScrollable( + AnimatedLegendList, + { + displayName: 'HeaderMotionLegendList', + isComponentAnimated: true, + } +); +``` + +## Factory options + +| Option | Type | Default | Description | +| ---------------------- | --------------------------------------- | ------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `displayName` | `string` | Auto-derived from the component name | Sets the React `displayName` for dev tools. | +| `isComponentAnimated` | `boolean` | `false` | When `true`, the factory skips `Animated.createAnimatedComponent()`. Set this when the component is already animated. | +| `contentContainerMode` | `'children' \| 'renderScrollComponent'` | `'renderScrollComponent'` | `'children'` wraps children in an inner `Animated.View` (ScrollView-like). `'renderScrollComponent'` injects a custom scroll component that wraps the content (FlatList-like). | + +## What's next? + +If `createHeaderMotionScrollable()` doesn't give you enough control, you can drop down to the render-prop or hook API in [Using Scroll Manager](./using-scroll-manager). diff --git a/docs/docs/guides/default-scrollables.md b/docs/docs/guides/default-scrollables.md new file mode 100644 index 0000000..a43fcb2 --- /dev/null +++ b/docs/docs/guides/default-scrollables.md @@ -0,0 +1,75 @@ +--- +sidebar_position: 3 +title: Default scrollables +description: Built-in scrollable components that drive the header progress value. +--- + +# Default scrollables + +Scrollable components are equally important as the header in Header Motion — they're what drives the `progress` value as the user scrolls. The library ships with two pre-wired scrollables that you can drop in with minimal setup. + +## HeaderMotion.ScrollView + +A drop-in replacement for React Native's `ScrollView`. It works exactly like you'd expect — same props, same behavior — but it's connected to Header Motion's scroll tracking under the hood. + +```tsx +import HeaderMotion from 'react-native-header-motion'; + +export default function Screen() { + return ( + + + + Your scrollable content goes here. + + + ); +} +``` + +## HeaderMotion.FlatList + +Same story for `FlatList` — use `HeaderMotion.FlatList` wherever you'd use a regular `FlatList`: + +```tsx +import HeaderMotion from 'react-native-header-motion'; + +export default function Screen() { + return ( + + + } + /> + + ); +} +``` + +No new API to learn for basic usage. If you know `ScrollView` and `FlatList`, you already know how to use these. + +## Additional props + +On top of the standard React Native props, Header Motion scrollables accept a few extra props: + +| Prop | Type | Description | +| ---------------------------------- | --------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------- | +| `scrollId` | `string` | Identifies this scrollable in multi-scroll setups (e.g., tabs). Defaults to an internal ID. | +| `headerOffsetStrategy` | `'padding' \| 'margin' \| 'top' \| 'translate' \| 'none'` | How the content is offset to appear below the header. Defaults to `'padding'`. | +| `ensureScrollableContentMinHeight` | `boolean` | When `true`, ensures the content is tall enough to scroll even if the actual content is short. Defaults to `false`. | +| `animatedRef` | `AnimatedRef` | Pass your own Reanimated animated ref if you need external access to the scrollable. | + +:::info +You can also pass standard scroll event handlers like `onScroll`, `onScrollBeginDrag`, etc. They'll be composed with Header Motion's internal handlers — your callbacks still fire normally. +::: + +## Custom scrollables + +For third-party scrollable components like [FlashList](https://shopify.github.io/flash-list/) or [LegendList](https://github.com/LegendApp/legend-list), use the `createHeaderMotionScrollable()` factory. For more information, check the [Custom scrollables](./custom-scrollables) guide. + +For even more control, you can manage the scroll integration yourself using `HeaderMotion.ScrollManager` or the `useScrollManager()` hook. See [Using ScrollManager](./using-scroll-manager) for details. + +## What's next? + +If your header is rendered by a navigation library (Expo Router, React Navigation), you'll need to bridge the context. Learn how in [Navigation bridging](./navigation-bridging). diff --git a/docs/docs/guides/dynamic-header-measurement.md b/docs/docs/guides/dynamic-header-measurement.md new file mode 100644 index 0000000..cb915ef --- /dev/null +++ b/docs/docs/guides/dynamic-header-measurement.md @@ -0,0 +1,101 @@ +--- +sidebar_position: 1 +title: Dynamic header measurement +description: How HeaderMotion measures your header and derives the collapse distance. +--- + +# Dynamic header measurement + +Header Motion automatically measures your header so it knows exactly how many pixels of scroll should collapse it. Understanding how this works will help you build headers that behave predictably. + +## Two layers of measurement + +There are two components involved: + +- **`HeaderMotion.Header`** measures the **total height** of your header via `onLayout`. This value is used to offset your scrollable content so it appears below the header. +- **`HeaderMotion.Header.Dynamic`** measures the **collapsible part** — the section that should disappear as the user scrolls. This value feeds into `progressThreshold`, which determines the scroll distance for `progress` to go from `0` to `1`. + +```tsx + + {/* This stays visible — it's outside Header.Dynamic */} + + My App + + + {/* This is the collapsible section */} + + + + Welcome back! + + + + +``` + +## `progressThreshold` + +The `progressThreshold` prop on `HeaderMotion` controls how many pixels of scroll it takes for `progress` to go from `0` (expanded) to `1` (collapsed). You can pass it in two ways: + +**As a number** — a fixed pixel value: + +```tsx + +``` + +**As a function** — receives the measured dynamic height and returns the threshold: + +```tsx + measuredDynamic + 20}> +``` + +When you don't pass `progressThreshold` at all, the default behavior is `(measuredDynamic) => measuredDynamic` — the collapse distance equals the dynamic section's height exactly. + +## Custom measurement with `measureDynamic` + +By default, `HeaderMotion.Header.Dynamic` measures its height using the `onLayout` event: + +```tsx +(e) => e.nativeEvent.layout.height; +``` + +If you need a different measurement strategy — for example, measuring other properties or applying a multiplier — you can pass a custom `measureDynamic` function to `HeaderMotion`: + +```tsx + e.nativeEvent.layout.height * 0.75}> +``` + +## `measureDynamicMode` + +The `measureDynamicMode` prop on `HeaderMotion` controls **when** the dynamic section is re-measured: + +- **`'mount'`** (default) — measures once when `HeaderMotion.Header.Dynamic` first mounts. Subsequent layout changes are ignored. +- **`'update'`** — re-measures every time the layout changes. Use this when your dynamic section's height can change after mount (e.g., dynamic content loading). + +```tsx + +``` + +:::caution +Using `'update'` mode means the `progressThreshold` can change mid-scroll, which may cause visual jumps. Prefer `'mount'` unless you have a genuine need for dynamic re-measurement. +::: + +## `asChild` + +Both `HeaderMotion.Header` and `HeaderMotion.Header.Dynamic` accept an `asChild` prop. When `true`, the component does not render its own `Animated.View` — instead, it forwards measurement behavior to the single child element you provide. + +```tsx +import { LinearGradient } from 'expo-linear-gradient'; + + + + {/* your header content */} + +; +``` + +This is useful when you need full control over the wrapper element. + +## What's next? + +Now that you know how measurement works, head over to [Fixed progress threshold](./fixed-progress-threshold) to learn how to skip dynamic measurement when you already know the collapse distance. diff --git a/docs/docs/guides/external-refs.md b/docs/docs/guides/external-refs.md new file mode 100644 index 0000000..04b0bba --- /dev/null +++ b/docs/docs/guides/external-refs.md @@ -0,0 +1,49 @@ +--- +sidebar_position: 14 +title: External refs +--- + +# External refs + +Header Motion scrollables manage their own animated ref internally to track scroll position and drive header animations. If you need access to the scrollable ref yourself — for programmatic scrolling, measuring, or other imperative operations — you can pass your own ref via the `animatedRef` prop. + +:::caution Important +The ref must be an animated ref from Reanimated (`useAnimatedRef()`), not a regular React ref. +::: + +## Example + +```tsx +import { useAnimatedRef, scrollTo } from 'react-native-reanimated'; +import { scheduleOnUI } from 'react-native-worklets'; + +function Screen() { + const scrollRef = useAnimatedRef(); + + const handleScrollToTop = () => { + scheduleOnUI(() => { + 'worklet'; + scrollTo(scrollRef, 0, 0, true); + })(); + }; + + return ( + + + {/* content */} + +