I've created a view which is essentially a horizontal ScrollView that can only be swiped one item at a time. Each item (= category) has a header showing the category's name. To somehow reveal to the user what the next page/previous page has to offer, I decided to show a tiny bit of the headers flowing into the screen.
| Without header parallax | With header parallax |
|---|---|
| Without header parallax | With header parallax |
|---|---|
![]() |
![]() |
While you can't _really_ see the difference in a compressed, 320 pixel wide, 25 FPS GIF, it is extremely noticeable in the app, since you can swipe/scroll normally without the parallax, and once you enable the header parallax it looks like a powerpoint presentation. I'd say without parallax it's around 60 FPS, with parallax about 5 FPS.
I've tried multiple approaches to solve those performance issues, here's what I tried:
useDerivedValues into a single useAnimatedStyle) so that there's a few less worklets executing at once - didn't make a difference at all.removeClippedSubviews property on the container to remove all views that are offscreen, so not rendering them if they're overflowing. That requires overflow: 'hidden' to be set, which I did, so now only everything that's on screen should be rendered. This doesn't affect worklets though, since still every single useDerivedValue and useAnimatedStyle worklet is executing per frame on drag. For 30 Stories, that's 1 useAnimatedStyle and 1 useAnimatedGestureHandler for the "list" container, and 30 useDerivedValues and 30 useAnimatedStyles for the Headers. Can we ignore them if RCTView removes that view due to clipping?<PanGestureHandler> and translate those views using the translateX (plus some offsetX)translateX value and it's index in the SwiperuseAnimatedStyle which interpolates the current Swiper's translateX to parallax-like translate the header towards the screen corners so they get revealed.I expect it to run smoothly
It runs terribly stuttery.
Header.tsx
function StoryHeader({
story,
style: _style,
indexInSwiper,
swiperTranslateX,
isSwipeMode,
onPress,
...passThroughProps
}: StoryHeaderProps): React.ReactElement {
const [width, setWidth] = useState(0);
const onLayout = useCallback(
({
nativeEvent: {
layout: { width: newWidth },
},
}: LayoutChangeEvent) => {
setWidth(newWidth);
},
[setWidth],
);
const thisTranslateX = useMemo(() => -(SCREEN_WIDTH * indexInSwiper), [indexInSwiper]);
const offsetX = useDerivedValue(() => {
if (isSwipeMode) {
return withTiming(width / 2, {
duration: 200,
easing: Easing.back(1),
});
} else {
return withTiming(width / 2 - HEADER_PREVIEW_OVERFLOW, {
duration: 300,
easing: Easing.elastic(1),
});
}
}, [isSwipeMode, width]);
const animatedStyle = useAnimatedStyle(() => {
const translateX = interpolate(
swiperTranslateX.value,
[
thisTranslateX - SCREEN_WIDTH - SCREEN_WIDTH,
thisTranslateX - SCREEN_WIDTH,
thisTranslateX, //
thisTranslateX + SCREEN_WIDTH,
thisTranslateX + SCREEN_WIDTH + SCREEN_WIDTH,
],
[
SCREEN_WIDTH / 2,
SCREEN_WIDTH / 2 - offsetX.value, // Swiper is at Next slide
0, // Swiper is at current slide
-(SCREEN_WIDTH / 2) + offsetX.value, // Swiper is at Previous slide
-(SCREEN_WIDTH / 2),
],
Extrapolate.CLAMP,
);
const isCurrentHeader = between(translateX, -10, 10);
return {
// opacity: withTiming(isSwipeMode ? (isCurrentHeader ? 1 : 0) : 1, { duration: 300 }),
opacity: 1,
transform: [{ translateX: translateX }, { scale: withTiming(isSwipeMode ? (isCurrentHeader ? 1.2 : 1) : 1, { duration: 150 }) }],
};
}, [isSwipeMode, offsetX, swiperTranslateX, thisTranslateX]);
const style = useMemo(() => [_style, { left: SCREEN_WIDTH * indexInSwiper + (SCREEN_WIDTH / 2 - width / 2) }], [_style, indexInSwiper, width]);
return (
// TODO: DEBUG IF renderToHardwareTextureAndroid={true} shouldRasterizeIOS={true} ARE A GOOD IDEA
<Reanimated.View
onLayout={onLayout}
style={[style, animatedStyle]}
renderToHardwareTextureAndroid={true}
shouldRasterizeIOS={true}
{...passThroughProps}>
<StoryHeaderContent story={story} onPress={onPress} />
</Reanimated.View>
);
}
I'm really not sure how I can make this more efficient or if I'm doing something wrong, so any tips would be greatly appreciated!
Here's everything I've noted:
useAnimatedStyle hook, that gets executed a lot (30 times for 30 different headers per on drag event), using withTiming (or withSpring) is a bad idea. Instead, extracting that into a useSharedValue and _imperatively_ animating that when the isSwipeMode or isCurrentHeader props changes works a lot better. I am guessing it just takes a long time to set up the withTiming (or withSpring) animation, even though the inputs haven't changed an it already is at the desired position.useDerivedValues doesn't seem to really do anything, it feels a tiny bit smoother I guessremoveClippedSubviews. (overflow hidden)useAnimatedStyle hooks.Also, are there fundamental differences between useAnimatedStyle and useDerivedValue in terms of dealing with results that are shallow-equal? E.g.: When a useDerivedValue executes and it's result stays the same, other useDerivedValues and useAnimatedStyles do not re-run since the input is the same (afaik).
Is this also the case for useAnimatedStyle? Does it forcefully re-trigger a native re-render/re-draw even if the output' style (return value of useAnimatedStyle) is still the same?
Actually, I've just tried to disable random parts of the animation and noticed that the scale transform:
{ scale: withTiming(isSwipeMode ? (isCurrentHeader ? 1.2 : 1) : 1, { duration: 150 })
in the useAnimatedStyle hook is causing a lot of performance issues, even though the inputs haven't changed. I'm trying to run scale animations imeratively using useEffect hooks now, that works _a bit_ better. It's still under 30 FPS.
Hello!
Congratulations! Your issue passed the validator! Thank you!
It seems to be related to the performance issues I had and we talked about @karol-bisztyga
Basically, it subscribes for too many mappers (animated style callback) and for some reason it had hard time executing all of them during the single frame update
Hello!
First of all thanks to @mrousavy for posting such a well-prepared issue! You must've taken your time to make it look like this, I really appreciate that 馃檶 !
I got a little bit confused about whether your components are inside ScrollView or PanGestureHandler that tries to imitate a ScrollView(you started the post by mentioning the first one but in repro, there is the other one). Could you clarify? Still, both options should work smoothly but just wanted to understand it better. The code snippet you posted(Header.tsx - I understand it's rendered multiple times in some kind of loop(Array.map in a parent component, something like that?), right?). Maybe it's just me and I don't get it right, then I'm sorry.
I'll be looking into it, I wouldn't be surprised if it turned out that there are several distinct problems which together cause those big lags.
Also, are there fundamental differences between
useAnimatedStyleanduseDerivedValuein terms of dealing with results that are shallow-equal? E.g.: When auseDerivedValueexecutes and it's result stays the same, otheruseDerivedValues anduseAnimatedStyles do not re-run since the input is the same (afaik).
Is this also the case foruseAnimatedStyle? Does it forcefully re-trigger a native re-render/re-draw even if the output' style (return value ofuseAnimatedStyle) is still the same?
I think it should work that way, however, it doesn't(I've reproduced such a case already) and that could be the first problem here.
@karol-bisztyga Thanks for responding.
<PanGestureHandler
onGestureEvent={onGestureEvent}
maxPointers={1}
activeOffsetX={GESTURE_HANDLER_RANGE}
failOffsetY={GESTURE_HANDLER_FAIL_RANGE}>
<Reanimated.View style={styles.sliderContainer}> {/* <-- That's just width: SCREEN_WIDTH, height: SCREEN_HEIGHT */}
<Reanimated.View style={[styles.slider, sliderStyle, sliderAnimatedStyle]}> {/* <-- That's flexDirection: 'row', width: SCREEN_WIDTH * headers.length, transform: [{ translateX: translateX.value }] */}
{swipers}
{headers} {/* Those are the individual headers, memoized so I can return null ("unmount them") for those that aren't in the viewport, story headers have a custom style of position: absolute and their left: inset. Also, they interpolate their own translateX on the translateX value from the PanGestureHandler parent, see the code in my original issue/post */}
</Reanimated.View>
</Reanimated.View>
</PanGestureHandler>
const headers = useMemo(
() =>
stories.map((story, i) => {
if (Math.abs(index - i) > 1) {
return null; // it's out of viewport bounds
} else {
return (
<StoryHeader
key={`${story.id}.header`}
style={styles.storyHeader}
story={story}
indexInSwiper={i}
swiperTranslateX={translateX}
onPress={() => onHeaderPressed(i)}
/>
);
}
}),
[index, isExperimental, isSwipeMode, onHeaderPressed, stories, translateX],
);
If you need any more details, please let me know! Thanks for looking into this 馃
Ok so there are two major problems here:
const sv = useSharedValue(50)
const uas = useAnimatedStyle(() => {
return { width: sv.value }
})
for (let i = 0; i < 1000; ++i) {
sv.value = 100;
}
In that case, the mapper inside of the uas is going to be triggered 1000 times(plus initial updater run but I mean 1000 extra times that shouldn't happen).
As for this problem, I'm going to push a solution soon.
In the meantime will also hunt for more issues here(maybe there are some).
Extrapolate.clamp for my translateX interpolation, and if the view is out of bounds, the header translateX value will get clamped, so it doesn't re-run the animated style hook (since the inputs are the same).I've done some testing and my fix in #1502 seems to reduce 90%+ of the lag. BUT there has to be a proper approach applied. The main idea is to have that one useDerivedValue per item in the render loop(Array.map) which would be some kind of 'entry point' for all the other mappers for that item. Then every other worklet there would depend on that value. Something like this(pseudo-code):
items.map((item) => {
const udv = useDerivedValue(() => {
// ... calculate something
return ... // e.g. null/undefined when the item wouldn't be visible, original number when it would
})
const st1 = useAnimatedStyle(() => {
return { transform: [{ translateX: udv.value === null ? -100 : udv.value }] }
})
const st2 = useAnimatedStyle(() => {
return { scale: withTiming(udv.value === null ? 1 : 0.7) }
})
return <...>
})
I wouldn't treat it as some kind of workaround but rather a good practice. This happens only when there is a lot of mappers spawned which recalculate a lot. Maybe there's some way we could improve the process more on the reanimated 2 side but keep in mind that the application's logic matters a lot too.
Below I'm pasting the code that I used for testing/reproducing. It's a standalone component so just feel free to paste it and launch it.
I've run it on iPhone 11 and without the fix from #1502 it would drop to extremely low FPS values(both JS and UI, often <10, sometimes even 0, lol) but with the fix applied it stays at 60FPS(both) and drops to 50+ when there are more components involved(meaning when you swipe so the new ones appear)(also, if you remove console.log's that little drop only appears on UI, on JS it remains at 60). I realize that's still a frame drop there and I will look deeper, but just wanted to point out a direction for solving the problem(also the calculations in this particular example could be not very precise as I didn't pay that much attention to this).
the code
import Animated, {
useSharedValue,
withTiming,
useAnimatedStyle,
useDerivedValue,
useAnimatedGestureHandler,
} from 'react-native-reanimated';
import { View, Dimensions, Text } from 'react-native';
import React from 'react';
import { PanGestureHandler } from 'react-native-gesture-handler';
const WIDTH = Dimensions.get('window').width;
export default function App() {
const items = Array(50)
.fill()
.map((_, i) => i);
const itemWidth = 150;
const itemMargin = 10;
const posX = useSharedValue(0);
const handler = useAnimatedGestureHandler({
onActive: (e, ctx) => {
let newPosX = posX.value;
newPosX += e.velocityX;
newPosX = Math.min(newPosX, 0);
const minval = (-(itemWidth + itemMargin) * items.length + WIDTH) * 50;
newPosX = Math.max(newPosX, minval);
console.log('here new pos', newPosX);
posX.value = newPosX;
},
});
return (
<View>
<PanGestureHandler onGestureEvent={handler}>
<Animated.View
style={{
alignSelf: 'stretch',
height: 300,
backgroundColor: 'purple',
}}>
{items.map((index) => {
// this determines whether the styles should calculate
const udv = useDerivedValue(() => {
console.log('here mapper #1', index);
const dist = Math.abs((itemWidth + 10) * index + posX.value / 50);
return (dist > 500) ? null : posX.value ;
})
const style = useAnimatedStyle(() => {
console.log('here mapper #2', index, udv.value);
const x =
udv.value === null
? -itemWidth
: (itemWidth + itemMargin) * index + udv.value / 50;
return {
transform: [
{
translateX: x,
},
],
};
});
const style2 = useAnimatedStyle(() => {
const dist = (udv.value === 2000) ? null : (itemWidth + 10) * index + udv.value / 50;
console.log('here mapper #3', index, dist);
return {
transform: [{ scale: withTiming(dist === null ? 1 : 0.7) }],
};
});
return (
<Animated.View
style={[
{
position: 'absolute',
width: 100,
height: 250,
backgroundColor: 'orange',
marginTop: 25,
},
style,
]}>
<Text style={{ fontSize: 20 }}>{index}</Text>
<Animated.View
style={[
{
width: itemWidth / 2,
height: itemWidth / 2,
backgroundColor: 'green',
},
style2,
]}
/>
</Animated.View>
);
})}
</Animated.View>
</PanGestureHandler>
</View>
);
}
@mrousavy what do you think? I'm probably going to try to also refactor your code a little bit in such a manner.
That's awesome! That's essentially what I was referring to with "clamping" the outputs, so they stay the same and won't re-trigger dependant worklets (useAnimatedStyle). I'll test that out in my app sometime this week to see how big of an impact it's going to have, but it looks promising. Thanks again for looking into this.
Great! Looking forward to hearing about the results, thx! 馃檶
Most helpful comment
Issue validator - update # 3
Hello!
Congratulations! Your issue passed the validator! Thank you!