Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -438,6 +438,7 @@ Supports:
- `scrollId?: string` for multi-scroll scenarios
- `headerOffsetStrategy?: 'padding' | 'margin' | 'top' | 'translate' | 'none'`
- `ensureScrollableContentMinHeight?: boolean`
Experimental. Defaults to `false`.

`padding` is the default and recommended option. `top` and `translate` also add bottom compensation internally so the end of the content remains reachable.

Expand All @@ -450,6 +451,7 @@ Supports:
- `scrollId?: string` for multi-scroll scenarios
- `headerOffsetStrategy?: 'padding' | 'margin' | 'top' | 'translate' | 'none'`
- `ensureScrollableContentMinHeight?: boolean`
Experimental. Defaults to `false`.

#### `createHeaderMotionScrollable(Component, options?)`

Expand All @@ -461,6 +463,7 @@ Returned components support:
- `scrollId?: string`
- `headerOffsetStrategy?: 'padding' | 'margin' | 'top' | 'translate' | 'none'`
- `ensureScrollableContentMinHeight?: boolean`
Experimental. Defaults to `false`.

Use:

Expand Down
2 changes: 1 addition & 1 deletion example/src/app/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,7 @@ const SECTIONS: ShowcaseSection[] = [
],
},
{
title: 'Content & Refs',
title: 'Layout edge cases',
data: [
{
title: 'Short Content (min height)',
Expand Down
10 changes: 5 additions & 5 deletions example/src/app/scroll-manager.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,14 +28,14 @@ export default function Screen() {
<HeaderMotion.ScrollManager>
{(
scrollViewProps,
{ originalHeaderHeight, minHeightContentContainerStyle }
{ originalHeaderHeight, contentContainerMinHeight }
) => (
<Animated.ScrollView {...scrollViewProps}>
<Animated.View
style={[
minHeightContentContainerStyle,
{ paddingTop: originalHeaderHeight },
]}
style={{
paddingTop: originalHeaderHeight,
minHeight: contentContainerMinHeight,
}}
>
{content}
</Animated.View>
Expand Down
4 changes: 1 addition & 3 deletions example/src/app/short-content-no-min-height.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,9 +24,7 @@ export default function Screen() {
/>
)}
</HeaderMotion.Header>
<HeaderMotion.ScrollView ensureScrollableContentMinHeight={false}>
{content}
</HeaderMotion.ScrollView>
<HeaderMotion.ScrollView>{content}</HeaderMotion.ScrollView>
</HeaderMotion>
);
}
Expand Down
4 changes: 3 additions & 1 deletion example/src/app/short-content.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,9 @@ export default function Screen() {
/>
)}
</HeaderMotion.Header>
<HeaderMotion.ScrollView>{content}</HeaderMotion.ScrollView>
<HeaderMotion.ScrollView ensureScrollableContentMinHeight>
{content}
</HeaderMotion.ScrollView>
</HeaderMotion>
);
}
Expand Down
24 changes: 10 additions & 14 deletions src/components/HeaderMotion.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useCallback, useRef, useEffect, useMemo } from 'react';
import { useCallback, useRef, useEffect, useMemo, useState } from 'react';
import {
Extrapolation,
interpolate,
Expand Down Expand Up @@ -111,7 +111,7 @@ function HeaderMotionContextProvider<T extends string>({
children,
}: HeaderMotionProps<T>) {
const dynamicMeasurement = useSharedValue<number | undefined>(undefined);
const originalHeaderHeight = useSharedValue(0);
const [originalHeaderHeight, setOriginalHeaderHeight] = useState(0);
const progressThresholdValue = useSharedValue(
typeof progressThreshold === 'number' ? progressThreshold : Infinity
);
Expand All @@ -131,11 +131,11 @@ function HeaderMotionContextProvider<T extends string>({
}

dynamicMeasurement.set(measured);
progressThresholdValue.set(
const nextThreshold =
typeof progressThreshold === 'number'
? progressThreshold
: progressThreshold(measured)
);
: progressThreshold(measured);
progressThresholdValue.set(nextThreshold);
},
[
measureDynamicMode,
Expand All @@ -153,21 +153,17 @@ function HeaderMotionContextProvider<T extends string>({
}

const measured = dynamicMeasurement.get();
progressThresholdValue.set(
measured === undefined ? Infinity : progressThreshold(measured)
);
const nextThreshold =
measured === undefined ? Infinity : progressThreshold(measured);
progressThresholdValue.set(nextThreshold);
}, [progressThreshold, dynamicMeasurement, progressThresholdValue]);

const measureTotalHeight = useCallback<MeasureAnimatedHeaderAndSet>(
(e) => {
const measuredValue = e.nativeEvent.layout.height;
if (originalHeaderHeight.get() === measuredValue) {
return;
}

originalHeaderHeight.set(measuredValue);
setOriginalHeaderHeight(measuredValue);
},
[originalHeaderHeight]
[setOriginalHeaderHeight]
);

const scrollValues = useSharedValue<ScrollValues>({
Expand Down
5 changes: 4 additions & 1 deletion src/components/ScrollManager.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,11 +24,14 @@ export interface HeaderMotionScrollManagerProps<
}

/**
* ScrollManager component that provides scroll tracking functionality for custom scroll implementations. Uses {@link useScrollManager} under the hood.
* ScrollManager component that provides scroll tracking functionality for
* custom scroll implementations. Uses {@link useScrollManager} under the hood.
* Must be used within a HeaderMotion component.
*
* This is useful when you need to use a scroll component that isn't directly supported
* (like a custom scroll view or third-party list components).
* If you would rather compose the same functionality in a hook-based API,
* use {@link useScrollManager} directly.
*
* @example
* ```tsx
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ jest.mock('react', () => {
...ReactActual,
useMemo: (factory: () => unknown) => factory(),
useCallback: <T extends (...args: any[]) => any>(callback: T) => callback,
useRef: <T,>(value: T) => ({ current: value }),
useLayoutEffect: (effect: () => void) => effect(),
forwardRef: (render: any) => {
const Forwarded = (props: any) => render(props, null);
Forwarded.render = render;
Expand Down Expand Up @@ -103,7 +105,7 @@ function createScrollManagerResult() {
},
headerMotionContext: {
originalHeaderHeight: 48,
minHeightContentContainerStyle: { minHeight: 320 },
contentContainerMinHeight: 320,
},
};
}
Expand Down
43 changes: 32 additions & 11 deletions src/components/createHeaderMotionScrollable.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
import {
forwardRef,
useCallback,
useLayoutEffect,
useMemo,
useRef,
type ComponentRef,
type ReactElement,
type ReactNode,
type Ref,
} from 'react';
import type { ScrollViewProps } from 'react-native';
import type { LayoutChangeEvent, ScrollViewProps } from 'react-native';
import Animated, {
type AnimatedProps,
type AnimatedRef,
Expand Down Expand Up @@ -74,18 +76,18 @@ export function createHeaderMotionScrollable<
)})`,
} = options || {};

const AnimatedScrollable = (isComponentAnimated
? ScrollableComponent
: Animated.createAnimatedComponent(
ScrollableComponent as never
)) as unknown as ScrollableImplementationComponent;
const AnimatedScrollable = (
isComponentAnimated
? ScrollableComponent
: Animated.createAnimatedComponent(ScrollableComponent)
) as ScrollableImplementationComponent;

function HeaderMotionScrollable(props: ScrollableRuntimeProps) {
const {
scrollId,
animatedRef,
headerOffsetStrategy,
ensureScrollableContentMinHeight = true,
ensureScrollableContentMinHeight = false,
contentContainerStyle,
refreshControl,
refreshing,
Expand Down Expand Up @@ -114,31 +116,39 @@ export function createHeaderMotionScrollable<
onMomentumScrollBegin,
onMomentumScrollEnd,
animatedRef,
ensureScrollableContentMinHeight,
}
);

const {
onScroll: managedOnScroll,
onLayout: managedOnLayout,
refreshControl: managedRefreshControl,
ref,
...scrollViewProps
} = scrollableProps;
const { originalHeaderHeight, minHeightContentContainerStyle } =
const { originalHeaderHeight, contentContainerMinHeight } =
headerMotionContext;

const userOnLayoutRef = useRef<UserOnLayout>(rest.onLayout as UserOnLayout);
useLayoutEffect(() => {
userOnLayoutRef.current = rest.onLayout as UserOnLayout;
});

const managedContentContainerStyle = useMemo(
() => [
ensureScrollableContentMinHeight
? minHeightContentContainerStyle
ensureScrollableContentMinHeight &&
contentContainerMinHeight !== undefined
? { minHeight: contentContainerMinHeight }
: undefined,
resolveHeaderOffsetStyle(originalHeaderHeight, headerOffsetStrategy),
contentContainerStyle,
],
[
contentContainerStyle,
contentContainerMinHeight,
ensureScrollableContentMinHeight,
headerOffsetStrategy,
minHeightContentContainerStyle,
originalHeaderHeight,
]
);
Expand All @@ -147,6 +157,14 @@ export function createHeaderMotionScrollable<
refreshControl: managedRefreshControl,
};

const handleLayout = useCallback(
(e: LayoutChangeEvent) => {
managedOnLayout?.(e);
userOnLayoutRef.current?.(e);
},
[managedOnLayout]
);

const contentContainerProps = useContentContainerProps({
children: rest.children,
mode: contentContainerMode,
Expand All @@ -160,6 +178,7 @@ export function createHeaderMotionScrollable<
{...refreshControlProps}
{...contentContainerProps}
ref={ref}
onLayout={handleLayout}
onScroll={managedOnScroll}
/>
);
Expand Down Expand Up @@ -222,6 +241,8 @@ function getDisplayName(ScrollableComponent: {
);
}

type UserOnLayout = ScrollViewProps['onLayout'] | undefined;

// TODO: From here below Codex did some absolute TypeScript magic but it seems to work
// Having limited time, I can't spend more on adjusting this to make it less convoluted
// But what matters is that it seems that for the user the types work very well
Expand Down
2 changes: 1 addition & 1 deletion src/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ interface HeaderMotionContextType {
scrollValues: SharedValue<ScrollValues>;
activeScrollId: SharedValue<string> | undefined;
progressThreshold: SharedValue<number>;
originalHeaderHeight: SharedValue<number>;
originalHeaderHeight: number;

scrollToRef: React.RefObject<ScrollTo | null>;
}
Expand Down
2 changes: 1 addition & 1 deletion src/hooks/useMotionProgress.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ import type { MotionProgress } from '../types';
* const translateY = interpolate(
* progress.value,
* [0, 1],
* [0, -progressThreshold],
* [0, -progressThreshold.get()],
* Extrapolation.CLAMP,
* )
* return { transform: [{ translateY }] }
Expand Down
Loading
Loading