From 9d5df82abac442bd074b4003aef3fd334dd90202 Mon Sep 17 00:00:00 2001 From: Oskar Pawica Date: Sat, 15 Aug 2026 10:04:26 +0200 Subject: [PATCH 1/2] feat: add high-level collapsible header API Adds Collapsible and CollapsibleTabs compound components, a composable preset system, pluggable pager adapters, contextual scrollId resolution, and the useCollapsibleHeader hook. All changes are additive on top of the existing low-level primitives. Co-Authored-By: Claude Fable 5 --- README.md | 27 ++ docs/docs/high-level/collapsible-tabs.md | 103 +++++ docs/docs/high-level/collapsible.md | 114 +++++ docs/docs/high-level/presets.md | 73 ++++ docs/sidebars.ts | 10 + example/src/app/collapsible-simple.tsx | 55 +++ .../app/collapsible-tabs-default-pager.tsx | 108 +++++ example/src/app/collapsible-tabs.tsx | 93 ++++ example/src/app/index.tsx | 20 + src/collapsible/Collapsible.tsx | 360 ++++++++++++++++ src/collapsible/CollapsibleTabs.tsx | 402 ++++++++++++++++++ .../__tests__/Collapsible.test.tsx | 227 ++++++++++ .../__tests__/CollapsibleTabs.test.tsx | 312 ++++++++++++++ .../__tests__/pagerAdapters.test.tsx | 122 ++++++ src/collapsible/__tests__/presets.test.ts | 176 ++++++++ .../__tests__/useCollapsibleHeader.test.ts | 128 ++++++ src/collapsible/context.ts | 53 +++ src/collapsible/index.ts | 49 +++ src/collapsible/pagerAdapters.tsx | 220 ++++++++++ src/collapsible/presets.ts | 280 ++++++++++++ src/collapsible/types.ts | 85 ++++ src/collapsible/useCollapsibleHeader.ts | 87 ++++ src/collapsible/useCollapsibleTabs.ts | 34 ++ src/components/HeaderMotion.tsx | 27 +- src/context.ts | 13 + src/hooks/useScrollManager.ts | 9 +- src/index.ts | 1 + src/utils/values.ts | 31 ++ 28 files changed, 3190 insertions(+), 29 deletions(-) create mode 100644 docs/docs/high-level/collapsible-tabs.md create mode 100644 docs/docs/high-level/collapsible.md create mode 100644 docs/docs/high-level/presets.md create mode 100644 example/src/app/collapsible-simple.tsx create mode 100644 example/src/app/collapsible-tabs-default-pager.tsx create mode 100644 example/src/app/collapsible-tabs.tsx create mode 100644 src/collapsible/Collapsible.tsx create mode 100644 src/collapsible/CollapsibleTabs.tsx create mode 100644 src/collapsible/__tests__/Collapsible.test.tsx create mode 100644 src/collapsible/__tests__/CollapsibleTabs.test.tsx create mode 100644 src/collapsible/__tests__/pagerAdapters.test.tsx create mode 100644 src/collapsible/__tests__/presets.test.ts create mode 100644 src/collapsible/__tests__/useCollapsibleHeader.test.ts create mode 100644 src/collapsible/context.ts create mode 100644 src/collapsible/index.ts create mode 100644 src/collapsible/pagerAdapters.tsx create mode 100644 src/collapsible/presets.ts create mode 100644 src/collapsible/types.ts create mode 100644 src/collapsible/useCollapsibleHeader.ts create mode 100644 src/collapsible/useCollapsibleTabs.ts diff --git a/README.md b/README.md index b4fa2c5..98bb1a4 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,7 @@ Full documentation lives here: ## What it helps with - Scroll-driven animated headers +- Ready-made, preset-driven collapsible headers via `Collapsible` and `CollapsibleTabs` - Shared header state across tabs, pagers, and multiple scrollables - Navigation-rendered headers in Expo Router or React Navigation - Custom scrollables via `createHeaderMotionScrollable()` and `ScrollablePresets` @@ -102,6 +103,32 @@ function AppHeader() { In a real header, use `useMotionProgress()` to drive your Reanimated styles. See the [Quick Start](https://pawicao.github.io/react-native-header-motion/docs/quick-start) for the full walkthrough, animation examples, and styling details. +## High-level API + +If you'd rather not hand-write the choreography, `Collapsible` wraps the same primitives into a preset-driven compound component — and `CollapsibleTabs` does the same for one header shared across pager pages: + +```tsx +import { Collapsible } from 'react-native-header-motion'; +import { Stack } from 'expo-router'; + +export default function Screen() { + return ( + + header }} />} + > + {/* stays in place */} + {/* collapses away */} + + + {/* content */} + + ); +} +``` + +See the [high-level API docs](https://pawicao.github.io/react-native-header-motion/docs/high-level/collapsible) for presets, tabs, and pager adapters. + ## Version notes - Upgrading from `v0.3.x`? Read [MIGRATION-v1.md](./MIGRATION-v1.md). diff --git a/docs/docs/high-level/collapsible-tabs.md b/docs/docs/high-level/collapsible-tabs.md new file mode 100644 index 0000000..2552f99 --- /dev/null +++ b/docs/docs/high-level/collapsible-tabs.md @@ -0,0 +1,103 @@ +--- +sidebar_position: 3 +title: CollapsibleTabs +--- + +# CollapsibleTabs + +`CollapsibleTabs` shares one collapsible header across multiple swipeable pages. It absorbs everything the low-level [multi-tab setup](../guides/multiple-tabs-pages) wires by hand: the `useActiveScrollId()` state, the per-scrollable `scrollId`s, and the pager coordination. + +```tsx +import { Collapsible, CollapsibleTabs } from 'react-native-header-motion'; + + + + + + + + + + + + + + {about} + + + +``` + +Note what's absent: no `scrollId` props, no active-id state, no pager event wiring. Each `CollapsibleTabs.Tab` provides its `name` as the default `scrollId` through context, so **any** header-motion scrollable inside it — including custom ones from `createHeaderMotionScrollable()` — participates automatically. An explicit `scrollId` prop still wins if you need to override. + +## Parts + +- **`CollapsibleTabs`** — the root. Extends [`Collapsible`](./collapsible) (same `preset` and pass-through props) and owns the active-tab state. +- **`CollapsibleTabs.Pager`** — renders the pages through a pager adapter and keeps it in sync with the active tab. +- **`CollapsibleTabs.Tab`** — one page; provides its `name` as the default `scrollId`. +- **`CollapsibleTabs.Bar`** — a minimal, deliberately unstyled tab bar. For a custom one, build on `useCollapsibleTabs()`. + +All of it works inside `Collapsible.NavigationHeader` too — the tabs context is bridged across the navigation boundary automatically, so `CollapsibleTabs.Bar` can live in a navigation-rendered header. + +## Props + +### `CollapsibleTabs` + +Everything from `Collapsible` except `activeScrollId` (owned internally), plus: + +| Prop | Type | Default | Description | +| ------------- | --------------------------------------------- | ---------------- | ----------------------------------------------------------------------- | +| `tabs` | `(string \| { name, label? })[]` | — | The tabs in pager order. Labels are what `CollapsibleTabs.Bar` renders. | +| `initialTab` | `string` | first tab | Name of the initially active tab. | +| `onTabChange` | `(name: string) => void` | — | Called when the active tab changes, from a swipe or `goTo`. | + +### `CollapsibleTabs.Pager` + +| Prop | Type | Default | Description | +| --------- | ----------------------------- | ---------------- | ---------------------------------------- | +| `adapter` | `CollapsibleTabsPagerAdapter` | built-in pager | The pager engine. See below. | +| `style` | `ViewStyle` | `{ flex: 1 }` | Style for the pager container. | + +## Pager engines + +The pager is pluggable through a tiny adapter interface, so the library itself has **zero pager dependencies**. + +**Built-in default** — a paging horizontal `ScrollView`. No extra install, keeps all pages mounted, supports swipe and programmatic changes. + +**`react-native-pager-view`** — for native pager behavior, hand your app's `PagerView` to the factory. The library never imports the package itself, so it stays a dependency of *your* app: + +```tsx +import PagerView from 'react-native-pager-view'; +import { createPagerViewAdapter } from 'react-native-header-motion'; + +const pagerAdapter = createPagerViewAdapter(PagerView); + + +``` + +**Custom** — implement `CollapsibleTabsPagerAdapter`: a component receiving `{ initialIndex, onIndexChange, controllerRef, style, children }`. Render `children` (one element per tab), report user-driven page changes through `onIndexChange`, and assign `{ setIndex }` to `controllerRef` for programmatic changes. + +## Custom tab bars + +`useCollapsibleTabs()` exposes the tab state anywhere in the tree: + +```tsx +import { useCollapsibleTabs } from 'react-native-header-motion'; + +function MyTabBar() { + const { tabs, activeTab, goTo } = useCollapsibleTabs(); + + return ( + + {tabs.map((tab) => ( + goTo(tab.name)} + /> + ))} + + ); +} +``` diff --git a/docs/docs/high-level/collapsible.md b/docs/docs/high-level/collapsible.md new file mode 100644 index 0000000..952a396 --- /dev/null +++ b/docs/docs/high-level/collapsible.md @@ -0,0 +1,114 @@ +--- +sidebar_position: 1 +title: Collapsible +--- + +# Collapsible + +`Collapsible` is the high-level, preset-driven way to build a collapsible header. It wraps the `HeaderMotion` primitives — the provider, the measured header, the animated styles — into a compound component where you compose the header from explicit parts and pick a [preset](./presets) for the animation. + +Everything `Collapsible` does is built on the public low-level API, and every low-level prop passes through. You can start high-level and drop down at any point. + +## Anatomy + +```tsx +import { Collapsible } from 'react-native-header-motion'; +import { Stack } from 'expo-router'; + +export default function Screen() { + return ( + + header }} />} + > + + + + + + + + + + {content} + + ); +} +``` + +Each part names the *role* a piece of UI plays while the header collapses: + +- **`Collapsible`** — the root provider. Renders a `HeaderMotion` provider, resolves the `preset`, and shares both with the parts. No visual output. +- **`Collapsible.Header`** — the header frame. Measures the total header height (like `HeaderMotion.Header`) and slides up by the collapse distance as the user scrolls. +- **`Collapsible.Pinned`** — content that stays visually in place (a title row, actions). It counter-translates against the frame's slide. +- **`Collapsible.Dynamic`** — the collapsing section. Its measured height defines the collapse distance (like `HeaderMotion.Header.Dynamic`), and the active preset animates its content. Anything after it (like the `SearchBar` above) simply rides up and ends docked under the pinned content. +- **`Collapsible.NavigationHeader`** — a `Collapsible.Header` rendered by a navigation library. It fuses the `Bridge` / `NavigationBridge` wiring into one component: compose the header as children, and use `render` to place the prepared element into your navigator. +- **`Collapsible.ScrollView` / `Collapsible.FlatList`** — the same pre-wired scrollables as `HeaderMotion.ScrollView` / `HeaderMotion.FlatList`, re-exported for a self-contained import surface. Custom scrollables from `createHeaderMotionScrollable()` work here too. + +## Overlay headers without navigation + +If your header is not rendered by a navigation library, use `Collapsible.Header` directly — it overlays the content by default, exactly like the low-level `HeaderMotion.Header`: + +```tsx + + + + + + + + + + {content} + +``` + +## Props + +### `Collapsible` + +Accepts every `HeaderMotion` prop (`progressThreshold`, `measureDynamic`, `measureDynamicMode`, `activeScrollId`, `progressExtrapolation`), plus: + +| Prop | Type | Default | Description | +| --------------- | ------------------------------------------- | ------------ | ------------------------------------------------------------------------------------------------- | +| `preset` | `CollapsiblePresetInput` | `'collapse'` | How the collapsing content animates. See [Presets](./presets). | +| `onStateChange` | `(state: 'expanded' \| 'collapsed') => void` | — | Called when the header settles into a terminal state. | + +### `Collapsible.Header` / `Collapsible.NavigationHeader` + +Accept all `HeaderMotion.Header` props except `asChild` (`overlay`, `pannable`, `panDecayConfig`, `withGestureHandlerRootView`, any `Animated.View` prop). `Collapsible.NavigationHeader` additionally requires: + +| Prop | Type | Description | +| -------- | ------------------------------------- | -------------------------------------------------------------- | +| `render` | `(header: ReactElement) => ReactNode` | Places the prepared, context-bridged header into your navigator. | + +### `Collapsible.Dynamic` + +Accepts all `HeaderMotion.Header.Dynamic` props except `asChild`, plus: + +| Prop | Type | Description | +| -------------- | ----------------------- | ---------------------------------------------------------------------------------------------------------------- | +| `contentStyle` | `Animated.View` `style` | Style for the inner content view the preset effects animate. Layout styles for the measured wrapper go on `style`. | + +The wrapper clips its content by default (`overflow: 'hidden'`); override it through `style` if needed. + +### `Collapsible.Pinned` + +Accepts all `Animated.View` props. + +## Imperative control + +`useCollapsibleHeader()` exposes the motion state plus imperative controls, anywhere inside the tree (it also works in plain `HeaderMotion` trees): + +```tsx +import { useCollapsibleHeader } from 'react-native-header-motion'; + +const { progress, progressThreshold, collapse, expand } = useCollapsibleHeader(); + +collapse(); // scrolls the active scrollable until the header is collapsed +expand({ animated: false }); +``` + +## Pull to refresh + +The scrollables accept `refreshControl` (and `refreshing` / `onRefresh`) exactly like their low-level counterparts — see [Pull to refresh](../guides/pull-to-refresh). Custom refresh indicators compose naturally into the header as regular children of `Collapsible.Header`. diff --git a/docs/docs/high-level/presets.md b/docs/docs/high-level/presets.md new file mode 100644 index 0000000..fccdb46 --- /dev/null +++ b/docs/docs/high-level/presets.md @@ -0,0 +1,73 @@ +--- +sidebar_position: 2 +title: Presets +--- + +# Presets + +A preset describes how the collapsing content of a [`Collapsible`](./collapsible) header animates. The structural choreography — the frame sliding up, pinned content counter-translating — is intrinsic to the parts; presets only decorate the collapsing section, which is what makes them freely composable. + +## Built-in presets + +Pass a name for the defaults, or call the factory from `CollapsiblePresets` to configure: + +```tsx +import { Collapsible, CollapsiblePresets } from 'react-native-header-motion'; + + // string shorthand + // configured +``` + +| Preset | Effect | Options | +| ---------- | ----------------------------------------------------------------------------------------------- | ------------------------------------------- | +| `collapse` | Content slides up and is clipped under the pinned sections (the classic iOS large-title feel). | — | +| `fade` | Content fades out in place while the header collapses. | `from` (default `0`), `to` (default `0.8`) | +| `parallax` | Content lags behind the collapse and fades out. | `factor` (default `0.5`), `fade` (default `true`) | +| `scale` | Content shrinks and fades out. | `to` (default `0.9`), `fade` (default `true`) | +| `none` | No content effect; the header still slides up. | — | + +## Combining presets + +Pass an array to combine effects. `transform` arrays concatenate in order, numeric `opacity` values multiply, and any other property is taken from the last preset that defines it: + +```tsx + +``` + +## Custom presets + +A preset is a worklet from the current motion state to per-part styles. Author one with `createCollapsiblePreset()` — the function runs on the UI thread, so it **must carry the `'worklet'` directive**: + +```tsx +import { createCollapsiblePreset } from 'react-native-header-motion'; + +const lift = createCollapsiblePreset(({ progress, progressThreshold }) => { + 'worklet'; + return { + header: { borderBottomWidth: progress }, + dynamicContent: { + opacity: 1 - progress, + transform: [{ translateY: -progress * progressThreshold * 0.25 }], + }, + }; +}); + + +``` + +The returned object may style four parts: + +| Key | Applied to | Merged on top of | +| ---------------- | --------------------------------------------- | ------------------------------------ | +| `header` | `Collapsible.Header` frame | The intrinsic slide-up transform | +| `pinned` | Every `Collapsible.Pinned` | The intrinsic counter-translate | +| `dynamic` | The measured `Collapsible.Dynamic` wrapper | Its default `overflow: 'hidden'` | +| `dynamicContent` | The content view inside `Collapsible.Dynamic` | — | + +Use transforms only on `dynamic` — its layout defines the collapse distance, and transforms don't affect layout measurement. + +Because presets are plain values, they are shareable: hoist them to module scope (or memoize) so the animated styles aren't rebuilt on every render, and publish the ones you're proud of. + +:::note Designed to grow +The preset context (`{ progress, progressThreshold }`) is intentionally open-ended. When the headless refresh control lands, refresh state will join it, letting presets react to pull-to-refresh without any breaking change. Read the fields you need and ignore the rest. +::: diff --git a/docs/sidebars.ts b/docs/sidebars.ts index 48f1f42..2cc7a03 100644 --- a/docs/sidebars.ts +++ b/docs/sidebars.ts @@ -17,6 +17,16 @@ const sidebars: SidebarsConfig = { id: 'quick-start', label: 'Quick Start', }, + { + type: 'category', + label: 'High-level API', + collapsed: false, + items: [ + 'high-level/collapsible', + 'high-level/presets', + 'high-level/collapsible-tabs', + ], + }, { type: 'category', label: 'Guides', diff --git a/example/src/app/collapsible-simple.tsx b/example/src/app/collapsible-simple.tsx new file mode 100644 index 0000000..007b08a --- /dev/null +++ b/example/src/app/collapsible-simple.tsx @@ -0,0 +1,55 @@ +import { DynamicBox, TitleWithSubtitle, generateContent } from '@/components'; +import { Collapsible } from 'react-native-header-motion'; +import { Stack } from 'expo-router'; +import { StyleSheet } from 'react-native'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; + +export default function Screen() { + const insets = useSafeAreaInsets(); + + return ( + + header }} />} + > + + + + + + + + + {content} + + ); +} + +const styles = StyleSheet.create({ + header: { + backgroundColor: '#304077', + borderBottomWidth: 1, + borderBottomColor: 'rgba(0,0,0,0.1)', + }, + dynamic: { + padding: 12, + }, + boxRow: { + flexDirection: 'row', + gap: 6, + alignItems: 'stretch', + }, +}); + +const content = generateContent({ + count: 500, + backgroundColor: '#E3CBFC', + textColor: '#304077', +}); diff --git a/example/src/app/collapsible-tabs-default-pager.tsx b/example/src/app/collapsible-tabs-default-pager.tsx new file mode 100644 index 0000000..4e380b2 --- /dev/null +++ b/example/src/app/collapsible-tabs-default-pager.tsx @@ -0,0 +1,108 @@ +import { ContentCard, DynamicBox, TitleWithSubtitle } from '@/components'; +import { Collapsible, CollapsibleTabs } from 'react-native-header-motion'; +import { Stack } from 'expo-router'; +import { StyleSheet } from 'react-native'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; + +type ListRow = { + index: number; + label: string; +}; + +const TABS = [ + { name: 'cards', label: 'Cards' }, + { name: 'list', label: 'FlatList' }, +]; + +export default function Screen() { + const insets = useSafeAreaInsets(); + + return ( + + header }} />} + > + + + + + + + + + + + + + {cards.map((row) => ( + + ))} + + + + `${item.index}`} + renderItem={({ item }) => ( + + )} + /> + + + + ); +} + +const styles = StyleSheet.create({ + header: { + backgroundColor: '#304077', + borderBottomWidth: 1, + borderBottomColor: 'rgba(0,0,0,0.1)', + }, + dynamic: { + padding: 12, + }, + boxRow: { + flexDirection: 'row', + gap: 6, + alignItems: 'stretch', + }, + tabBar: { + backgroundColor: '#FFF', + borderTopWidth: 1, + borderTopColor: '#EEE', + }, + tabLabel: { + color: '#304077', + }, +}); + +const cards: ListRow[] = Array.from({ length: 500 }, (_, k) => ({ + index: k + 1, + label: 'Cards', +})); +const rows: ListRow[] = Array.from({ length: 500 }, (_, k) => ({ + index: k + 1, + label: 'FlatList', +})); diff --git a/example/src/app/collapsible-tabs.tsx b/example/src/app/collapsible-tabs.tsx new file mode 100644 index 0000000..8bc1ee5 --- /dev/null +++ b/example/src/app/collapsible-tabs.tsx @@ -0,0 +1,93 @@ +import { DynamicBox, TitleWithSubtitle, generateContent } from '@/components'; +import { + Collapsible, + CollapsibleTabs, + createPagerViewAdapter, +} from 'react-native-header-motion'; +import { Stack } from 'expo-router'; +import { StyleSheet } from 'react-native'; +import PagerView from 'react-native-pager-view'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; + +const pagerAdapter = createPagerViewAdapter(PagerView); + +const TABS = [ + { name: 'a', label: 'Page A' }, + { name: 'b', label: 'Page B' }, +]; + +export default function Screen() { + const insets = useSafeAreaInsets(); + + return ( + + header }} />} + > + + + + + + + + + + + + {contentA} + + + {contentB} + + + + ); +} + +const styles = StyleSheet.create({ + header: { + backgroundColor: '#304077', + borderBottomWidth: 1, + borderBottomColor: 'rgba(0,0,0,0.1)', + }, + dynamic: { + padding: 12, + }, + boxRow: { + flexDirection: 'row', + gap: 6, + alignItems: 'stretch', + }, + tabBar: { + backgroundColor: '#FFF', + borderTopWidth: 1, + borderTopColor: '#EEE', + }, + tabLabel: { + color: '#304077', + }, +}); + +const contentA = generateContent({ + count: 500, + backgroundColor: '#E3CBFC', + textColor: '#304077', + label: 'Page A', +}); +const contentB = generateContent({ + count: 500, + backgroundColor: '#CBE3FC', + textColor: '#304077', + label: 'Page B', +}); diff --git a/example/src/app/index.tsx b/example/src/app/index.tsx index 5447cce..825528e 100644 --- a/example/src/app/index.tsx +++ b/example/src/app/index.tsx @@ -13,6 +13,26 @@ interface ShowcaseSection { } const SECTIONS: ShowcaseSection[] = [ + { + title: 'High-level API', + data: [ + { + title: 'Collapsible (parallax preset)', + href: '/collapsible-simple', + icon: '🎁', + }, + { + title: 'CollapsibleTabs (pager-view)', + href: '/collapsible-tabs', + icon: '🗂️', + }, + { + title: 'CollapsibleTabs (built-in pager)', + href: '/collapsible-tabs-default-pager', + icon: '📚', + }, + ], + }, { title: 'Core', data: [ diff --git a/src/collapsible/Collapsible.tsx b/src/collapsible/Collapsible.tsx new file mode 100644 index 0000000..c9698e5 --- /dev/null +++ b/src/collapsible/Collapsible.tsx @@ -0,0 +1,360 @@ +import { + useCallback, + useContext, + useEffect, + useMemo, + useRef, + type ReactElement, + type ReactNode, +} from 'react'; +import { StyleSheet, type ViewProps, type ViewStyle } from 'react-native'; +import Animated, { + useAnimatedReaction, + useAnimatedStyle, + useSharedValue, + type AnimatedProps, +} from 'react-native-reanimated'; +import { scheduleOnRN } from 'react-native-worklets'; +import { Bridge } from '../components/Bridge'; +import { FlatList } from '../components/FlatList'; +import { Header, type HeaderProps } from '../components/Header'; +import { HeaderDynamic } from '../components/HeaderDynamic'; +import { + HeaderMotionContextProvider, + type HeaderMotionProps, +} from '../components/HeaderMotion'; +import { NavigationBridge } from '../components/NavigationBridge'; +import { ScrollView } from '../components/ScrollView'; +import { useMotionProgress } from '../hooks/useMotionProgress'; +import type { HeaderDynamicProps } from '../types'; +import { + CollapsiblePresetsContext, + CollapsibleTabsContext, + useCollapsiblePresetsOrThrow, +} from './context'; +import { + resolveCollapsiblePartStyle, + resolveCollapsiblePresets, +} from './presets'; +import type { + CollapsibleHeaderState, + CollapsiblePresetContext, + CollapsiblePresetInput, +} from './types'; + +type DistributiveOmit = T extends any + ? Omit + : never; + +const EXPANDED_EDGE = 0.001; +const COLLAPSED_EDGE = 0.999; + +export interface CollapsibleProps + extends HeaderMotionProps { + /** + * How the collapsing content should animate: a built-in preset name, a + * configured preset from `CollapsiblePresets`, a custom preset created with + * `createCollapsiblePreset()`, or an array of those to combine. + * + * @default 'collapse' + */ + preset?: CollapsiblePresetInput; + /** + * Called when the header settles into a terminal state: `'collapsed'` when + * `progress` reaches `1`, `'expanded'` when it returns to `0`. + */ + onStateChange?: (state: CollapsibleHeaderState) => void; +} + +function CollapsibleStateObserver({ + onStateChange, +}: { + onStateChange: (state: CollapsibleHeaderState) => void; +}) { + const { progress } = useMotionProgress(); + const callbackRef = useRef(onStateChange); + useEffect(() => { + callbackRef.current = onStateChange; + }); + + const emit = useCallback((state: CollapsibleHeaderState) => { + callbackRef.current?.(state); + }, []); + + const lastState = useSharedValue('expanded'); + + useAnimatedReaction( + () => progress.get(), + (value) => { + const previous = lastState.get(); + if (value >= COLLAPSED_EDGE && previous !== 'collapsed') { + lastState.set('collapsed'); + scheduleOnRN(emit, 'collapsed'); + } else if (value <= EXPANDED_EDGE && previous !== 'expanded') { + lastState.set('expanded'); + scheduleOnRN(emit, 'expanded'); + } + } + ); + + return null; +} + +/** + * High-level root for a collapsible header setup. + * + * It renders a `HeaderMotion` provider and shares the resolved `preset` with + * the `Collapsible.*` parts composed inside — no visual output of its own. + * + * @template T - The type of scroll ID string + */ +function CollapsibleRoot({ + preset = 'collapse', + onStateChange, + children, + ...headerMotionProps +}: CollapsibleProps) { + const presets = useMemo(() => resolveCollapsiblePresets(preset), [preset]); + + return ( + + + {onStateChange ? ( + + ) : null} + {children} + + + ); +} + +export type CollapsibleHeaderProps = DistributiveOmit< + Extract, + 'asChild' +>; + +/** + * The header frame of a collapsible setup. + * + * Renders `HeaderMotion.Header` (so the total header height is measured + * automatically) and slides itself up by the collapse distance as the user + * scrolls. Preset `header` styles are merged on top of that intrinsic + * transform. + */ +function CollapsibleHeaderPart({ style, ...rest }: CollapsibleHeaderProps) { + const presets = useCollapsiblePresetsOrThrow('Collapsible.Header'); + const { progress, progressThreshold } = useMotionProgress(); + + const animatedStyle = useAnimatedStyle(() => { + const context: CollapsiblePresetContext = { + progress: progress.get(), + progressThreshold: progressThreshold.get(), + }; + const intrinsic: ViewStyle = { + transform: [ + { translateY: -context.progress * context.progressThreshold }, + ], + }; + + return resolveCollapsiblePartStyle('header', intrinsic, presets, context); + }); + + return
; +} + +export type CollapsiblePinnedProps = AnimatedProps; + +/** + * A header section that stays visually in place while the header collapses. + * + * Use it for content that must remain visible — a title row, actions, or a + * search field. It counter-translates against the header frame's slide, and + * preset `pinned` styles are merged on top. + */ +function CollapsiblePinned({ style, ...rest }: CollapsiblePinnedProps) { + const presets = useCollapsiblePresetsOrThrow('Collapsible.Pinned'); + const { progress, progressThreshold } = useMotionProgress(); + + const animatedStyle = useAnimatedStyle(() => { + const context: CollapsiblePresetContext = { + progress: progress.get(), + progressThreshold: progressThreshold.get(), + }; + const intrinsic: ViewStyle = { + transform: [{ translateY: context.progress * context.progressThreshold }], + }; + + return resolveCollapsiblePartStyle('pinned', intrinsic, presets, context); + }); + + return ; +} + +export type CollapsibleDynamicProps = DistributiveOmit< + Extract, + 'asChild' +> & { + /** Style for the inner content view that the preset effects animate. */ + contentStyle?: AnimatedProps['style']; +}; + +const dynamicStyles = StyleSheet.create({ + clip: { + overflow: 'hidden', + }, +}); + +/** + * The collapsing section of the header. + * + * Renders `HeaderMotion.Header.Dynamic`, so its measured height defines the + * collapse distance. The children are wrapped in an inner view that receives + * the preset's content effect (fade, parallax, scale, ...), while this + * wrapper clips the content (`overflow: 'hidden'`) and receives the preset's + * `dynamic` styles. + */ +function CollapsibleDynamic({ + style, + contentStyle, + children, + ...rest +}: CollapsibleDynamicProps) { + const presets = useCollapsiblePresetsOrThrow('Collapsible.Dynamic'); + const { progress, progressThreshold } = useMotionProgress(); + + const wrapperStyle = useAnimatedStyle(() => { + const context: CollapsiblePresetContext = { + progress: progress.get(), + progressThreshold: progressThreshold.get(), + }; + + return resolveCollapsiblePartStyle('dynamic', undefined, presets, context); + }); + + const innerStyle = useAnimatedStyle(() => { + const context: CollapsiblePresetContext = { + progress: progress.get(), + progressThreshold: progressThreshold.get(), + }; + + return resolveCollapsiblePartStyle( + 'dynamicContent', + undefined, + presets, + context + ); + }); + + return ( + + + {children} + + + ); +} + +export interface CollapsibleNavigationHeaderRenderProps { + /** + * Places the finished header element into your navigator. + * + * The element already carries the HeaderMotion, preset, and tabs contexts + * across the tree boundary, so it can be rendered anywhere — most commonly + * inside a navigation library's `header` option. + */ + render: (header: ReactElement) => ReactNode; +} + +export type CollapsibleNavigationHeaderProps = CollapsibleHeaderProps & + CollapsibleNavigationHeaderRenderProps; + +/** + * A collapsible header rendered by a navigation library. + * + * Compose the header content as children exactly like in `Collapsible.Header` + * and use `render` to mount the prepared element into your navigator. This + * replaces the manual `Bridge` / `NavigationBridge` wiring of the low-level + * API. + * + * @example + * ```tsx + * header }} />} + * > + * {...} + * {...} + * + * ``` + */ +function CollapsibleNavigationHeader({ + render, + ...headerProps +}: CollapsibleNavigationHeaderProps) { + const presets = useCollapsiblePresetsOrThrow('Collapsible.NavigationHeader'); + const tabsContext = useContext(CollapsibleTabsContext); + + return ( + + {(value) => + render( + + + + + + + + ) + } + + ); +} + +/** + * High-level, preset-driven collapsible header built on top of the + * `HeaderMotion` primitives. + * + * Compose the header from explicit parts — the same composition pattern as + * the low-level API, with the animation choreography handled for you: + * + * @example + * ```tsx + * + * header }} />} + * > + * + * + * + * + * + * + * + * {content} + * + * ``` + */ +export const Collapsible = Object.assign(CollapsibleRoot, { + /** + * Header frame: measures the total header height and slides up while + * collapsing. Compose `Collapsible.Pinned` and `Collapsible.Dynamic` + * (plus any plain views) inside it. + */ + Header: CollapsibleHeaderPart, + /** Header section that stays visually in place while the header collapses. */ + Pinned: CollapsiblePinned, + /** + * The collapsing header section. Its measured height defines the collapse + * distance and the active preset animates its content. + */ + Dynamic: CollapsibleDynamic, + /** + * Navigation-rendered variant of `Collapsible.Header` — bridges every + * collapsible context across the React tree boundary for you. + */ + NavigationHeader: CollapsibleNavigationHeader, + /** Pre-wired `Animated.ScrollView` — the same component as `HeaderMotion.ScrollView`. */ + ScrollView: ScrollView, + /** Pre-wired `Animated.FlatList` — the same component as `HeaderMotion.FlatList`. */ + FlatList: FlatList, +}); diff --git a/src/collapsible/CollapsibleTabs.tsx b/src/collapsible/CollapsibleTabs.tsx new file mode 100644 index 0000000..a893061 --- /dev/null +++ b/src/collapsible/CollapsibleTabs.tsx @@ -0,0 +1,402 @@ +import { + Children, + cloneElement, + isValidElement, + useCallback, + useEffect, + useMemo, + useRef, + type ReactElement, + type ReactNode, +} from 'react'; +import { + Pressable, + StyleSheet, + Text, + View, + type StyleProp, + type TextStyle, + type ViewStyle, +} from 'react-native'; +import { HeaderMotionScrollIdContext } from '../context'; +import { useActiveScrollId } from '../hooks/useActiveScrollId'; +import { Collapsible, type CollapsibleProps } from './Collapsible'; +import { + CollapsibleTabsContext, + useCollapsibleTabsContextOrThrow, + type CollapsibleTabsContextValue, +} from './context'; +import { + DefaultCollapsibleTabsPagerAdapter, + type CollapsibleTabsPagerAdapter, + type CollapsibleTabsPagerController, +} from './pagerAdapters'; + +/** A tab declaration: a plain name, or a name with a display label. */ +export type CollapsibleTabsTabInput = string | { name: string; label?: string }; + +export interface CollapsibleTabsProps + extends Omit, 'activeScrollId'> { + /** + * The tabs, in pager order. Each entry is a name or `{ name, label }` — + * the label is what `CollapsibleTabs.Bar` displays and falls back to the + * name. + * + * The names must match the `name` props of the `CollapsibleTabs.Tab` + * children rendered inside `CollapsibleTabs.Pager`, in the same order. + */ + tabs: readonly CollapsibleTabsTabInput[]; + /** + * Name of the initially active tab. + * + * @default the first entry of `tabs` + */ + initialTab?: string; + /** Called whenever the active tab changes, from a swipe or `goTo`. */ + onTabChange?: (name: string) => void; +} + +/** + * High-level root for one collapsible header shared across multiple tabs. + * + * It renders a `Collapsible` root and owns the active-tab state: the + * `activeScrollId` wiring, the pager coordination, and the tab bar state all + * come from here. Each `CollapsibleTabs.Tab` provides its name as the default + * `scrollId`, so the scrollables inside need no manual wiring. + * + * @example + * ```tsx + * + * + * + * + * + * + * + * + * + * + * + * {about} + * + * + * + * ``` + */ +function CollapsibleTabsRoot({ + tabs, + initialTab, + onTabChange, + children, + ...collapsibleProps +}: CollapsibleTabsProps) { + const normalizedTabs = useMemo( + () => + tabs.map((tab) => + typeof tab === 'string' + ? { name: tab, label: tab } + : { name: tab.name, label: tab.label ?? tab.name } + ), + [tabs] + ); + + if (normalizedTabs.length === 0) { + throw new Error( + '[react-native-header-motion] CollapsibleTabs requires at least one tab.' + ); + } + + // The initial tab is resolved once — later `initialTab` changes must not + // re-run it, mirroring how pagers treat their `initialPage`. + const initialRef = useRef<{ name: string; index: number } | null>(null); + if (initialRef.current === null) { + let index = initialTab + ? normalizedTabs.findIndex((tab) => tab.name === initialTab) + : 0; + if (index < 0) { + if (__DEV__) { + console.warn( + `[react-native-header-motion] CollapsibleTabs: initialTab "${initialTab}" is not in \`tabs\`. Falling back to the first tab.` + ); + } + index = 0; + } + + initialRef.current = { name: normalizedTabs[index]!.name, index }; + } + + const [activeScrollId, setActiveScrollId] = useActiveScrollId( + initialRef.current.name + ); + const controllerRef = useRef(null); + + const onTabChangeRef = useRef(onTabChange); + useEffect(() => { + onTabChangeRef.current = onTabChange; + }); + + const activeTab = activeScrollId.state; + + const setActiveTab = useCallback( + (name: string) => { + setActiveScrollId(name); + onTabChangeRef.current?.(name); + }, + [setActiveScrollId] + ); + + const goTo = useCallback( + (name: string) => { + const index = normalizedTabs.findIndex((tab) => tab.name === name); + if (index < 0) { + if (__DEV__) { + console.warn( + `[react-native-header-motion] CollapsibleTabs: unknown tab "${name}".` + ); + } + return; + } + + controllerRef.current?.setIndex(index); + if (name !== activeTab) { + setActiveTab(name); + } + }, + [normalizedTabs, activeTab, setActiveTab] + ); + + const onPagerIndexChange = useCallback( + (index: number) => { + const tab = normalizedTabs[index]; + if (!tab || tab.name === activeTab) { + return; + } + + setActiveTab(tab.name); + }, + [normalizedTabs, activeTab, setActiveTab] + ); + + const contextValue = useMemo( + () => ({ + tabs: normalizedTabs, + activeTab, + goTo, + initialIndex: initialRef.current!.index, + controllerRef, + onPagerIndexChange, + }), + [normalizedTabs, activeTab, goTo, onPagerIndexChange] + ); + + return ( + + + {children} + + + ); +} + +export interface CollapsibleTabsPagerProps { + /** + * The pager engine to render with. + * + * Defaults to the dependency-free built-in (a paging horizontal + * `ScrollView`). Pass `createPagerViewAdapter(PagerView)` for + * `react-native-pager-view`, or any custom `CollapsibleTabsPagerAdapter`. + */ + adapter?: CollapsibleTabsPagerAdapter; + /** Style for the pager container. Defaults to `flex: 1`. */ + style?: StyleProp; + /** `CollapsibleTabs.Tab` children, in the same order as the `tabs` prop. */ + children: ReactNode; +} + +/** + * Renders the swipeable pages of a `CollapsibleTabs` setup through a pager + * adapter and keeps the pager in sync with the active tab. + */ +function CollapsibleTabsPager({ + adapter: Adapter = DefaultCollapsibleTabsPagerAdapter, + style, + children, +}: CollapsibleTabsPagerProps) { + const { tabs, initialIndex, controllerRef, onPagerIndexChange } = + useCollapsibleTabsContextOrThrow('CollapsibleTabs.Pager'); + + const pages = useMemo(() => { + const elements: ReactElement[] = []; + const names: (string | undefined)[] = []; + + Children.forEach(children, (child) => { + if (!isValidElement(child)) { + if (child != null && __DEV__) { + console.warn( + '[react-native-header-motion] CollapsibleTabs.Pager only renders children.' + ); + } + return; + } + + const name = (child.props as { name?: string }).name; + names.push(name); + elements.push( + cloneElement(child, { key: name ?? `tab-${elements.length}` }) + ); + }); + + if (__DEV__) { + const expected = tabs.map((tab) => tab.name); + const matches = + expected.length === names.length && + expected.every((name, index) => name === names[index]); + if (!matches) { + console.warn( + `[react-native-header-motion] CollapsibleTabs.Pager children do not match the \`tabs\` prop. Expected [${expected.join( + ', ' + )}], found [${names.join(', ')}].` + ); + } + } + + return elements; + }, [children, tabs]); + + return ( + + {pages} + + ); +} + +export interface CollapsibleTabProps { + /** + * The tab's name from the `tabs` prop. It doubles as the default `scrollId` + * for every header-motion scrollable rendered inside. + */ + name: string; + /** Style for the page container. */ + style?: StyleProp; + /** The page content, typically a header-motion scrollable. */ + children?: ReactNode; +} + +const tabStyles = StyleSheet.create({ + tab: { + flex: 1, + }, +}); + +/** + * One page of a `CollapsibleTabs.Pager`. + * + * Provides its `name` as the default `scrollId`, so any header-motion + * scrollable inside — `Collapsible.ScrollView`, `Collapsible.FlatList`, or a + * custom one from `createHeaderMotionScrollable()` — participates in the + * shared header state without extra wiring. + */ +function CollapsibleTab({ name, style, children }: CollapsibleTabProps) { + return ( + + + {children} + + + ); +} + +export interface CollapsibleTabsBarProps { + /** Style for the bar container. */ + style?: StyleProp; + /** Style for each tab press target. */ + tabStyle?: StyleProp; + /** Style for every tab label. */ + labelStyle?: StyleProp; + /** Style merged onto the active tab's label. */ + activeLabelStyle?: StyleProp; +} + +const barStyles = StyleSheet.create({ + bar: { + flexDirection: 'row', + }, + tab: { + flex: 1, + alignItems: 'center', + paddingVertical: 12, + }, + label: { + fontSize: 14, + fontWeight: '500', + opacity: 0.6, + }, + activeLabel: { + fontWeight: '700', + opacity: 1, + }, +}); + +/** + * Minimal, unstyled-by-design tab bar for `CollapsibleTabs`. + * + * Place it anywhere inside the header (or outside it). For a fully custom tab + * bar, build your own component on top of `useCollapsibleTabs()` instead. + */ +function CollapsibleTabsBar({ + style, + tabStyle, + labelStyle, + activeLabelStyle, +}: CollapsibleTabsBarProps) { + const { tabs, activeTab, goTo } = useCollapsibleTabsContextOrThrow( + 'CollapsibleTabs.Bar' + ); + + return ( + + {tabs.map((tab) => { + const isActive = tab.name === activeTab; + + return ( + goTo(tab.name)} + > + + {tab.label} + + + ); + })} + + ); +} + +/** + * Compound entrypoint for tabbed collapsible headers. See + * `CollapsibleTabsRoot` for the full example. + */ +export const CollapsibleTabs = Object.assign(CollapsibleTabsRoot, { + /** Renders the swipeable pages through a pager adapter. */ + Pager: CollapsibleTabsPager, + /** One page; provides its `name` as the default `scrollId`. */ + Tab: CollapsibleTab, + /** Minimal default tab bar. Build custom bars with `useCollapsibleTabs()`. */ + Bar: CollapsibleTabsBar, +}); diff --git a/src/collapsible/__tests__/Collapsible.test.tsx b/src/collapsible/__tests__/Collapsible.test.tsx new file mode 100644 index 0000000..548cef6 --- /dev/null +++ b/src/collapsible/__tests__/Collapsible.test.tsx @@ -0,0 +1,227 @@ +const mockUseCollapsiblePresetsOrThrow = jest.fn(); +const mockUseMotionProgress = jest.fn(); +const mockUseHeaderMotionBridge = jest.fn(); +const mockUseContext = jest.fn(); + +jest.mock('react', () => { + const ReactActual = jest.requireActual('react'); + + return { + ...ReactActual, + useMemo: (factory: () => unknown) => factory(), + useCallback: (callback: unknown) => callback, + useRef: (initial: unknown) => ({ current: initial }), + useEffect: jest.fn(), + useContext: (...args: any[]) => mockUseContext(...args), + }; +}); + +jest.mock('../context', () => { + const actual = jest.requireActual('../context'); + + return { + __esModule: true, + ...actual, + useCollapsiblePresetsOrThrow: (...args: any[]) => + mockUseCollapsiblePresetsOrThrow(...args), + }; +}); + +jest.mock('../../hooks/useMotionProgress', () => ({ + __esModule: true, + useMotionProgress: (...args: any[]) => mockUseMotionProgress(...args), +})); + +jest.mock('../../hooks/useHeaderMotionBridge', () => ({ + __esModule: true, + useHeaderMotionBridge: (...args: any[]) => mockUseHeaderMotionBridge(...args), +})); + +jest.mock('react-native-gesture-handler', () => { + const ReactActual = jest.requireActual('react'); + const pan = { + enabled: () => pan, + onChange: () => pan, + onEnd: () => pan, + shouldCancelWhenOutside: () => pan, + }; + + return { + __esModule: true, + Gesture: { + Pan: () => pan, + }, + GestureDetector: ({ children }: any) => + ReactActual.createElement('GestureDetector', null, children), + GestureHandlerRootView: ({ children }: any) => + ReactActual.createElement('GestureHandlerRootView', null, children), + }; +}); + +import React from 'react'; +import Animated from 'react-native-reanimated'; +import { Bridge } from '../../components/Bridge'; +import { Header } from '../../components/Header'; +import { HeaderDynamic } from '../../components/HeaderDynamic'; +import { HeaderMotionContextProvider } from '../../components/HeaderMotion'; +import { NavigationBridge } from '../../components/NavigationBridge'; +import { Collapsible } from '../Collapsible'; +import { CollapsiblePresetsContext, CollapsibleTabsContext } from '../context'; +import { resolveCollapsiblePresets } from '../presets'; + +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 motionProgress = { + progress: createSharedValue(0.5), + progressThreshold: createSharedValue(100), +}; + +describe('Collapsible', () => { + beforeEach(() => { + mockUseCollapsiblePresetsOrThrow.mockReset(); + mockUseMotionProgress.mockReset(); + mockUseHeaderMotionBridge.mockReset(); + mockUseContext.mockReset(); + mockUseMotionProgress.mockReturnValue(motionProgress); + mockUseCollapsiblePresetsOrThrow.mockReturnValue( + resolveCollapsiblePresets('collapse') + ); + }); + + it('root renders the HeaderMotion provider and shares resolved presets', () => { + const children = React.createElement('Child'); + const element = Collapsible({ + progressThreshold: 120, + children, + }) as React.ReactElement; + + expect(element.type).toBe(HeaderMotionContextProvider); + expect(element.props.progressThreshold).toBe(120); + + const presetsProvider = element.props.children; + expect(presetsProvider.type).toBe(CollapsiblePresetsContext.Provider); + expect(presetsProvider.props.value).toHaveLength(1); + // No onStateChange -> no state observer rendered. + expect(presetsProvider.props.children[0]).toBeNull(); + expect(presetsProvider.props.children[1]).toBe(children); + }); + + it('root renders a state observer when onStateChange is provided', () => { + const element = Collapsible({ + onStateChange: jest.fn(), + children: null, + }) as React.ReactElement; + + expect(element.props.children.props.children[0]).not.toBeNull(); + }); + + it('Header slides up by the collapse distance', () => { + const userStyle = { backgroundColor: 'red' }; + const element = Collapsible.Header({ + style: userStyle, + children: React.createElement('Child'), + }) as React.ReactElement; + + expect(element.type).toBe(Header); + const [style, animatedStyle] = element.props.style; + expect(style).toBe(userStyle); + expect(animatedStyle).toEqual({ transform: [{ translateY: -50 }] }); + }); + + it('Header merges preset header styles on top of the intrinsic slide', () => { + mockUseCollapsiblePresetsOrThrow.mockReturnValue([ + () => ({ header: { opacity: 0.5 } }), + ]); + + const element = Collapsible.Header({ + children: null, + }) as React.ReactElement; + const [, animatedStyle] = element.props.style; + + expect(animatedStyle).toEqual({ + transform: [{ translateY: -50 }], + opacity: 0.5, + }); + }); + + it('Pinned counter-translates to stay in place', () => { + const element = Collapsible.Pinned({ + children: null, + }) as React.ReactElement; + + expect(element.type).toBe(Animated.View); + const [, animatedStyle] = element.props.style; + expect(animatedStyle).toEqual({ transform: [{ translateY: 50 }] }); + }); + + it('Dynamic clips its wrapper and animates the content with the preset', () => { + const wrapperUserStyle = { padding: 12 }; + const contentStyle = { gap: 6 }; + const element = Collapsible.Dynamic({ + style: wrapperUserStyle, + contentStyle, + children: React.createElement('Child'), + }) as React.ReactElement; + + expect(element.type).toBe(HeaderDynamic); + const [clipStyle, style, wrapperStyle] = element.props.style; + expect(clipStyle).toEqual({ overflow: 'hidden' }); + expect(style).toBe(wrapperUserStyle); + expect(wrapperStyle).toEqual({ transform: [{ translateY: 50 }] }); + + const inner = element.props.children; + expect(inner.type).toBe(Animated.View); + expect(inner.props.style[0]).toBe(contentStyle); + expect(inner.props.style[1]).toEqual({ + transform: [{ translateY: -50 }], + }); + }); + + it('NavigationHeader bridges the contexts and hands the header to render', () => { + const presets = resolveCollapsiblePresets('fade'); + const tabsContextValue = { activeTab: 'a' }; + mockUseCollapsiblePresetsOrThrow.mockReturnValue(presets); + mockUseContext.mockReturnValue(tabsContextValue); + + const render = jest.fn((_header: React.ReactElement) => 'rendered'); + const element = Collapsible.NavigationHeader({ + render, + children: React.createElement('Child'), + }) as React.ReactElement; + + expect(element.type).toBe(Bridge); + expect(mockUseContext).toHaveBeenCalledWith(CollapsibleTabsContext); + + const bridgeValue = { progress: motionProgress.progress }; + expect(element.props.children(bridgeValue)).toBe('rendered'); + + const headerElement = render.mock.calls[0]![0] as React.ReactElement; + expect(headerElement.type).toBe(NavigationBridge); + expect(headerElement.props.value).toBe(bridgeValue); + + const presetsProvider = headerElement.props.children; + expect(presetsProvider.type).toBe(CollapsiblePresetsContext.Provider); + expect(presetsProvider.props.value).toBe(presets); + + const tabsProvider = presetsProvider.props.children; + expect(tabsProvider.type).toBe(CollapsibleTabsContext.Provider); + expect(tabsProvider.props.value).toBe(tabsContextValue); + }); + + it('exposes the pre-wired scrollables as aliases', () => { + const { ScrollView } = jest.requireActual('../../components/ScrollView'); + const { FlatList } = jest.requireActual('../../components/FlatList'); + + expect(Collapsible.ScrollView).toBe(ScrollView); + expect(Collapsible.FlatList).toBe(FlatList); + }); +}); diff --git a/src/collapsible/__tests__/CollapsibleTabs.test.tsx b/src/collapsible/__tests__/CollapsibleTabs.test.tsx new file mode 100644 index 0000000..8090ff0 --- /dev/null +++ b/src/collapsible/__tests__/CollapsibleTabs.test.tsx @@ -0,0 +1,312 @@ +const mockUseActiveScrollId = jest.fn(); +const mockUseCollapsibleTabsContextOrThrow = jest.fn(); + +jest.mock('react', () => { + const ReactActual = jest.requireActual('react'); + + return { + ...ReactActual, + useMemo: (factory: () => unknown) => factory(), + useCallback: (callback: unknown) => callback, + useRef: (initial: unknown) => ({ current: initial }), + useEffect: jest.fn(), + }; +}); + +jest.mock('../context', () => { + const actual = jest.requireActual('../context'); + + return { + __esModule: true, + ...actual, + useCollapsibleTabsContextOrThrow: (...args: any[]) => + mockUseCollapsibleTabsContextOrThrow(...args), + }; +}); + +jest.mock('../../hooks/useActiveScrollId', () => ({ + __esModule: true, + useActiveScrollId: (...args: any[]) => mockUseActiveScrollId(...args), +})); + +jest.mock('react-native-gesture-handler', () => { + const ReactActual = jest.requireActual('react'); + const pan = { + enabled: () => pan, + onChange: () => pan, + onEnd: () => pan, + shouldCancelWhenOutside: () => pan, + }; + + return { + __esModule: true, + Gesture: { + Pan: () => pan, + }, + GestureDetector: ({ children }: any) => + ReactActual.createElement('GestureDetector', null, children), + GestureHandlerRootView: ({ children }: any) => + ReactActual.createElement('GestureHandlerRootView', null, children), + }; +}); + +import React from 'react'; +import { Pressable, View } from 'react-native'; +import { HeaderMotionScrollIdContext } from '../../context'; +import { Collapsible } from '../Collapsible'; +import { CollapsibleTabs } from '../CollapsibleTabs'; +import { CollapsibleTabsContext } from '../context'; +import { DefaultCollapsibleTabsPagerAdapter } from '../pagerAdapters'; + +const activeSv = { value: 'a' }; +let setActiveScrollId: jest.Mock; + +function mockActiveScroll(state: string) { + setActiveScrollId = jest.fn(); + mockUseActiveScrollId.mockReturnValue([ + { state, sv: activeSv }, + setActiveScrollId, + ]); +} + +function renderRoot(props: Record = {}) { + const element = CollapsibleTabs({ + tabs: ['a', 'b'], + children: React.createElement('Child'), + ...props, + } as any) as React.ReactElement; + + const tabsProvider = element.props.children; + return { element, tabsProvider, contextValue: tabsProvider.props.value }; +} + +describe('CollapsibleTabs root', () => { + beforeEach(() => { + mockUseActiveScrollId.mockReset(); + mockUseCollapsibleTabsContextOrThrow.mockReset(); + mockActiveScroll('a'); + }); + + it('renders a Collapsible root wired to the active scroll id', () => { + const { element, tabsProvider, contextValue } = renderRoot({ + preset: 'fade', + }); + + expect(element.type).toBe(Collapsible); + expect(element.props.activeScrollId).toBe(activeSv); + expect(element.props.preset).toBe('fade'); + expect(mockUseActiveScrollId).toHaveBeenCalledWith('a'); + + expect(tabsProvider.type).toBe(CollapsibleTabsContext.Provider); + expect(contextValue.tabs).toEqual([ + { name: 'a', label: 'a' }, + { name: 'b', label: 'b' }, + ]); + expect(contextValue.activeTab).toBe('a'); + expect(contextValue.initialIndex).toBe(0); + }); + + it('normalizes labeled tab entries', () => { + const { contextValue } = renderRoot({ + tabs: [{ name: 'a', label: 'Page A' }, { name: 'b' }], + }); + + expect(contextValue.tabs).toEqual([ + { name: 'a', label: 'Page A' }, + { name: 'b', label: 'b' }, + ]); + }); + + it('resolves initialTab to the matching index', () => { + const { contextValue } = renderRoot({ initialTab: 'b' }); + + expect(mockUseActiveScrollId).toHaveBeenCalledWith('b'); + expect(contextValue.initialIndex).toBe(1); + }); + + it('warns and falls back to the first tab for an unknown initialTab', () => { + const warn = jest.spyOn(console, 'warn').mockImplementation(() => {}); + const { contextValue } = renderRoot({ initialTab: 'nope' }); + + expect(warn).toHaveBeenCalledWith( + expect.stringContaining('initialTab "nope"') + ); + expect(mockUseActiveScrollId).toHaveBeenCalledWith('a'); + expect(contextValue.initialIndex).toBe(0); + warn.mockRestore(); + }); + + it('throws without tabs', () => { + expect(() => renderRoot({ tabs: [] })).toThrow('at least one tab'); + }); + + it('goTo moves the pager and activates the tab', () => { + const onTabChange = jest.fn(); + const { contextValue } = renderRoot({ onTabChange }); + const setIndex = jest.fn(); + contextValue.controllerRef.current = { setIndex }; + + contextValue.goTo('b'); + + expect(setIndex).toHaveBeenCalledWith(1); + expect(setActiveScrollId).toHaveBeenCalledWith('b'); + expect(onTabChange).toHaveBeenCalledWith('b'); + }); + + it('goTo on the active tab still moves the pager but does not re-activate', () => { + const onTabChange = jest.fn(); + const { contextValue } = renderRoot({ onTabChange }); + const setIndex = jest.fn(); + contextValue.controllerRef.current = { setIndex }; + + contextValue.goTo('a'); + + expect(setIndex).toHaveBeenCalledWith(0); + expect(setActiveScrollId).not.toHaveBeenCalled(); + expect(onTabChange).not.toHaveBeenCalled(); + }); + + it('goTo warns on unknown tabs', () => { + const warn = jest.spyOn(console, 'warn').mockImplementation(() => {}); + const { contextValue } = renderRoot({}); + + contextValue.goTo('nope'); + + expect(warn).toHaveBeenCalledWith( + expect.stringContaining('unknown tab "nope"') + ); + expect(setActiveScrollId).not.toHaveBeenCalled(); + warn.mockRestore(); + }); + + it('pager index changes activate the corresponding tab once', () => { + const onTabChange = jest.fn(); + const { contextValue } = renderRoot({ onTabChange }); + + contextValue.onPagerIndexChange(1); + expect(setActiveScrollId).toHaveBeenCalledWith('b'); + expect(onTabChange).toHaveBeenCalledWith('b'); + + setActiveScrollId.mockClear(); + onTabChange.mockClear(); + contextValue.onPagerIndexChange(0); + expect(setActiveScrollId).not.toHaveBeenCalled(); + expect(onTabChange).not.toHaveBeenCalled(); + }); +}); + +describe('CollapsibleTabs.Pager', () => { + const tabsContext = () => ({ + tabs: [ + { name: 'a', label: 'a' }, + { name: 'b', label: 'b' }, + ], + activeTab: 'a', + goTo: jest.fn(), + initialIndex: 1, + controllerRef: { current: null }, + onPagerIndexChange: jest.fn(), + }); + + beforeEach(() => { + mockUseCollapsibleTabsContextOrThrow.mockReset(); + }); + + it('renders the tabs through the default adapter with keyed pages', () => { + const contextValue = tabsContext(); + mockUseCollapsibleTabsContextOrThrow.mockReturnValue(contextValue); + + const element = CollapsibleTabs.Pager({ + children: [ + React.createElement(CollapsibleTabs.Tab, { name: 'a' }), + React.createElement(CollapsibleTabs.Tab, { name: 'b' }), + ], + }) as React.ReactElement; + + expect(element.type).toBe(DefaultCollapsibleTabsPagerAdapter); + expect(element.props.initialIndex).toBe(1); + expect(element.props.controllerRef).toBe(contextValue.controllerRef); + expect(element.props.onIndexChange).toBe(contextValue.onPagerIndexChange); + + const pages = element.props.children; + expect(pages).toHaveLength(2); + expect(pages[0].key).toBe('a'); + expect(pages[1].key).toBe('b'); + }); + + it('renders through a custom adapter when provided', () => { + mockUseCollapsibleTabsContextOrThrow.mockReturnValue({ + ...tabsContext(), + tabs: [{ name: 'a', label: 'a' }], + }); + const CustomAdapter = jest.fn(); + + const element = CollapsibleTabs.Pager({ + adapter: CustomAdapter, + children: React.createElement(CollapsibleTabs.Tab, { name: 'a' }), + }) as React.ReactElement; + + expect(element.type).toBe(CustomAdapter); + }); + + it('warns when the children do not match the tabs prop', () => { + const warn = jest.spyOn(console, 'warn').mockImplementation(() => {}); + mockUseCollapsibleTabsContextOrThrow.mockReturnValue(tabsContext()); + + CollapsibleTabs.Pager({ + children: React.createElement(CollapsibleTabs.Tab, { name: 'b' }), + }); + + expect(warn).toHaveBeenCalledWith( + expect.stringContaining('do not match the `tabs` prop') + ); + warn.mockRestore(); + }); +}); + +describe('CollapsibleTabs.Tab', () => { + it('provides its name as the default scrollId', () => { + const child = React.createElement('Child'); + const element = CollapsibleTabs.Tab({ + name: 'feed', + children: child, + }) as React.ReactElement; + + expect(element.type).toBe(View); + expect(element.props.collapsable).toBe(false); + + const provider = element.props.children; + expect(provider.type).toBe(HeaderMotionScrollIdContext.Provider); + expect(provider.props.value).toBe('feed'); + expect(provider.props.children).toBe(child); + }); +}); + +describe('CollapsibleTabs.Bar', () => { + beforeEach(() => { + mockUseCollapsibleTabsContextOrThrow.mockReset(); + }); + + it('renders a pressable per tab and navigates on press', () => { + const goTo = jest.fn(); + mockUseCollapsibleTabsContextOrThrow.mockReturnValue({ + tabs: [ + { name: 'a', label: 'Page A' }, + { name: 'b', label: 'Page B' }, + ], + activeTab: 'a', + goTo, + }); + + const element = CollapsibleTabs.Bar({}) as React.ReactElement; + expect(element.type).toBe(View); + + const [first, second] = element.props.children; + expect(first.type).toBe(Pressable); + expect(first.props.accessibilityState).toEqual({ selected: true }); + expect(second.props.accessibilityState).toEqual({ selected: false }); + + second.props.onPress(); + expect(goTo).toHaveBeenCalledWith('b'); + }); +}); diff --git a/src/collapsible/__tests__/pagerAdapters.test.tsx b/src/collapsible/__tests__/pagerAdapters.test.tsx new file mode 100644 index 0000000..8403e2f --- /dev/null +++ b/src/collapsible/__tests__/pagerAdapters.test.tsx @@ -0,0 +1,122 @@ +jest.mock('react', () => { + const ReactActual = jest.requireActual('react'); + + return { + ...ReactActual, + useMemo: (factory: () => unknown) => factory(), + useCallback: (callback: unknown) => callback, + useRef: (initial: unknown) => ({ current: initial }), + useEffect: jest.fn(), + useState: (initial: unknown) => [ + typeof initial === 'function' ? initial() : initial, + jest.fn(), + ], + useImperativeHandle: (ref: any, create: () => unknown) => { + const value = create(); + if (typeof ref === 'function') { + ref(value); + } else if (ref) { + ref.current = value; + } + }, + }; +}); + +import React from 'react'; +import { Dimensions, ScrollView } from 'react-native'; +import { + DefaultCollapsibleTabsPagerAdapter, + createPagerViewAdapter, + type CollapsibleTabsPagerController, +} from '../pagerAdapters'; + +describe('DefaultCollapsibleTabsPagerAdapter', () => { + const windowWidth = Dimensions.get('window').width; + + function render(overrides: Record = {}) { + const onIndexChange = jest.fn(); + const controllerRef: { current: CollapsibleTabsPagerController | null } = { + current: null, + }; + const element = DefaultCollapsibleTabsPagerAdapter({ + initialIndex: 0, + onIndexChange, + controllerRef, + children: [ + React.createElement('PageA', { key: 'a' }), + React.createElement('PageB', { key: 'b' }), + ], + ...overrides, + }) as React.ReactElement; + + return { element, onIndexChange, controllerRef }; + } + + it('renders a paging horizontal ScrollView with fixed-width pages', () => { + const { element } = render(); + + expect(element.type).toBe(ScrollView); + expect(element.props.horizontal).toBe(true); + expect(element.props.pagingEnabled).toBe(true); + expect(element.props.showsHorizontalScrollIndicator).toBe(false); + + const pages = element.props.children; + expect(pages).toHaveLength(2); + expect(pages[0].props.style[1]).toEqual({ width: windowWidth }); + }); + + it('starts at the initial index', () => { + const { element } = render({ initialIndex: 1 }); + + expect(element.props.contentOffset).toEqual({ x: windowWidth, y: 0 }); + }); + + it('reports page changes from momentum end offsets', () => { + const { element, onIndexChange } = render(); + + element.props.onMomentumScrollEnd({ + nativeEvent: { contentOffset: { x: windowWidth * 2 } }, + }); + + expect(onIndexChange).toHaveBeenCalledWith(2); + }); + + it('exposes a controller that does not crash before the ref attaches', () => { + const { controllerRef } = render(); + + expect(controllerRef.current).not.toBeNull(); + expect(() => controllerRef.current!.setIndex(1)).not.toThrow(); + }); +}); + +describe('createPagerViewAdapter', () => { + function FakePagerView() { + return null; + } + + it('maps the adapter contract onto pager-view props', () => { + const Adapter = createPagerViewAdapter(FakePagerView) as ( + props: unknown + ) => React.ReactElement; + const onIndexChange = jest.fn(); + const controllerRef: { current: CollapsibleTabsPagerController | null } = { + current: null, + }; + + const element = Adapter({ + initialIndex: 1, + onIndexChange, + controllerRef, + children: React.createElement('Page', { key: 'a' }), + }) as React.ReactElement; + + expect(element.type).toBe(FakePagerView); + expect(element.props.initialPage).toBe(1); + + element.props.onPageSelected({ nativeEvent: { position: 2 } }); + expect(onIndexChange).toHaveBeenCalledWith(2); + + expect(controllerRef.current).not.toBeNull(); + expect(() => controllerRef.current!.setIndex(0)).not.toThrow(); + }); +}); diff --git a/src/collapsible/__tests__/presets.test.ts b/src/collapsible/__tests__/presets.test.ts new file mode 100644 index 0000000..56734e4 --- /dev/null +++ b/src/collapsible/__tests__/presets.test.ts @@ -0,0 +1,176 @@ +import { + CollapsiblePresets, + createCollapsiblePreset, + mergeCollapsiblePartStyles, + resolveCollapsiblePartStyle, + resolveCollapsiblePresets, +} from '../presets'; +import type { CollapsiblePreset, CollapsiblePresetContext } from '../types'; + +const context = ( + progress: number, + progressThreshold = 100 +): CollapsiblePresetContext => ({ progress, progressThreshold }); + +describe('CollapsiblePresets', () => { + it('collapse counter-translates the wrapper and slides the content up', () => { + const preset = CollapsiblePresets.collapse(); + + expect(preset(context(0.5))).toEqual({ + dynamic: { transform: [{ translateY: 50 }] }, + dynamicContent: { transform: [{ translateY: -50 }] }, + }); + expect(preset(context(0))).toEqual({ + dynamic: { transform: [{ translateY: 0 }] }, + dynamicContent: { transform: [{ translateY: -0 }] }, + }); + }); + + it('fade maps progress onto opacity over its range', () => { + const preset = CollapsiblePresets.fade(); + + expect(preset(context(0)).dynamicContent).toEqual({ opacity: 1 }); + expect(preset(context(0.4)).dynamicContent).toEqual({ opacity: 0.5 }); + expect(preset(context(0.8)).dynamicContent).toEqual({ opacity: 0 }); + expect(preset(context(1)).dynamicContent).toEqual({ opacity: 0 }); + }); + + it('fade respects a custom range', () => { + const preset = CollapsiblePresets.fade({ from: 0.2, to: 0.7 }); + + expect(preset(context(0.2)).dynamicContent).toEqual({ opacity: 1 }); + expect(preset(context(0.45)).dynamicContent!.opacity).toBeCloseTo(0.5); + expect(preset(context(0.7)).dynamicContent).toEqual({ opacity: 0 }); + }); + + it('parallax lags the content by the configured factor', () => { + const preset = CollapsiblePresets.parallax({ factor: 0.25, fade: false }); + + expect(preset(context(0.8, 200)).dynamicContent).toEqual({ + transform: [{ translateY: 40 }], + }); + }); + + it('parallax fades by default', () => { + const preset = CollapsiblePresets.parallax(); + const styles = preset(context(0.8)).dynamicContent!; + + expect(styles.transform).toEqual([{ translateY: 40 }]); + expect(styles.opacity).toBe(0); + }); + + it('scale shrinks toward the configured end scale', () => { + const preset = CollapsiblePresets.scale({ to: 0.5, fade: false }); + + expect(preset(context(1)).dynamicContent).toEqual({ + transform: [{ scale: 0.5 }], + }); + expect(preset(context(0)).dynamicContent).toEqual({ + transform: [{ scale: 1 }], + }); + }); + + it('none produces no styles', () => { + expect(CollapsiblePresets.none()(context(0.5))).toEqual({}); + }); +}); + +describe('createCollapsiblePreset', () => { + it('returns the preset unchanged', () => { + const preset: CollapsiblePreset = () => ({}); + expect(createCollapsiblePreset(preset)).toBe(preset); + }); +}); + +describe('resolveCollapsiblePresets', () => { + it('resolves built-in names with default options', () => { + const [preset] = resolveCollapsiblePresets('fade'); + expect(preset!(context(0.8)).dynamicContent).toEqual({ opacity: 0 }); + }); + + it('keeps preset functions as-is and supports mixed arrays', () => { + const custom: CollapsiblePreset = () => ({ header: { opacity: 0.5 } }); + const resolved = resolveCollapsiblePresets(['collapse', custom]); + + expect(resolved).toHaveLength(2); + expect(resolved[1]).toBe(custom); + expect(resolved[0]!(context(1)).dynamic).toEqual({ + transform: [{ translateY: 100 }], + }); + }); + + it('throws on unknown preset names', () => { + expect(() => + resolveCollapsiblePresets('spin' as unknown as 'collapse') + ).toThrow('Unknown collapsible preset "spin"'); + }); +}); + +describe('mergeCollapsiblePartStyles', () => { + it('concatenates transforms in order', () => { + expect( + mergeCollapsiblePartStyles([ + { transform: [{ translateY: 10 }] }, + undefined, + { transform: [{ scale: 0.5 }] }, + ]) + ).toEqual({ + transform: [{ translateY: 10 }, { scale: 0.5 }], + }); + }); + + it('multiplies numeric opacities', () => { + expect( + mergeCollapsiblePartStyles([{ opacity: 0.5 }, { opacity: 0.5 }]) + ).toEqual({ opacity: 0.25 }); + }); + + it('lets the last style win for other properties', () => { + expect( + mergeCollapsiblePartStyles([ + { backgroundColor: 'red', borderTopWidth: 1 }, + { backgroundColor: 'blue' }, + ]) + ).toEqual({ backgroundColor: 'blue', borderTopWidth: 1 }); + }); + + it('omits transform and opacity keys when no style defines them', () => { + expect(mergeCollapsiblePartStyles([{ borderTopWidth: 2 }])).toEqual({ + borderTopWidth: 2, + }); + }); +}); + +describe('resolveCollapsiblePartStyle', () => { + it('merges the intrinsic style with every preset result for the part', () => { + const presets = resolveCollapsiblePresets([ + 'collapse', + CollapsiblePresets.fade(), + ]); + + expect( + resolveCollapsiblePartStyle( + 'dynamicContent', + { transform: [{ translateX: 5 }] }, + presets, + context(0.4) + ) + ).toEqual({ + transform: [{ translateX: 5 }, { translateY: -40 }], + opacity: 0.5, + }); + }); + + it('returns only the intrinsic style when presets skip the part', () => { + const presets = resolveCollapsiblePresets('none'); + + expect( + resolveCollapsiblePartStyle( + 'header', + { transform: [{ translateY: -40 }] }, + presets, + context(0.4) + ) + ).toEqual({ transform: [{ translateY: -40 }] }); + }); +}); diff --git a/src/collapsible/__tests__/useCollapsibleHeader.test.ts b/src/collapsible/__tests__/useCollapsibleHeader.test.ts new file mode 100644 index 0000000..a165780 --- /dev/null +++ b/src/collapsible/__tests__/useCollapsibleHeader.test.ts @@ -0,0 +1,128 @@ +const mockUseHeaderMotionContextOrThrow = jest.fn(); + +jest.mock('react', () => { + const ReactActual = jest.requireActual('react'); + + return { + ...ReactActual, + useMemo: (factory: () => unknown) => factory(), + useCallback: (callback: unknown) => callback, + }; +}); + +jest.mock('../../context', () => { + const actual = jest.requireActual('../../context'); + + return { + __esModule: true, + ...actual, + useHeaderMotionContextOrThrow: (...args: any[]) => + mockUseHeaderMotionContextOrThrow(...args), + }; +}); + +import { useCollapsibleHeader } from '../useCollapsibleHeader'; + +const worklets = require('react-native-worklets'); + +function createSharedValue(value: T) { + return { + get: jest.fn(() => value), + set: jest.fn(), + value, + } as any; +} + +describe('useCollapsibleHeader', () => { + let scheduleOnUISpy: jest.SpyInstance; + + beforeAll(() => { + scheduleOnUISpy = jest + .spyOn(worklets, 'scheduleOnUI') + .mockImplementation((worklet: any, ...args: any[]) => worklet(...args)); + }); + + afterAll(() => { + scheduleOnUISpy.mockRestore(); + }); + + beforeEach(() => { + mockUseHeaderMotionContextOrThrow.mockReset(); + scheduleOnUISpy.mockClear(); + }); + + function createContext({ + scrollValues = { feed: { min: 20, current: 60 } }, + activeScrollId, + scrollTo = jest.fn(), + }: { + scrollValues?: Record; + activeScrollId?: any; + scrollTo?: jest.Mock | null; + } = {}) { + const ctxValue = { + progress: createSharedValue(0.5), + progressThreshold: createSharedValue(100), + scrollValues: createSharedValue(scrollValues), + activeScrollId, + scrollToRef: { current: scrollTo }, + }; + mockUseHeaderMotionContextOrThrow.mockReturnValue(ctxValue); + return { ctxValue, scrollTo }; + } + + it('collapse scrolls the resolved scrollable to the collapsed offset', () => { + const { scrollTo } = createContext(); + + useCollapsibleHeader().collapse(); + + expect(scrollTo).toHaveBeenCalledWith(120, { + isValueDelta: false, + animated: true, + }); + }); + + it('expand scrolls back to the scrollable minimum', () => { + const { scrollTo } = createContext(); + + useCollapsibleHeader().expand({ animated: false }); + + expect(scrollTo).toHaveBeenCalledWith(20, { + isValueDelta: false, + animated: false, + }); + }); + + it('targets the active scrollable in multi-scroll setups', () => { + const { scrollTo } = createContext({ + scrollValues: { + a: { min: 0, current: 0 }, + b: { min: 5, current: 40 }, + }, + activeScrollId: createSharedValue('b'), + }); + + useCollapsibleHeader().collapse(); + + expect(scrollTo).toHaveBeenCalledWith(105, { + isValueDelta: false, + animated: true, + }); + }); + + it('does nothing when no scrollable is connected yet', () => { + createContext({ scrollTo: null }); + + expect(() => useCollapsibleHeader().collapse()).not.toThrow(); + expect(scheduleOnUISpy).not.toHaveBeenCalled(); + }); + + it('exposes the motion progress values', () => { + const { ctxValue } = createContext(); + + const result = useCollapsibleHeader(); + + expect(result.progress).toBe(ctxValue.progress); + expect(result.progressThreshold).toBe(ctxValue.progressThreshold); + }); +}); diff --git a/src/collapsible/context.ts b/src/collapsible/context.ts new file mode 100644 index 0000000..a9098f7 --- /dev/null +++ b/src/collapsible/context.ts @@ -0,0 +1,53 @@ +import { createContext, useContext, type RefObject } from 'react'; +import type { CollapsibleTabsPagerController } from './pagerAdapters'; +import type { CollapsiblePreset } from './types'; + +export const CollapsiblePresetsContext = createContext< + readonly CollapsiblePreset[] | null +>(null); + +export function useCollapsiblePresetsOrThrow( + componentName: string +): readonly CollapsiblePreset[] { + const presets = useContext(CollapsiblePresetsContext); + if (!presets) { + throw new Error( + `${componentName} must be used within or . ` + + 'If you are rendering inside a navigation header, use so the collapsible context crosses the tree boundary.' + ); + } + + return presets; +} + +/** A tab entry after normalization — `label` falls back to `name`. */ +export interface CollapsibleTabDescriptor { + name: string; + label: string; +} + +export interface CollapsibleTabsContextValue { + tabs: readonly CollapsibleTabDescriptor[]; + activeTab: string; + goTo: (name: string) => void; + initialIndex: number; + controllerRef: RefObject; + onPagerIndexChange: (index: number) => void; +} + +export const CollapsibleTabsContext = + createContext(null); + +export function useCollapsibleTabsContextOrThrow( + componentName: string +): CollapsibleTabsContextValue { + const ctxValue = useContext(CollapsibleTabsContext); + if (!ctxValue) { + throw new Error( + `${componentName} must be used within . ` + + 'If you are rendering inside a navigation header, use so the tabs context crosses the tree boundary.' + ); + } + + return ctxValue; +} diff --git a/src/collapsible/index.ts b/src/collapsible/index.ts new file mode 100644 index 0000000..10e3930 --- /dev/null +++ b/src/collapsible/index.ts @@ -0,0 +1,49 @@ +export { + Collapsible, + type CollapsibleProps, + type CollapsibleHeaderProps, + type CollapsiblePinnedProps, + type CollapsibleDynamicProps, + type CollapsibleNavigationHeaderProps, +} from './Collapsible'; +export { + CollapsibleTabs, + type CollapsibleTabsProps, + type CollapsibleTabsTabInput, + type CollapsibleTabsPagerProps, + type CollapsibleTabProps, + type CollapsibleTabsBarProps, +} from './CollapsibleTabs'; +export { + CollapsiblePresets, + createCollapsiblePreset, + type CollapsibleFadeOptions, + type CollapsibleParallaxOptions, + type CollapsibleScaleOptions, +} from './presets'; +export { + DefaultCollapsibleTabsPagerAdapter, + createPagerViewAdapter, + type CollapsiblePagerViewLikeProps, + type CollapsibleTabsPagerAdapter, + type CollapsibleTabsPagerAdapterProps, + type CollapsibleTabsPagerController, +} from './pagerAdapters'; +export { + useCollapsibleHeader, + type CollapsibleScrollOptions, + type UseCollapsibleHeaderResult, +} from './useCollapsibleHeader'; +export { + useCollapsibleTabs, + type UseCollapsibleTabsResult, +} from './useCollapsibleTabs'; +export type { CollapsibleTabDescriptor } from './context'; +export type { + CollapsibleHeaderState, + CollapsiblePreset, + CollapsiblePresetContext, + CollapsiblePresetInput, + CollapsiblePresetName, + CollapsiblePresetPartStyles, +} from './types'; diff --git a/src/collapsible/pagerAdapters.tsx b/src/collapsible/pagerAdapters.tsx new file mode 100644 index 0000000..7676b61 --- /dev/null +++ b/src/collapsible/pagerAdapters.tsx @@ -0,0 +1,220 @@ +import { + Children, + useCallback, + useEffect, + useImperativeHandle, + useRef, + useState, + type ComponentRef, + type ComponentType, + type ReactNode, + type Ref, +} from 'react'; +import { + ScrollView, + StyleSheet, + View, + useWindowDimensions, + type LayoutChangeEvent, + type NativeScrollEvent, + type NativeSyntheticEvent, + type StyleProp, + type ViewStyle, +} from 'react-native'; + +/** + * Imperative surface a pager adapter must expose so `CollapsibleTabs` can + * change pages programmatically (for example from a tab-bar press). + */ +export interface CollapsibleTabsPagerController { + /** Animates the pager to the page at `index`. */ + setIndex: (index: number) => void; +} + +/** + * Props every pager adapter receives from `CollapsibleTabs.Pager`. + * + * An adapter renders `children` (one element per tab, in order), reports + * user-driven page changes through `onIndexChange`, and assigns a + * {@link CollapsibleTabsPagerController} to `controllerRef` for programmatic + * page changes. + */ +export interface CollapsibleTabsPagerAdapterProps { + /** Index of the page that should be shown initially. */ + initialIndex: number; + /** Reports that the user moved the pager to the page at `index`. */ + onIndexChange: (index: number) => void; + /** Receives the adapter's imperative controller. */ + controllerRef: Ref; + /** Style for the pager container. Defaults to `flex: 1` when omitted. */ + style?: StyleProp; + /** The tab pages, one element per tab, in tab order. */ + children: ReactNode; +} + +/** + * A pager engine for `CollapsibleTabs.Pager`. + * + * The library ships a dependency-free default (a paging horizontal + * `ScrollView`) and `createPagerViewAdapter()` for `react-native-pager-view`. + * Provide your own adapter to plug in any other pager implementation. + */ +export type CollapsibleTabsPagerAdapter = + ComponentType; + +const styles = StyleSheet.create({ + pager: { + flex: 1, + }, + page: { + height: '100%', + }, +}); + +/** + * Default pager engine: a paging horizontal `ScrollView`. + * + * It has no dependencies beyond React Native, supports swiping between pages + * and programmatic page changes, and keeps every page mounted so scroll + * positions are preserved. For native pager behavior, pass + * `createPagerViewAdapter(PagerView)` to `CollapsibleTabs.Pager` instead. + */ +export function DefaultCollapsibleTabsPagerAdapter({ + initialIndex, + onIndexChange, + controllerRef, + style, + children, +}: CollapsibleTabsPagerAdapterProps) { + const { width: windowWidth } = useWindowDimensions(); + const [pageWidth, setPageWidth] = useState(windowWidth); + const pageWidthRef = useRef(pageWidth); + const scrollRef = useRef>(null); + + useEffect(() => { + pageWidthRef.current = pageWidth; + }, [pageWidth]); + + useImperativeHandle( + controllerRef, + () => ({ + setIndex: (index: number) => { + scrollRef.current?.scrollTo({ + x: index * pageWidthRef.current, + animated: true, + }); + }, + }), + [] + ); + + const handleLayout = useCallback((e: LayoutChangeEvent) => { + const width = e.nativeEvent.layout.width; + if (width > 0) { + setPageWidth((previous) => (previous === width ? previous : width)); + } + }, []); + + const handleMomentumScrollEnd = useCallback( + (e: NativeSyntheticEvent) => { + const width = pageWidthRef.current; + if (width <= 0) { + return; + } + + onIndexChange(Math.round(e.nativeEvent.contentOffset.x / width)); + }, + [onIndexChange] + ); + + return ( + + {Children.map(children, (child) => ( + {child} + ))} + + ); +} + +/** + * Structural subset of `react-native-pager-view`'s props that the adapter + * relies on. Typed loosely on purpose so the library does not depend on + * `react-native-pager-view`'s types. + */ +export interface CollapsiblePagerViewLikeProps { + ref?: Ref; + style?: StyleProp; + initialPage?: number; + onPageSelected?: (event: { + nativeEvent: { + position: number; + }; + }) => void; + children?: ReactNode; +} + +/** + * Creates a `CollapsibleTabs.Pager` adapter backed by + * `react-native-pager-view`. + * + * The component is passed in by the caller so the library never imports + * `react-native-pager-view` itself — it stays a regular dependency of *your* + * app, not of `react-native-header-motion`. + * + * @example + * ```tsx + * import PagerView from 'react-native-pager-view'; + * import { createPagerViewAdapter } from 'react-native-header-motion'; + * + * const pagerAdapter = createPagerViewAdapter(PagerView); + * + * + * ``` + */ +export function createPagerViewAdapter( + PagerViewComponent: ComponentType +): CollapsibleTabsPagerAdapter { + function PagerViewAdapter({ + initialIndex, + onIndexChange, + controllerRef, + style, + children, + }: CollapsibleTabsPagerAdapterProps) { + const pagerRef = useRef<{ setPage?: (index: number) => void } | null>(null); + + useImperativeHandle( + controllerRef, + () => ({ + setIndex: (index: number) => { + pagerRef.current?.setPage?.(index); + }, + }), + [] + ); + + return ( + onIndexChange(e.nativeEvent.position)} + > + {children} + + ); + } + + PagerViewAdapter.displayName = 'CollapsibleTabs.PagerViewAdapter'; + return PagerViewAdapter; +} diff --git a/src/collapsible/presets.ts b/src/collapsible/presets.ts new file mode 100644 index 0000000..1839d6b --- /dev/null +++ b/src/collapsible/presets.ts @@ -0,0 +1,280 @@ +import type { ViewStyle } from 'react-native'; +import type { + CollapsiblePreset, + CollapsiblePresetContext, + CollapsiblePresetInput, + CollapsiblePresetName, + CollapsiblePresetPartStyles, +} from './types'; + +function clamp01(value: number): number { + 'worklet'; + return Math.min(1, Math.max(0, value)); +} + +function rangeProgress(progress: number, from: number, to: number): number { + 'worklet'; + if (to <= from) { + return progress >= to ? 1 : 0; + } + + return clamp01((progress - from) / (to - from)); +} + +/** + * Identity helper for authoring custom collapsible presets with full typing. + * + * The preset function runs on the UI thread inside the collapsible parts' + * animated styles, so it **must be marked with the `'worklet'` directive**. + * + * @example + * ```ts + * const myPreset = createCollapsiblePreset(({ progress, progressThreshold }) => { + * 'worklet'; + * return { + * dynamicContent: { opacity: 1 - progress }, + * header: { borderBottomWidth: progress }, + * }; + * }); + * ``` + */ +export function createCollapsiblePreset( + preset: CollapsiblePreset +): CollapsiblePreset { + return preset; +} + +export interface CollapsibleFadeOptions { + /** + * Progress value at which the fade starts. + * + * @default 0 + */ + from?: number; + /** + * Progress value at which the content is fully transparent. + * + * @default 0.8 + */ + to?: number; +} + +export interface CollapsibleParallaxOptions { + /** + * Fraction of the collapse distance the content lags behind by. `0` moves + * the content 1:1 with the header, `1` keeps it visually still. + * + * @default 0.5 + */ + factor?: number; + /** + * Also fades the content out while it collapses. + * + * @default true + */ + fade?: boolean; +} + +export interface CollapsibleScaleOptions { + /** + * Scale of the content in the fully collapsed state. + * + * @default 0.9 + */ + to?: number; + /** + * Also fades the content out while it collapses. + * + * @default true + */ + fade?: boolean; +} + +const collapse = (): CollapsiblePreset => { + return (context: CollapsiblePresetContext) => { + 'worklet'; + const offset = context.progress * context.progressThreshold; + + return { + dynamic: { transform: [{ translateY: offset }] }, + dynamicContent: { transform: [{ translateY: -offset }] }, + }; + }; +}; + +const fade = ({ + from = 0, + to = 0.8, +}: CollapsibleFadeOptions = {}): CollapsiblePreset => { + return (context: CollapsiblePresetContext) => { + 'worklet'; + + return { + dynamicContent: { + opacity: 1 - rangeProgress(context.progress, from, to), + }, + }; + }; +}; + +const parallax = ({ + factor = 0.5, + fade: withFade = true, +}: CollapsibleParallaxOptions = {}): CollapsiblePreset => { + return (context: CollapsiblePresetContext) => { + 'worklet'; + const transform = [ + { translateY: context.progress * context.progressThreshold * factor }, + ]; + const dynamicContent: ViewStyle = withFade + ? { transform, opacity: 1 - rangeProgress(context.progress, 0, 0.8) } + : { transform }; + + return { dynamicContent }; + }; +}; + +const scale = ({ + to = 0.9, + fade: withFade = true, +}: CollapsibleScaleOptions = {}): CollapsiblePreset => { + return (context: CollapsiblePresetContext) => { + 'worklet'; + const transform = [{ scale: 1 - (1 - to) * context.progress }]; + const dynamicContent: ViewStyle = withFade + ? { transform, opacity: 1 - rangeProgress(context.progress, 0, 0.8) } + : { transform }; + + return { dynamicContent }; + }; +}; + +const none = (): CollapsiblePreset => { + return () => { + 'worklet'; + return {}; + }; +}; + +/** + * Built-in collapsible presets as configurable factories. + * + * Every factory returns a `CollapsiblePreset` you can pass to the `preset` + * prop, alone or in an array to combine effects: + * + * ```tsx + * + * + * ``` + * + * The string shorthands (`preset="parallax"`) resolve to these factories with + * their default options. + * + * - `collapse` — content slides up and is clipped under the pinned sections + * (the classic iOS large-title behavior). + * - `fade` — content fades out in place while the header collapses. + * - `parallax` — content lags behind the collapse and fades out. + * - `scale` — content shrinks and fades out. + * - `none` — no content effect; the header still slides up. + */ +export const CollapsiblePresets = { + collapse, + fade, + parallax, + scale, + none, +}; + +const BUILT_IN_PRESETS: Record CollapsiblePreset> = + CollapsiblePresets; + +/** + * Normalizes a `preset` prop value into a flat list of preset functions, + * resolving built-in names to their default-configured factories. + */ +export function resolveCollapsiblePresets( + input: CollapsiblePresetInput +): readonly CollapsiblePreset[] { + const items = Array.isArray(input) ? input : [input]; + + return (items as readonly (CollapsiblePresetName | CollapsiblePreset)[]).map( + (item) => { + if (typeof item !== 'string') { + return item; + } + + const factory = BUILT_IN_PRESETS[item]; + if (!factory) { + throw new Error( + `[react-native-header-motion] Unknown collapsible preset "${item}". ` + + `Available presets: ${Object.keys(BUILT_IN_PRESETS).join(', ')}.` + ); + } + + return factory(); + } + ); +} + +/** + * Merges part styles coming from multiple presets into one style object. + * + * `transform` arrays are concatenated in order, numeric `opacity` values are + * multiplied, and every other property is taken from the last style that + * defines it. + */ +export function mergeCollapsiblePartStyles( + styles: readonly (ViewStyle | undefined)[] +): ViewStyle { + 'worklet'; + const result: Record = {}; + let transform: unknown[] | null = null; + let opacity: number | null = null; + + for (let i = 0; i < styles.length; i++) { + const style = styles[i] as Record | undefined; + if (!style) { + continue; + } + + for (const key in style) { + const value = style[key]; + if (key === 'transform' && Array.isArray(value)) { + transform = transform ? transform.concat(value) : value.slice(); + } else if (key === 'opacity' && typeof value === 'number') { + opacity = opacity === null ? value : opacity * value; + } else { + result[key] = value; + } + } + } + + if (transform) { + result.transform = transform; + } + if (opacity !== null) { + result.opacity = opacity; + } + + return result as ViewStyle; +} + +/** + * Evaluates every preset for one header part and merges the results on top of + * the part's intrinsic style. + */ +export function resolveCollapsiblePartStyle( + part: keyof CollapsiblePresetPartStyles, + intrinsic: ViewStyle | undefined, + presets: readonly CollapsiblePreset[], + context: CollapsiblePresetContext +): ViewStyle { + 'worklet'; + const collected: (ViewStyle | undefined)[] = [intrinsic]; + + for (let i = 0; i < presets.length; i++) { + collected.push(presets[i]!(context)[part]); + } + + return mergeCollapsiblePartStyles(collected); +} diff --git a/src/collapsible/types.ts b/src/collapsible/types.ts new file mode 100644 index 0000000..84e220d --- /dev/null +++ b/src/collapsible/types.ts @@ -0,0 +1,85 @@ +import type { ViewStyle } from 'react-native'; + +/** + * Snapshot of the motion state a collapsible preset is evaluated against. + * + * The object is intentionally open-ended: future versions may extend it with + * additional state (for example pull-to-refresh progress once the headless + * refresh control lands) without breaking existing presets. Presets should + * read the fields they need and ignore the rest. + */ +export interface CollapsiblePresetContext { + /** + * Current header-motion progress, usually in the `0..1` range where `0` is + * fully expanded and `1` is fully collapsed. + */ + progress: number; + /** + * Pixel distance that maps `progress` from `0` to `1`. This is the measured + * height of `Collapsible.Dynamic` unless overridden via `progressThreshold`. + */ + progressThreshold: number; +} + +/** + * Styles a preset produces for each part of a collapsible header. + * + * All keys are optional — a preset only describes the parts it wants to + * animate. When multiple presets are combined, their part styles are merged: + * `transform` arrays are concatenated, numeric `opacity` values are + * multiplied, and any other property is taken from the last preset that + * defines it. + */ +export interface CollapsiblePresetPartStyles { + /** + * Style for the header container (`Collapsible.Header`), merged on top of + * its intrinsic slide-up transform. + */ + header?: ViewStyle; + /** + * Style for pinned sections (`Collapsible.Pinned`), merged on top of their + * intrinsic counter-translate transform. + */ + pinned?: ViewStyle; + /** + * Style for the measured collapsing wrapper (`Collapsible.Dynamic`). Use + * transforms only — this element's layout defines the collapse distance. + */ + dynamic?: ViewStyle; + /** + * Style for the content inside `Collapsible.Dynamic`. This is where most + * visual effects (fade, parallax, scale) belong. + */ + dynamicContent?: ViewStyle; +} + +/** + * A collapsible preset: a worklet mapping the current motion state to styles + * for the collapsible header parts. + * + * Custom presets **must be marked with the `'worklet'` directive** — they run + * on the UI thread inside the parts' animated styles. + */ +export type CollapsiblePreset = ( + context: CollapsiblePresetContext +) => CollapsiblePresetPartStyles; + +/** Names of the built-in collapsible presets. */ +export type CollapsiblePresetName = + | 'collapse' + | 'fade' + | 'parallax' + | 'scale' + | 'none'; + +/** + * Anything accepted by the `preset` prop: a built-in preset name, a configured + * or custom preset function, or an array of those to combine. + */ +export type CollapsiblePresetInput = + | CollapsiblePresetName + | CollapsiblePreset + | readonly (CollapsiblePresetName | CollapsiblePreset)[]; + +/** Terminal states reported by `Collapsible`'s `onStateChange`. */ +export type CollapsibleHeaderState = 'expanded' | 'collapsed'; diff --git a/src/collapsible/useCollapsibleHeader.ts b/src/collapsible/useCollapsibleHeader.ts new file mode 100644 index 0000000..04e3623 --- /dev/null +++ b/src/collapsible/useCollapsibleHeader.ts @@ -0,0 +1,87 @@ +import { useCallback, useMemo } from 'react'; +import { scheduleOnUI } from 'react-native-worklets'; +import { useHeaderMotionContextOrThrow } from '../context'; +import type { MotionProgress } from '../types'; +import { resolveScrollIdForProgress } from '../utils'; + +export interface CollapsibleScrollOptions { + /** + * Animates the scroll to the target state. + * + * @default true + */ + animated?: boolean; +} + +export interface UseCollapsibleHeaderResult extends MotionProgress { + /** Scrolls the active scrollable so the header fully collapses. */ + collapse: (options?: CollapsibleScrollOptions) => void; + /** Scrolls the active scrollable so the header fully expands. */ + expand: (options?: CollapsibleScrollOptions) => void; +} + +/** + * Motion state and imperative controls for a collapsible header. + * + * Works anywhere inside a `Collapsible`, `CollapsibleTabs`, or plain + * `HeaderMotion` tree. `collapse()` and `expand()` scroll the currently + * active scrollable to the corresponding terminal state. + * + * @example + * ```tsx + * const { progress, collapse, expand } = useCollapsibleHeader(); + * ``` + */ +export function useCollapsibleHeader(): UseCollapsibleHeaderResult { + const { + progress, + progressThreshold, + scrollValues, + activeScrollId, + scrollToRef, + } = useHeaderMotionContextOrThrow( + 'useCollapsibleHeader must be used within , , or .' + ); + + const scrollToProgress = useCallback( + (target: 0 | 1, options?: CollapsibleScrollOptions) => { + const scrollToActive = scrollToRef.current; + if (!scrollToActive) { + return; + } + + const animated = options?.animated ?? true; + + scheduleOnUI(() => { + 'worklet'; + const values = scrollValues.get(); + const id = resolveScrollIdForProgress(values, activeScrollId?.get()); + const scrollValue = values[id]; + if (!scrollValue) { + return; + } + + const y = + target === 1 + ? scrollValue.min + progressThreshold.get() + : scrollValue.min; + scrollToActive(y, { isValueDelta: false, animated }); + }); + }, + [scrollToRef, scrollValues, activeScrollId, progressThreshold] + ); + + const collapse = useCallback( + (options?: CollapsibleScrollOptions) => scrollToProgress(1, options), + [scrollToProgress] + ); + const expand = useCallback( + (options?: CollapsibleScrollOptions) => scrollToProgress(0, options), + [scrollToProgress] + ); + + return useMemo( + () => ({ progress, progressThreshold, collapse, expand }), + [progress, progressThreshold, collapse, expand] + ); +} diff --git a/src/collapsible/useCollapsibleTabs.ts b/src/collapsible/useCollapsibleTabs.ts new file mode 100644 index 0000000..890972c --- /dev/null +++ b/src/collapsible/useCollapsibleTabs.ts @@ -0,0 +1,34 @@ +import { useMemo } from 'react'; +import { + useCollapsibleTabsContextOrThrow, + type CollapsibleTabDescriptor, +} from './context'; + +export interface UseCollapsibleTabsResult { + /** The normalized tabs, in pager order. */ + tabs: readonly CollapsibleTabDescriptor[]; + /** Name of the currently active tab. */ + activeTab: string; + /** Moves the pager to the given tab and makes it active. */ + goTo: (name: string) => void; +} + +/** + * Tab state and navigation for a `CollapsibleTabs` tree. + * + * Use it to build a custom tab bar (or any other tab-aware UI) in place of + * `CollapsibleTabs.Bar`. + * + * @example + * ```tsx + * function MyTabBar() { + * const { tabs, activeTab, goTo } = useCollapsibleTabs(); + * } + * ``` + */ +export function useCollapsibleTabs(): UseCollapsibleTabsResult { + const { tabs, activeTab, goTo } = + useCollapsibleTabsContextOrThrow('useCollapsibleTabs'); + + return useMemo(() => ({ tabs, activeTab, goTo }), [tabs, activeTab, goTo]); +} diff --git a/src/components/HeaderMotion.tsx b/src/components/HeaderMotion.tsx index f14ffda..4aaa75f 100644 --- a/src/components/HeaderMotion.tsx +++ b/src/components/HeaderMotion.tsx @@ -22,34 +22,9 @@ import { DEFAULT_PROGRESS_THRESHOLD, DEFAULT_SCROLL_ID, getInitialScrollValue, + resolveScrollIdForProgress, } from '../utils'; -const resolveScrollIdForProgress = ( - scrollValues: ScrollValues, - activeScrollIdValue: string | undefined -) => { - 'worklet'; - - if (activeScrollIdValue) { - return activeScrollIdValue; - } - - let onlyNonDefaultId: string | null = null; - for (const key in scrollValues) { - if (key === DEFAULT_SCROLL_ID) { - continue; - } - - if (onlyNonDefaultId !== null) { - return DEFAULT_SCROLL_ID; - } - - onlyNonDefaultId = key; - } - - return onlyNonDefaultId ?? DEFAULT_SCROLL_ID; -}; - export interface HeaderMotionProps { /** * Distance that maps the active scrollable from `progress = 0` diff --git a/src/context.ts b/src/context.ts index 0adb73b..6f66c73 100644 --- a/src/context.ts +++ b/src/context.ts @@ -4,6 +4,19 @@ import type { HeaderMotionBridgeValue } from './types'; export const HeaderMotionContext = createContext(null); +/** + * Provides a default `scrollId` to every header-motion scrollable in its + * subtree. + * + * An explicit `scrollId` prop always wins over this context. Container + * components that own a scroll region (for example `CollapsibleTabs.Tab`) + * provide it so pre-wired and custom scrollables participate in multi-scroll + * setups without manual `scrollId` wiring. + */ +export const HeaderMotionScrollIdContext = createContext( + undefined +); + export function useHeaderMotionContextOrThrow(errorMessage: string) { const ctxValue = useContext(HeaderMotionContext); if (!ctxValue) { diff --git a/src/hooks/useScrollManager.ts b/src/hooks/useScrollManager.ts index cbc618c..1f8ffc7 100644 --- a/src/hooks/useScrollManager.ts +++ b/src/hooks/useScrollManager.ts @@ -16,7 +16,7 @@ import { type ScrollHandler, } from 'react-native-reanimated'; import { scheduleOnRN, scheduleOnUI } from 'react-native-worklets'; -import { HeaderMotionContext } from '../context'; +import { HeaderMotionContext, HeaderMotionScrollIdContext } from '../context'; import type { ScrollManagerConfig, ScrollHandlerContext } from '../types'; import type { LayoutChangeEvent } from 'react-native'; import { @@ -336,7 +336,9 @@ export interface UseScrollManagerOptions * 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. + * In single-scroll setups, you usually do not need one. Container components + * that own a scroll region (for example `CollapsibleTabs.Tab`) can provide a + * default id through context; an explicit `scrollId` always wins over it. * * If you need the same fallback behavior but prefer render-prop composition * over a hook, use `HeaderMotion.ScrollManager`. @@ -375,7 +377,8 @@ export function useScrollManager( options?: UseScrollManagerOptions ): ScrollManagerConfig { const { originalHeaderHeight } = useScrollManagerContext(); - const id = scrollId ?? DEFAULT_SCROLL_ID; + const contextScrollId = useContext(HeaderMotionScrollIdContext); + const id = scrollId ?? contextScrollId ?? DEFAULT_SCROLL_ID; const ensureScrollableContentMinHeight = options?.ensureScrollableContentMinHeight ?? false; diff --git a/src/index.ts b/src/index.ts index b0075fa..b38519f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -105,6 +105,7 @@ const HeaderMotion: HeaderMotionCompound = Object.assign( export default HeaderMotion; export * from './hooks'; +export * from './collapsible'; export { ScrollablePresets } from './utils/presets'; export type * from './types'; export { createHeaderMotionScrollable }; diff --git a/src/utils/values.ts b/src/utils/values.ts index 540ccaa..77932c0 100644 --- a/src/utils/values.ts +++ b/src/utils/values.ts @@ -29,6 +29,37 @@ export function ensureScrollValueRegistered( return scrollValues.get(); } +/** + * Resolves which scrollable currently drives the shared `progress` value: + * the active scroll id when one is set, otherwise the only registered + * non-default id, falling back to the default id. + */ +export function resolveScrollIdForProgress( + scrollValues: ScrollValues, + activeScrollIdValue: string | undefined +): string { + 'worklet'; + + if (activeScrollIdValue) { + return activeScrollIdValue; + } + + let onlyNonDefaultId: string | null = null; + for (const key in scrollValues) { + if (key === DEFAULT_SCROLL_ID) { + continue; + } + + if (onlyNonDefaultId !== null) { + return DEFAULT_SCROLL_ID; + } + + onlyNonDefaultId = key; + } + + return onlyNonDefaultId ?? DEFAULT_SCROLL_ID; +} + export function warnIfMissingActiveScrollId( scrollValues: ScrollValues, id: string, From e0bb26abc7373430fdb3e81093e1425c170b576c Mon Sep 17 00:00:00 2001 From: Oskar Pawica Date: Sat, 15 Aug 2026 10:25:21 +0200 Subject: [PATCH 2/2] chore: add preset, custom-preset, and overlay example screens Adds one example screen per built-in preset (collapse, fade, parallax, scale, none) through a shared PresetShowcaseScreen component, a custom preset example (createCollapsiblePreset with a 3D fold and header tint), and an overlay example that uses Collapsible.Header without NavigationHeader. Replaces the previous collapsible-simple screen, which the parallax preset screen now covers. Co-Authored-By: Claude Fable 5 --- ...ble-simple.tsx => collapsible-overlay.tsx} | 19 ++-- example/src/app/index.tsx | 12 ++- example/src/app/preset-collapse.tsx | 12 +++ example/src/app/preset-custom.tsx | 82 ++++++++++++++++++ example/src/app/preset-fade.tsx | 12 +++ example/src/app/preset-none.tsx | 13 +++ example/src/app/preset-parallax.tsx | 12 +++ example/src/app/preset-scale.tsx | 12 +++ .../src/components/PresetShowcaseScreen.tsx | 86 +++++++++++++++++++ example/src/components/index.ts | 1 + 10 files changed, 249 insertions(+), 12 deletions(-) rename example/src/app/{collapsible-simple.tsx => collapsible-overlay.tsx} (68%) create mode 100644 example/src/app/preset-collapse.tsx create mode 100644 example/src/app/preset-custom.tsx create mode 100644 example/src/app/preset-fade.tsx create mode 100644 example/src/app/preset-none.tsx create mode 100644 example/src/app/preset-parallax.tsx create mode 100644 example/src/app/preset-scale.tsx create mode 100644 example/src/components/PresetShowcaseScreen.tsx diff --git a/example/src/app/collapsible-simple.tsx b/example/src/app/collapsible-overlay.tsx similarity index 68% rename from example/src/app/collapsible-simple.tsx rename to example/src/app/collapsible-overlay.tsx index 007b08a..ade30ff 100644 --- a/example/src/app/collapsible-simple.tsx +++ b/example/src/app/collapsible-overlay.tsx @@ -4,19 +4,20 @@ import { Stack } from 'expo-router'; import { StyleSheet } from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; +// No NavigationHeader here: the navigation header is hidden and +// Collapsible.Header renders in place, overlaying the content (the default +// `overlay` behavior of the low-level Header). export default function Screen() { const insets = useSafeAreaInsets(); return ( - - header }} />} - > + + + - + {content} ); @@ -50,6 +51,6 @@ const styles = StyleSheet.create({ const content = generateContent({ count: 500, - backgroundColor: '#E3CBFC', + backgroundColor: '#CBFCF5', textColor: '#304077', }); diff --git a/example/src/app/index.tsx b/example/src/app/index.tsx index 825528e..5a99013 100644 --- a/example/src/app/index.tsx +++ b/example/src/app/index.tsx @@ -16,10 +16,16 @@ const SECTIONS: ShowcaseSection[] = [ { title: 'High-level API', data: [ + { title: 'Preset: collapse', href: '/preset-collapse', icon: '🎁' }, + { title: 'Preset: fade', href: '/preset-fade', icon: '🌫️' }, + { title: 'Preset: parallax', href: '/preset-parallax', icon: '🎢' }, + { title: 'Preset: scale', href: '/preset-scale', icon: '🔍' }, + { title: 'Preset: none', href: '/preset-none', icon: '⬜' }, + { title: 'Custom preset (3D fold)', href: '/preset-custom', icon: '🧪' }, { - title: 'Collapsible (parallax preset)', - href: '/collapsible-simple', - icon: '🎁', + title: 'Overlay header (no NavigationHeader)', + href: '/collapsible-overlay', + icon: '🪟', }, { title: 'CollapsibleTabs (pager-view)', diff --git a/example/src/app/preset-collapse.tsx b/example/src/app/preset-collapse.tsx new file mode 100644 index 0000000..a8433ef --- /dev/null +++ b/example/src/app/preset-collapse.tsx @@ -0,0 +1,12 @@ +import { PresetShowcaseScreen } from '@/components'; + +export default function Screen() { + return ( + + ); +} diff --git a/example/src/app/preset-custom.tsx b/example/src/app/preset-custom.tsx new file mode 100644 index 0000000..5712964 --- /dev/null +++ b/example/src/app/preset-custom.tsx @@ -0,0 +1,82 @@ +import { DynamicBox, TitleWithSubtitle, generateContent } from '@/components'; +import { + Collapsible, + createCollapsiblePreset, +} from 'react-native-header-motion'; +import { Stack } from 'expo-router'; +import { StyleSheet } from 'react-native'; +import { interpolateColor } from 'react-native-reanimated'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; + +// A custom preset is a worklet from motion state to per-part styles. This one +// folds the collapsing content backwards in 3D, fades it, and darkens the +// header background while it collapses. +const fold = createCollapsiblePreset(({ progress, progressThreshold }) => { + 'worklet'; + return { + header: { + backgroundColor: interpolateColor( + progress, + [0, 1], + ['#304077', '#0E1A40'] + ), + }, + dynamicContent: { + opacity: 1 - progress, + transform: [ + { perspective: 600 }, + { rotateX: `${progress * 60}deg` }, + { translateY: progress * progressThreshold * 0.25 }, + ], + }, + }; +}); + +export default function Screen() { + const insets = useSafeAreaInsets(); + + return ( + + header }} />} + > + + + + + + + + + {content} + + ); +} + +const styles = StyleSheet.create({ + header: { + borderBottomWidth: 1, + borderBottomColor: 'rgba(0,0,0,0.1)', + }, + dynamic: { + padding: 12, + }, + boxRow: { + flexDirection: 'row', + gap: 6, + alignItems: 'stretch', + }, +}); + +const content = generateContent({ + count: 500, + backgroundColor: '#CBCFFC', + textColor: '#304077', +}); diff --git a/example/src/app/preset-fade.tsx b/example/src/app/preset-fade.tsx new file mode 100644 index 0000000..3c9ba09 --- /dev/null +++ b/example/src/app/preset-fade.tsx @@ -0,0 +1,12 @@ +import { PresetShowcaseScreen } from '@/components'; + +export default function Screen() { + return ( + + ); +} diff --git a/example/src/app/preset-none.tsx b/example/src/app/preset-none.tsx new file mode 100644 index 0000000..5e5d18d --- /dev/null +++ b/example/src/app/preset-none.tsx @@ -0,0 +1,13 @@ +import { PresetShowcaseScreen } from '@/components'; + +export default function Screen() { + return ( + + ); +} diff --git a/example/src/app/preset-parallax.tsx b/example/src/app/preset-parallax.tsx new file mode 100644 index 0000000..61a06ea --- /dev/null +++ b/example/src/app/preset-parallax.tsx @@ -0,0 +1,12 @@ +import { PresetShowcaseScreen } from '@/components'; + +export default function Screen() { + return ( + + ); +} diff --git a/example/src/app/preset-scale.tsx b/example/src/app/preset-scale.tsx new file mode 100644 index 0000000..be077a3 --- /dev/null +++ b/example/src/app/preset-scale.tsx @@ -0,0 +1,12 @@ +import { PresetShowcaseScreen } from '@/components'; + +export default function Screen() { + return ( + + ); +} diff --git a/example/src/components/PresetShowcaseScreen.tsx b/example/src/components/PresetShowcaseScreen.tsx new file mode 100644 index 0000000..71414c8 --- /dev/null +++ b/example/src/components/PresetShowcaseScreen.tsx @@ -0,0 +1,86 @@ +import { + Collapsible, + type CollapsiblePresetInput, +} from 'react-native-header-motion'; +import { Stack } from 'expo-router'; +import { StyleSheet, View } from 'react-native'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import { DynamicBox } from './DynamicBox'; +import { TitleWithSubtitle } from './TitleWithSubtitle'; +import { generateContent } from './generateContent'; + +interface PresetShowcaseScreenProps { + title: string; + subtitle: string; + preset: CollapsiblePresetInput; + /** + * Renders the title as a `Collapsible.Pinned` section. Disable to move the + * title into the collapsing section instead (used by the `none` preset, + * where the whole header slides away). + */ + withPinnedTitle?: boolean; + contentBackgroundColor?: string; +} + +export function PresetShowcaseScreen({ + title, + subtitle, + preset, + withPinnedTitle = true, + contentBackgroundColor = '#E3CBFC', +}: PresetShowcaseScreenProps) { + const insets = useSafeAreaInsets(); + + return ( + + header }} />} + > + {withPinnedTitle ? ( + + + + ) : null} + + {withPinnedTitle ? null : ( + + )} + + + + + + + + {generateContent({ + count: 500, + backgroundColor: contentBackgroundColor, + textColor: '#304077', + })} + + + ); +} + +const styles = StyleSheet.create({ + header: { + backgroundColor: '#304077', + borderBottomWidth: 1, + borderBottomColor: 'rgba(0,0,0,0.1)', + }, + dynamic: { + padding: 12, + }, + dynamicContent: { + gap: 12, + }, + boxRow: { + flexDirection: 'row', + gap: 6, + alignItems: 'stretch', + }, +}); diff --git a/example/src/components/index.ts b/example/src/components/index.ts index 26cec60..b1ff7c8 100644 --- a/example/src/components/index.ts +++ b/example/src/components/index.ts @@ -1,4 +1,5 @@ export { ContentCard } from './ContentCard'; +export { PresetShowcaseScreen } from './PresetShowcaseScreen'; export { DynamicBox } from './DynamicBox'; export { generateContent } from './generateContent'; export { ShowcaseCollapsibleHeader } from './ShowcaseCollapsibleHeader';