diff --git a/example/src/app/index.tsx b/example/src/app/index.tsx index 5447cce..0863621 100644 --- a/example/src/app/index.tsx +++ b/example/src/app/index.tsx @@ -154,6 +154,21 @@ const SECTIONS: ShowcaseSection[] = [ { title: 'Header asChild', href: '/as-child', icon: '🧩' }, ], }, + { + title: 'SubHeaders', + data: [ + { + title: 'SubHeader + FlatList', + href: '/subheader-flatlist', + icon: '🔎📋', + }, + { + title: 'SubHeader + Pager', + href: '/subheader-pager', + icon: '🔎📑', + }, + ], + }, { title: 'Header Pan', data: [ diff --git a/example/src/app/subheader-flatlist.tsx b/example/src/app/subheader-flatlist.tsx new file mode 100644 index 0000000..5a8ae79 --- /dev/null +++ b/example/src/app/subheader-flatlist.tsx @@ -0,0 +1,167 @@ +import { ContentCard, DynamicBox, TitleWithSubtitle } from '@/components'; +import HeaderMotion, { useMotionProgress } from 'react-native-header-motion'; +import { Stack } from 'expo-router'; +import { StyleSheet, TextInput, View } from 'react-native'; +import Animated, { + Extrapolation, + interpolate, + useAnimatedStyle, +} from 'react-native-reanimated'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; + +type ListRow = { + index: number; + label: string; +}; + +export default function Screen() { + return ( + + + {(value) => ( + ( + + + + ), + }} + /> + )} + + + + + + + + + `${item.index}`} + renderItem={({ item }) => ( + + )} + /> + + ); +} + +function CollapsibleHeader() { + const { progress, progressThreshold } = useMotionProgress(); + const insets = useSafeAreaInsets(); + + const containerStyle = useAnimatedStyle(() => { + const threshold = progressThreshold.get(); + const translateY = interpolate( + progress.get(), + [0, 1], + [0, -threshold], + Extrapolation.CLAMP + ); + return { transform: [{ translateY }] }; + }); + + const titleStyle = useAnimatedStyle(() => { + const threshold = progressThreshold.get(); + const translateY = interpolate( + progress.get(), + [0, 1], + [0, threshold], + Extrapolation.CLAMP + ); + return { transform: [{ translateY }] }; + }); + + const boxSectionStyle = useAnimatedStyle(() => { + const threshold = progressThreshold.get(); + const parallaxTranslateY = interpolate( + progress.get(), + [0, 1], + [0, threshold * 0.5], + Extrapolation.CLAMP + ); + const opacity = interpolate( + progress.get(), + [0, 1 * 0.6], + [1, 0], + Extrapolation.CLAMP + ); + const scale = interpolate( + progress.get(), + [0, 1], + [1, 0.8], + Extrapolation.CLAMP + ); + return { + opacity, + transform: [{ translateY: parallaxTranslateY }, { scale }], + }; + }); + + return ( + + + + + + + + + + + + + ); +} + +const styles = StyleSheet.create({ + headerWrapper: { + backgroundColor: '#304077', + borderBottomWidth: 1, + borderBottomColor: 'rgba(0,0,0,0.1)', + }, + dynamicContent: { + overflow: 'hidden', + }, + boxContainer: { + flexDirection: 'row', + gap: 6, + padding: 12, + alignItems: 'stretch', + overflow: 'hidden', + }, + searchBarContainer: { + backgroundColor: '#E3CBFC', + paddingHorizontal: 12, + paddingVertical: 10, + borderBottomWidth: 1, + borderBottomColor: '#d6b4f6', + }, + searchInput: { + height: 40, + borderRadius: 10, + backgroundColor: 'white', + borderWidth: 1, + borderColor: '#d9d9e3', + color: '#304077', + paddingHorizontal: 12, + }, +}); + +const content: ListRow[] = Array.from({ length: 200 }, (_, k) => ({ + index: k + 1, + label: 'FlatList Item', +})); diff --git a/example/src/app/subheader-pager.tsx b/example/src/app/subheader-pager.tsx new file mode 100644 index 0000000..58da021 --- /dev/null +++ b/example/src/app/subheader-pager.tsx @@ -0,0 +1,280 @@ +import { + ContentCard, + DynamicBox, + TabButton, + TitleWithSubtitle, +} from '@/components'; +import HeaderMotion, { + useActiveScrollId, + useMotionProgress, +} from 'react-native-header-motion'; +import { useRef } from 'react'; +import { StyleSheet, TextInput, View } from 'react-native'; +import PagerView, { + type PagerViewOnPageSelectedEvent, +} from 'react-native-pager-view'; +import Animated, { + Extrapolation, + interpolate, + useAnimatedStyle, +} from 'react-native-reanimated'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import { Stack } from 'expo-router/build/layouts/Stack'; + +const indexToKey = new Map([ + [0, 'A'], + [1, 'B'], +]); +const keyToIndex = new Map([ + ['A', 0], + ['B', 1], +]); + +export default function Screen() { + const [activeScrollId, setActiveScrollId] = useActiveScrollId('A'); + const pagerRef = useRef(null); + + const handleTabPress = (key: string) => { + pagerRef.current?.setPage(keyToIndex.get(key)!); + }; + + const onPageSelected = (e: PagerViewOnPageSelectedEvent) => { + setActiveScrollId(indexToKey.get(e.nativeEvent.position)!); + }; + + return ( + + + {(value) => ( + ( + + + + ), + }} + /> + )} + + + + + + + + + + `${item.index}`} + renderItem={({ item }) => ( + + )} + /> + + + + + + + + `${item.index}`} + renderItem={({ item }) => ( + + )} + /> + + + + ); +} + +function CollapsibleHeader({ + activeTab, + onTabChange, +}: { + activeTab: string; + onTabChange: (newTab: string) => void; +}) { + const { progress, progressThreshold } = useMotionProgress(); + const insets = useSafeAreaInsets(); + + const containerStyle = useAnimatedStyle(() => { + const threshold = progressThreshold.get(); + const translateY = interpolate( + progress.get(), + [0, 1], + [0, -threshold], + Extrapolation.CLAMP + ); + return { transform: [{ translateY }] }; + }); + + const titleStyle = useAnimatedStyle(() => { + const threshold = progressThreshold.get(); + const translateY = interpolate( + progress.get(), + [0, 1], + [0, threshold], + Extrapolation.CLAMP + ); + return { transform: [{ translateY }] }; + }); + + const boxSectionStyle = useAnimatedStyle(() => { + const threshold = progressThreshold.get(); + const parallaxTranslateY = interpolate( + progress.get(), + [0, 1], + [0, threshold * 0.5], + Extrapolation.CLAMP + ); + const opacity = interpolate( + progress.get(), + [0, 0.6], + [1, 0], + Extrapolation.CLAMP + ); + const scale = interpolate( + progress.get(), + [0, 1], + [1, 0.8], + Extrapolation.CLAMP + ); + return { + opacity, + transform: [{ translateY: parallaxTranslateY }, { scale }], + }; + }); + + return ( + + + + + + + + + + + + + + onTabChange('A')} + /> + onTabChange('B')} + /> + + + ); +} + +const styles = StyleSheet.create({ + pagerView: { + flex: 1, + }, + dynamicContent: { + overflow: 'hidden', + }, + headerWrapper: { + backgroundColor: '#304077', + borderBottomWidth: 1, + borderBottomColor: 'rgba(0,0,0,0.1)', + }, + boxContainer: { + flexDirection: 'row', + gap: 6, + padding: 12, + alignItems: 'stretch', + overflow: 'hidden', + }, + tabBar: { + flexDirection: 'row', + backgroundColor: '#FFF', + borderTopWidth: 1, + borderTopColor: '#EEE', + paddingBottom: 4, + }, + searchBarContainerA: { + paddingHorizontal: 12, + paddingVertical: 10, + backgroundColor: '#bbf7d0', + borderBottomWidth: 1, + borderBottomColor: '#86efac', + }, + searchBarContainerB: { + paddingHorizontal: 12, + paddingVertical: 10, + backgroundColor: '#bfdbfe', + borderBottomWidth: 1, + borderBottomColor: '#93c5fd', + }, + searchInputA: { + height: 40, + borderRadius: 10, + backgroundColor: '#fff', + borderWidth: 1, + borderColor: '#86efac', + paddingHorizontal: 12, + color: '#14532d', + }, + searchInputB: { + height: 40, + borderRadius: 10, + backgroundColor: '#fff', + borderWidth: 1, + borderColor: '#93c5fd', + paddingHorizontal: 12, + color: '#1d4ed8', + }, +}); + +const content = Array.from({ length: 120 }, (_, k) => ({ + index: k + 1, + label: 'FlatList Item', +})); diff --git a/src/components/HeaderMotion.tsx b/src/components/HeaderMotion.tsx index f14ffda..0cd03cc 100644 --- a/src/components/HeaderMotion.tsx +++ b/src/components/HeaderMotion.tsx @@ -126,6 +126,9 @@ function HeaderMotionContextProvider({ }: HeaderMotionProps) { const dynamicMeasurement = useSharedValue(undefined); const [originalHeaderHeight, setOriginalHeaderHeight] = useState(0); + const [subHeaderHeights, setSubHeaderHeights] = useState< + Record + >({}); const progressThresholdValue = useSharedValue( typeof progressThreshold === 'number' ? progressThreshold : Infinity ); @@ -180,6 +183,22 @@ function HeaderMotionContextProvider({ [setOriginalHeaderHeight] ); + const setSubHeaderHeight = useCallback( + (id: string, height: number, topInset: number = 0) => { + setSubHeaderHeights((prev) => { + const current = prev[id]; + const heightDelta = Math.abs((current?.height ?? 0) - height); + const insetDelta = Math.abs((current?.topInset ?? 0) - topInset); + if (heightDelta < 0.5 && insetDelta < 0.5) { + return prev; + } + + return { ...prev, [id]: { height, topInset } }; + }); + }, + [] + ); + const scrollValues = useSharedValue({ [DEFAULT_SCROLL_ID]: getInitialScrollValue(), }); @@ -233,6 +252,8 @@ function HeaderMotionContextProvider({ scrollValues, scrollToRef, activeScrollId: activeScrollId as SharedValue | undefined, + subHeaderHeights, + setSubHeaderHeight, }), [ originalHeaderHeight, @@ -243,6 +264,8 @@ function HeaderMotionContextProvider({ scrollValues, activeScrollId, progressThresholdValue, + subHeaderHeights, + setSubHeaderHeight, ] ); diff --git a/src/components/SubHeader.tsx b/src/components/SubHeader.tsx new file mode 100644 index 0000000..e0ab879 --- /dev/null +++ b/src/components/SubHeader.tsx @@ -0,0 +1,82 @@ +import { cloneElement, isValidElement, useEffect } from 'react'; +import type { ViewProps } from 'react-native'; +import Animated, { useAnimatedStyle } from 'react-native-reanimated'; +import { useHeaderMotionContextOrThrow } from '../context'; +import type { HeaderSubHeaderProps } from '../types'; +import { composeOnLayoutHandlers, resolveSlottableChild } from '../utils'; +import { DEFAULT_SCROLL_ID } from '../utils/defaults'; + +const baseStyle = { + position: 'absolute' as const, + left: 0, + right: 0, + zIndex: 20, +}; + +export function SubHeader(props: HeaderSubHeaderProps) { + const ctx = useHeaderMotionContextOrThrow( + 'HeaderMotion.SubHeader must be used within or .' + ); + const scrollId = props.scrollId ?? DEFAULT_SCROLL_ID; + const topInset = props.topInset ?? 0; + const staticHeight = props.height; + + const handleLayout: ViewProps['onLayout'] = (e) => { + if (staticHeight !== undefined) { + return; + } + ctx.setSubHeaderHeight(scrollId, e.nativeEvent.layout.height, topInset); + }; + + useEffect(() => { + if (staticHeight === undefined) { + return; + } + + ctx.setSubHeaderHeight(scrollId, staticHeight, topInset); + }, [ctx, scrollId, staticHeight, topInset]); + + const stickyStyle = useAnimatedStyle(() => { + const collapsedHeaderHeight = Math.max( + 0, + ctx.originalHeaderHeight - ctx.progressThreshold.get() + ); + const currentHeaderHeight = + ctx.originalHeaderHeight - + ctx.progress.get() * (ctx.originalHeaderHeight - collapsedHeaderHeight); + + return { + top: currentHeaderHeight + topInset, + }; + }, [ctx.originalHeaderHeight, topInset]); + + if (props.asChild) { + const child = resolveSlottableChild( + 'HeaderMotion.SubHeader', + props.children + ); + if (!isValidElement(child)) { + return null; + } + const childAsAny = child as any; + + return cloneElement(childAsAny, { + onLayout: composeOnLayoutHandlers( + childAsAny.props.onLayout, + handleLayout + ), + style: [childAsAny.props.style, baseStyle, stickyStyle], + }); + } + + const { style, onLayout, ...rest } = props; + const userOnLayout = onLayout as ViewProps['onLayout'] | undefined; + + return ( + + ); +} diff --git a/src/components/__tests__/Header.test.tsx b/src/components/__tests__/Header.test.tsx index e86444f..e209d5d 100644 --- a/src/components/__tests__/Header.test.tsx +++ b/src/components/__tests__/Header.test.tsx @@ -80,6 +80,8 @@ const bridgeValue = { activeScrollId: undefined, scrollToRef: { current: jest.fn() }, originalHeaderHeight: 0, + subHeaderHeights: {}, + setSubHeaderHeight: jest.fn(), }; const layoutEvent = { diff --git a/src/components/createHeaderMotionScrollable.tsx b/src/components/createHeaderMotionScrollable.tsx index 355ce5c..146eb53 100644 --- a/src/components/createHeaderMotionScrollable.tsx +++ b/src/components/createHeaderMotionScrollable.tsx @@ -162,7 +162,7 @@ export function createHeaderMotionScrollable< ref, ...scrollViewProps } = scrollableProps; - const { originalHeaderHeight, contentContainerMinHeight } = + const { originalHeaderHeight, contentContainerMinHeight, subHeaderHeight } = headerMotionContext; const userOnLayoutRef = useRef(rest.onLayout as UserOnLayout); @@ -176,7 +176,11 @@ export function createHeaderMotionScrollable< contentContainerMinHeight !== undefined ? { minHeight: contentContainerMinHeight } : undefined, - resolveHeaderOffsetStyle(originalHeaderHeight, headerOffsetStrategy), + resolveHeaderAndSubHeaderOffsetStyle( + originalHeaderHeight, + headerOffsetStrategy, + subHeaderHeight ?? 0 + ), contentContainerStyle, ], [ @@ -185,6 +189,7 @@ export function createHeaderMotionScrollable< ensureScrollableContentMinHeight, headerOffsetStrategy, originalHeaderHeight, + subHeaderHeight, ] ); @@ -230,6 +235,42 @@ export function createHeaderMotionScrollable< return TypedHeaderMotionScrollable; } +function resolveHeaderAndSubHeaderOffsetStyle( + originalHeaderHeight: number, + headerOffsetStrategy: HeaderMotionOffsetProps['headerOffsetStrategy'], + subHeaderHeight: number +) { + const totalOffset = originalHeaderHeight + subHeaderHeight; + const base = resolveHeaderOffsetStyle( + originalHeaderHeight, + headerOffsetStrategy + ); + + if (!base) { + return undefined; + } + + if ('paddingTop' in base) { + return { paddingTop: totalOffset }; + } + + if ('marginTop' in base) { + return { marginTop: totalOffset }; + } + + if ('top' in base) { + return { + top: totalOffset, + paddingBottom: totalOffset, + }; + } + + return { + transform: [{ translateY: totalOffset }], + paddingBottom: totalOffset, + }; +} + function useContentContainerProps({ children: rawChildren, mode, diff --git a/src/components/index.ts b/src/components/index.ts index dc87f24..88adfac 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -6,3 +6,4 @@ export * from './HeaderMotion'; export * from './ScrollManager'; export * from './ScrollView'; export * from './createHeaderMotionScrollable'; +export * from './SubHeader'; diff --git a/src/hooks/useScrollManager.ts b/src/hooks/useScrollManager.ts index cbc618c..06abbf0 100644 --- a/src/hooks/useScrollManager.ts +++ b/src/hooks/useScrollManager.ts @@ -374,7 +374,7 @@ export function useScrollManager( scrollId?: string, options?: UseScrollManagerOptions ): ScrollManagerConfig { - const { originalHeaderHeight } = useScrollManagerContext(); + const { originalHeaderHeight, subHeaderHeights } = useScrollManagerContext(); const id = scrollId ?? DEFAULT_SCROLL_ID; const ensureScrollableContentMinHeight = @@ -425,6 +425,9 @@ export function useScrollManager( const headerMotionContext = { originalHeaderHeight, contentContainerMinHeight, + subHeaderHeight: + (subHeaderHeights[id]?.height ?? 0) + + (subHeaderHeights[id]?.topInset ?? 0), }; return { scrollableProps, headerMotionContext }; diff --git a/src/index.ts b/src/index.ts index b0075fa..524ae05 100644 --- a/src/index.ts +++ b/src/index.ts @@ -7,6 +7,7 @@ import { NavigationBridge, ScrollManager, ScrollView, + SubHeader, type CreateHeaderMotionScrollableOptions, type HeaderProps, type HeaderMotionBridgeProps, @@ -17,7 +18,7 @@ import { type HeaderMotionScrollableOwnProps, type HeaderMotionScrollViewProps, } from './components'; -import type { HeaderDynamicProps } from './types'; +import type { HeaderDynamicProps, HeaderSubHeaderProps } from './types'; type HeaderMotionCompound = typeof HeaderMotionContextProvider & { /** @@ -60,6 +61,13 @@ type HeaderMotionCompound = typeof HeaderMotionContextProvider & { * tracking and header offsetting. */ FlatList: typeof FlatList; + /** + * Tab/page-local sticky sub-header that pins below the collapsing header. + * + * It measures its own height and HeaderMotion scrollables automatically add + * top spacing for the matching `scrollId`. + */ + SubHeader: typeof SubHeader; }; /** @@ -100,6 +108,7 @@ const HeaderMotion: HeaderMotionCompound = Object.assign( ScrollManager, ScrollView, FlatList, + SubHeader, } ); @@ -112,6 +121,7 @@ export { Bridge, Header, NavigationBridge }; export type { CreateHeaderMotionScrollableOptions, HeaderDynamicProps, + HeaderSubHeaderProps, HeaderMotionFlatListProps, HeaderMotionBridgeProps, HeaderMotionNavigationBridgeProps, diff --git a/src/types.ts b/src/types.ts index 6d1a682..d4df368 100644 --- a/src/types.ts +++ b/src/types.ts @@ -97,6 +97,29 @@ export type HeaderDefaultProps = AnimatedProps & { export type HeaderDynamicProps = HeaderDefaultProps | HeaderAsChildProps; +export type HeaderSubHeaderProps = (HeaderDefaultProps | HeaderAsChildProps) & { + /** + * Scrollable identifier this sub-header belongs to. + * + * In single-scroll screens, you can omit it. + * In tab/pager setups, set this to the same value as the page's `scrollId`. + */ + scrollId?: string; + /** + * Extra top inset added below the visible/collapsed header. + * + * @default 0 + */ + topInset?: number; + /** + * Optional static height hint for eager layout reservation. + * + * Provide this when sub-header height is known to avoid first-render padding + * shifts before dynamic measurement completes. + */ + height?: number; +}; + export interface HeaderMotionBridgeValue extends MotionProgress { measureTotalHeight: MeasureAnimatedHeaderAndSet; measureDynamic: MeasureAnimatedHeaderAndSet; @@ -105,11 +128,14 @@ export interface HeaderMotionBridgeValue extends MotionProgress { activeScrollId: SharedValue | undefined; scrollToRef: React.RefObject; originalHeaderHeight: number; + subHeaderHeights: Record; + setSubHeaderHeight: (id: string, height: number, topInset?: number) => void; } export interface ScrollManagerHeaderMotionContext { originalHeaderHeight: number; contentContainerMinHeight?: number; + subHeaderHeight?: number; } export interface ScrollManagerConfig {