Skip to content

Commit 7eb121b

Browse files
authored
refactor!: fix performance regressions and refactor useScrollManager to be cleaner (#18)
* perf: fix performance regressions and make min height style addition opt-in * chore: adjust example to opt-in min height styling * fix: proper scheduleOnRN callback passing & typing improvements * chore: adjust JS Doc * test: fix tests BREAKING CHANGE: Scroll Manager's offsets related to sizing of headers are normal state values again to avoid using shared values for layout properties
1 parent 85cc385 commit 7eb121b

14 files changed

Lines changed: 281 additions & 153 deletions

README.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -438,6 +438,7 @@ Supports:
438438
- `scrollId?: string` for multi-scroll scenarios
439439
- `headerOffsetStrategy?: 'padding' | 'margin' | 'top' | 'translate' | 'none'`
440440
- `ensureScrollableContentMinHeight?: boolean`
441+
Experimental. Defaults to `false`.
441442

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

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

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

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

465468
Use:
466469

example/src/app/index.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -99,7 +99,7 @@ const SECTIONS: ShowcaseSection[] = [
9999
],
100100
},
101101
{
102-
title: 'Content & Refs',
102+
title: 'Layout edge cases',
103103
data: [
104104
{
105105
title: 'Short Content (min height)',

example/src/app/scroll-manager.tsx

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -28,14 +28,14 @@ export default function Screen() {
2828
<HeaderMotion.ScrollManager>
2929
{(
3030
scrollViewProps,
31-
{ originalHeaderHeight, minHeightContentContainerStyle }
31+
{ originalHeaderHeight, contentContainerMinHeight }
3232
) => (
3333
<Animated.ScrollView {...scrollViewProps}>
3434
<Animated.View
35-
style={[
36-
minHeightContentContainerStyle,
37-
{ paddingTop: originalHeaderHeight },
38-
]}
35+
style={{
36+
paddingTop: originalHeaderHeight,
37+
minHeight: contentContainerMinHeight,
38+
}}
3939
>
4040
{content}
4141
</Animated.View>

example/src/app/short-content-no-min-height.tsx

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,9 +24,7 @@ export default function Screen() {
2424
/>
2525
)}
2626
</HeaderMotion.Header>
27-
<HeaderMotion.ScrollView ensureScrollableContentMinHeight={false}>
28-
{content}
29-
</HeaderMotion.ScrollView>
27+
<HeaderMotion.ScrollView>{content}</HeaderMotion.ScrollView>
3028
</HeaderMotion>
3129
);
3230
}

example/src/app/short-content.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,9 @@ export default function Screen() {
2424
/>
2525
)}
2626
</HeaderMotion.Header>
27-
<HeaderMotion.ScrollView>{content}</HeaderMotion.ScrollView>
27+
<HeaderMotion.ScrollView ensureScrollableContentMinHeight>
28+
{content}
29+
</HeaderMotion.ScrollView>
2830
</HeaderMotion>
2931
);
3032
}

src/components/HeaderMotion.tsx

Lines changed: 10 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { useCallback, useRef, useEffect, useMemo } from 'react';
1+
import { useCallback, useRef, useEffect, useMemo, useState } from 'react';
22
import {
33
Extrapolation,
44
interpolate,
@@ -111,7 +111,7 @@ function HeaderMotionContextProvider<T extends string>({
111111
children,
112112
}: HeaderMotionProps<T>) {
113113
const dynamicMeasurement = useSharedValue<number | undefined>(undefined);
114-
const originalHeaderHeight = useSharedValue(0);
114+
const [originalHeaderHeight, setOriginalHeaderHeight] = useState(0);
115115
const progressThresholdValue = useSharedValue(
116116
typeof progressThreshold === 'number' ? progressThreshold : Infinity
117117
);
@@ -131,11 +131,11 @@ function HeaderMotionContextProvider<T extends string>({
131131
}
132132

133133
dynamicMeasurement.set(measured);
134-
progressThresholdValue.set(
134+
const nextThreshold =
135135
typeof progressThreshold === 'number'
136136
? progressThreshold
137-
: progressThreshold(measured)
138-
);
137+
: progressThreshold(measured);
138+
progressThresholdValue.set(nextThreshold);
139139
},
140140
[
141141
measureDynamicMode,
@@ -153,21 +153,17 @@ function HeaderMotionContextProvider<T extends string>({
153153
}
154154

155155
const measured = dynamicMeasurement.get();
156-
progressThresholdValue.set(
157-
measured === undefined ? Infinity : progressThreshold(measured)
158-
);
156+
const nextThreshold =
157+
measured === undefined ? Infinity : progressThreshold(measured);
158+
progressThresholdValue.set(nextThreshold);
159159
}, [progressThreshold, dynamicMeasurement, progressThresholdValue]);
160160

161161
const measureTotalHeight = useCallback<MeasureAnimatedHeaderAndSet>(
162162
(e) => {
163163
const measuredValue = e.nativeEvent.layout.height;
164-
if (originalHeaderHeight.get() === measuredValue) {
165-
return;
166-
}
167-
168-
originalHeaderHeight.set(measuredValue);
164+
setOriginalHeaderHeight(measuredValue);
169165
},
170-
[originalHeaderHeight]
166+
[setOriginalHeaderHeight]
171167
);
172168

173169
const scrollValues = useSharedValue<ScrollValues>({

src/components/ScrollManager.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,11 +24,14 @@ export interface HeaderMotionScrollManagerProps<
2424
}
2525

2626
/**
27-
* ScrollManager component that provides scroll tracking functionality for custom scroll implementations. Uses {@link useScrollManager} under the hood.
27+
* ScrollManager component that provides scroll tracking functionality for
28+
* custom scroll implementations. Uses {@link useScrollManager} under the hood.
2829
* Must be used within a HeaderMotion component.
2930
*
3031
* This is useful when you need to use a scroll component that isn't directly supported
3132
* (like a custom scroll view or third-party list components).
33+
* If you would rather compose the same functionality in a hook-based API,
34+
* use {@link useScrollManager} directly.
3235
*
3336
* @example
3437
* ```tsx

src/components/__tests__/createHeaderMotionScrollable.test.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ jest.mock('react', () => {
88
...ReactActual,
99
useMemo: (factory: () => unknown) => factory(),
1010
useCallback: <T extends (...args: any[]) => any>(callback: T) => callback,
11+
useRef: <T,>(value: T) => ({ current: value }),
12+
useLayoutEffect: (effect: () => void) => effect(),
1113
forwardRef: (render: any) => {
1214
const Forwarded = (props: any) => render(props, null);
1315
Forwarded.render = render;
@@ -103,7 +105,7 @@ function createScrollManagerResult() {
103105
},
104106
headerMotionContext: {
105107
originalHeaderHeight: 48,
106-
minHeightContentContainerStyle: { minHeight: 320 },
108+
contentContainerMinHeight: 320,
107109
},
108110
};
109111
}

src/components/createHeaderMotionScrollable.tsx

Lines changed: 32 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,15 @@
11
import {
22
forwardRef,
33
useCallback,
4+
useLayoutEffect,
45
useMemo,
6+
useRef,
57
type ComponentRef,
68
type ReactElement,
79
type ReactNode,
810
type Ref,
911
} from 'react';
10-
import type { ScrollViewProps } from 'react-native';
12+
import type { LayoutChangeEvent, ScrollViewProps } from 'react-native';
1113
import Animated, {
1214
type AnimatedProps,
1315
type AnimatedRef,
@@ -74,18 +76,18 @@ export function createHeaderMotionScrollable<
7476
)})`,
7577
} = options || {};
7678

77-
const AnimatedScrollable = (isComponentAnimated
78-
? ScrollableComponent
79-
: Animated.createAnimatedComponent(
80-
ScrollableComponent as never
81-
)) as unknown as ScrollableImplementationComponent;
79+
const AnimatedScrollable = (
80+
isComponentAnimated
81+
? ScrollableComponent
82+
: Animated.createAnimatedComponent(ScrollableComponent)
83+
) as ScrollableImplementationComponent;
8284

8385
function HeaderMotionScrollable(props: ScrollableRuntimeProps) {
8486
const {
8587
scrollId,
8688
animatedRef,
8789
headerOffsetStrategy,
88-
ensureScrollableContentMinHeight = true,
90+
ensureScrollableContentMinHeight = false,
8991
contentContainerStyle,
9092
refreshControl,
9193
refreshing,
@@ -114,31 +116,39 @@ export function createHeaderMotionScrollable<
114116
onMomentumScrollBegin,
115117
onMomentumScrollEnd,
116118
animatedRef,
119+
ensureScrollableContentMinHeight,
117120
}
118121
);
119122

120123
const {
121124
onScroll: managedOnScroll,
125+
onLayout: managedOnLayout,
122126
refreshControl: managedRefreshControl,
123127
ref,
124128
...scrollViewProps
125129
} = scrollableProps;
126-
const { originalHeaderHeight, minHeightContentContainerStyle } =
130+
const { originalHeaderHeight, contentContainerMinHeight } =
127131
headerMotionContext;
128132

133+
const userOnLayoutRef = useRef<UserOnLayout>(rest.onLayout as UserOnLayout);
134+
useLayoutEffect(() => {
135+
userOnLayoutRef.current = rest.onLayout as UserOnLayout;
136+
});
137+
129138
const managedContentContainerStyle = useMemo(
130139
() => [
131-
ensureScrollableContentMinHeight
132-
? minHeightContentContainerStyle
140+
ensureScrollableContentMinHeight &&
141+
contentContainerMinHeight !== undefined
142+
? { minHeight: contentContainerMinHeight }
133143
: undefined,
134144
resolveHeaderOffsetStyle(originalHeaderHeight, headerOffsetStrategy),
135145
contentContainerStyle,
136146
],
137147
[
138148
contentContainerStyle,
149+
contentContainerMinHeight,
139150
ensureScrollableContentMinHeight,
140151
headerOffsetStrategy,
141-
minHeightContentContainerStyle,
142152
originalHeaderHeight,
143153
]
144154
);
@@ -147,6 +157,14 @@ export function createHeaderMotionScrollable<
147157
refreshControl: managedRefreshControl,
148158
};
149159

160+
const handleLayout = useCallback(
161+
(e: LayoutChangeEvent) => {
162+
managedOnLayout?.(e);
163+
userOnLayoutRef.current?.(e);
164+
},
165+
[managedOnLayout]
166+
);
167+
150168
const contentContainerProps = useContentContainerProps({
151169
children: rest.children,
152170
mode: contentContainerMode,
@@ -160,6 +178,7 @@ export function createHeaderMotionScrollable<
160178
{...refreshControlProps}
161179
{...contentContainerProps}
162180
ref={ref}
181+
onLayout={handleLayout}
163182
onScroll={managedOnScroll}
164183
/>
165184
);
@@ -222,6 +241,8 @@ function getDisplayName(ScrollableComponent: {
222241
);
223242
}
224243

244+
type UserOnLayout = ScrollViewProps['onLayout'] | undefined;
245+
225246
// TODO: From here below Codex did some absolute TypeScript magic but it seems to work
226247
// Having limited time, I can't spend more on adjusting this to make it less convoluted
227248
// But what matters is that it seems that for the user the types work very well

src/context.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ interface HeaderMotionContextType {
1818
scrollValues: SharedValue<ScrollValues>;
1919
activeScrollId: SharedValue<string> | undefined;
2020
progressThreshold: SharedValue<number>;
21-
originalHeaderHeight: SharedValue<number>;
21+
originalHeaderHeight: number;
2222

2323
scrollToRef: React.RefObject<ScrollTo | null>;
2424
}

0 commit comments

Comments
 (0)