In this lesson we will build a nice gesture and sensor based progress bar interaction. Along the way we will explore Reanimated's measure, derived value, reactions, sensors, and custom animation APIs.
balloon-5.mp4
In this step we will turn the code from the previous lesson into a slider with a progress bar:
balloon-1.mp4
[1] Copy the code displaying a knob from the previous lesson and modify it such that it no longer snaps to the center upon release.
Remove onFinalize
callback from the previous lesson which should result in the knob staying at the place where it was released.
[2] Render a horizontal progress bar that guides the knob. Make the part to the left from the knob a different color than the right part.
We will need two separate views to implement that.
One of the view representing the whole progress bar will wrap the knob view and the "completed progress" view while being put inside of the GestureDetector
component.
This way, it'll be possible to start panning at any place on the bar.
The second "completed progress" view will be added inside along the knob.
We will use the shared value representing the knob position to control the width of this view:
return (
<Container>
<GestureDetector gesture={gestures}>
<View style={styles.slider} hitSlop={hitSlop}>
<Animated.View style={[styles.progress, { width: x }]} />
<Animated.View style={[styles.knob, animatedStyle]} />
</View>
</GestureDetector>
</Container>
);
We need some additional style to position everything correctly:
const styles = StyleSheet.create({
slider: {
width: "80%",
backgroundColor: colorShades.purple.light,
height: 5,
justifyContent: "center",
},
progress: {
height: 5,
backgroundColor: colorShades.purple.dark,
position: "absolute",
},
});
In this step we will update the code such that it only allows for a movement within the boundaries of the progress bar.
We will use Reanimated's synchronous measure
in order to get the dimension of the progress bar, such that we can use it as the upper bound for the position when processing pan gesture event.
In order to measure views synchronoulsy in Reanimated you need an animated ref object that is assigned to a component that you want to measure:
const aref = useAnimatedRef();
return <View ref={aref} />;
Now you can pass the animated ref object to the measure
method from Reanimated in order to get the view's position and dimensions.
balloon-2.mp4
[1] Create animated ref object and assign it to the progress bar component.
Add the following hook to your component:
const aRef = useAnimatedRef<View>();
[2] Update onChange
implementation to retrieve width of the progress bar and to clamp the knob position such that it never exceeds the width or goes below 0.
We can use clamp
method from react-native-reanimated
helper file to implement onChange
handler as follows:
const panGesture = Gesture.Pan().onChange((ev) => {
const size = measure(aRef);
x.value = clamp((x.value += ev.changeX), 0, size.width);
});
In this step we will render a balloon over the knob that follows the knob movement. We will use similar technique to knob scaling in order to animate the balloon in and out when the user is interacting with the knob:
balloon-3.mp4
[1] Add a balloon with static text.
We start by adding a necessary component representing the balloon to the view hierarchy:
return (
<Container>
<GestureDetector gesture={gestures}>
<View ref={aRef} style={styles.slider} hitSlop={hitSlop}>
<Animated.View style={styles.balloon}>
<View style={styles.textContainer}>
<Text style={{ color: "white", fontWeight: "600" }}>10</Text>
</View>
</Animated.View>
<Animated.View style={[styles.progress, { width: x }]} />
<Animated.View style={[styles.knob, animatedStyle]} />
</View>
</GestureDetector>
</Container>
);
And the necessary styles:
const styles = StyleSheet.create({
textContainer: {
width: 40,
height: 60,
borderTopLeftRadius: 20,
borderTopRightRadius: 20,
borderBottomLeftRadius: 40,
borderBottomRightRadius: 40,
justifyContent: "center",
alignItems: "center",
backgroundColor: colorShades.purple.base,
position: "absolute",
top: -layout.knobSize,
},
balloon: {
alignItems: "center",
justifyContent: "center",
width: 4,
height: layout.indicatorSize,
bottom: -layout.knobSize / 2,
borderRadius: 2,
backgroundColor: colorShades.purple.base,
position: "absolute",
},
});
[2] Create animated styles for the balloon such that it follows the knob.
We create a new animated style object in our component and use shared value representing knob position to control the x-translation of the balloon:
const balloonStyle = useAnimatedStyle(() => {
return {
transform: [{ translateX: x.value }],
};
});
We then use the defined animated style in the view that represents the balloon:
<Animated.View style={[styles.balloon, balloonStyle]}>
[3] Add appear/disappear effect: animated y-position to slide up/down, scale, and opacity.
We create a secondary shared value to control the balloon scale that's initially set to 0. Then we update it along the scale shared value used for the knob:
const balloonScale = useSharedValue(0);
const tapGesture = Gesture.Tap()
.maxDuration(100000)
.onBegin(() => {
scale.value = withSpring(2);
balloonScale.value = withSpring(1);
})
.onEnd(() => {
scale.value = withSpring(1);
balloonScale.value = withSpring(0);
});
We update balloon's animated styles and use the scale value to interpolate y-transition, opacity and the scale:
const balloonStyle = useAnimatedStyle(() => {
return {
opacity: balloonScale.value,
transform: [
{ translateX: x.value },
{ scale: balloonScale.value },
{
translateY: interpolate(
balloonScale.value,
[0, 1],
[0, -layout.indicatorSize]
),
},
],
};
});
In this step we will learn how
balloon-4.mp4
[1] Use AnimatedText
component from @/components/AnimatedText
to display the progress percentage on the balloon.
Here is the updated part of the render method:
return (
<Container>
<GestureDetector gesture={panGesture}>
<View ref={aRef} style={styles.slider} hitSlop={hitSlop}>
<Animated.View style={[styles.balloon, balloonStyle]}>
<View style={styles.textContainer}>
<AnimatedText
text={progress}
style={{ color: "white", fontWeight: "600" }}
/>
</View>
</Animated.View>
<Animated.View style={[styles.progress, { width: x }]} />
<Animated.View style={[styles.knob, animatedStyle]} />
</View>
</GestureDetector>
</Container>
);
[2] Check the implementation from @/components/AnimatedText
to learn how non-style properties can be manipulated with Reanimated's useAnimatedProps
hook.
π
In this step we will add some physics to the balloon movement. We will simulate the balloon inertia such that it appears to be attached to the knob from the bottom and leans to the side while following the knob movement.
balloon-5.mp4
The technique we are going to use is to create a shared value that will follow the top of the balloon.
Then use the top and bottom positions to calculate the angle to rotate the balloon view.
Since we want the top part to have inertia, we will use spring animation along with useDerivedValue
hook to follow the updates of the knob position using spring.
[1] Figure out the formula for the balloon angle
To calculate the angle you can use the following code:
Math.atan2(TOP_X - BOTTOM_X, BALLON_HEIGHT);
[2] Create a derived value that represents the top of the balloon and follows the knob position using spring animation.
const balloonSpringyX = useDerivedValue(() => {
return withSpring(x.value);
});
[3] Update balloon's animated style to include the rotation calculated based on the formula from pt 1.
We need to add rotate
attribute at the end of the transforms in balloon's animated style:
const balloonStyle = useAnimatedStyle(() => {
return {
opacity: knobScale.value,
transform: [
{ translateX: balloonSpringyX.value },
{ scale: knobScale.value },
{
translateY: interpolate(
knobScale.value,
[0, 1],
[0, -layout.indicatorSize]
),
},
{
rotate: `${Math.atan2(
balloonSpringyX.value - x.value,
layout.indicatorSize * 2
)}rad`,
},
],
};
});
In the final step we will explore Reanimated's sensors and custom animations API.
In order to integrate Reanimated code with device sensors, the library provides useAnimatedSensor
hook, which takes a single argument β the sensor type (we will use SensorType.GRAVITY
for gyroscope), and returns an object that consists of sensor
shared value that gets updated with the sensor data (different data shape depending on the sensor type used).
We will use information from the sensor to simulate a gravity movement of the know along the progress bar.
That is, when leaning the device to left, we'd expect the knob to start moving towords the left side.
This effect can be implemented in various different ways, but for the sake of this excercise we will build a custom animation called withGravity
(much like there exists withSpring
and similar).
In order to define a custom animation, we will use defineAnimation
API from Reanimated.
This API takes an animation factory worklet that instantiates an animation object for a given animation configuration.
The animation object consists of two main methods: onStart
and onFrame
.
Below we present a template for defining the custom gravity animation:
function withGravity(userConfig) {
"worklet";
return defineAnimation(0 /* initial position if none is specified */, () => {
"worklet";
return {
onStart: (
animation /* animation object reference */,
value /* position at the moment when animation is started */,
now /* timestamp */,
previousAnimation /* previous animation object if we override a new animation over a running one */
) => {},
onFrame: (
animation /* animation object reference */,
now /* timestamp */
) => {
// This method is expected to write the updated position for this animation into `animation.current`
// Should return true if animation has finished or false otherwise
},
};
});
}
When an animation is ongoing, onFrame
callback will execute on every frame.
It is expected for the onFrame
callback to update animated.current
field with the current position of the animated value, and to return true
when the animation completes.
Finally to get all the things hooked together, we will use useAnimatedReaction
hook, which helps in executing side-effect upon shared value updates.
We will use this hook to process updates to the sensor and start gravity animation for the knob position.
This hook takes two arguments: one is the "prepare" worklet and the other is "reaction" worklet.
In our case we will use "prepare" phase to calculate the acceleration based on the device rotation, then use that gravity in the "reaction" phase to run the animation.
useAnimatedReaction(
() => {
return calculateAccelerationBasedOnRotation(sensor.value.x);
},
(acceleration) => {
// start gravity animation
x.value = withGravity({ acceleration });
}
);
output_video.mp4
[1] Define withGravity
method using the provided schema, use animation
object to keep velocity and last timestamp, then use these two along with configured acceleration to calculate new velocty and position.
Below we show an initial implementation of withGravity
that
function withGravity(userConfig) {
"worklet";
return defineAnimation(0, () => {
"worklet";
const config = {
acceleration: 9.81,
velocity: 0,
};
Object.assign(config, userConfig);
return {
onStart: (animation, value, now, previousAnimation) => {
animation.current = value;
},
onFrame: (animation, now) => {
const { lastTimestamp, current, velocity } = animation;
const { acceleration } = config;
const delta = (now - lastTimestamp) / 1000;
animation.current = current + velocity * delta;
animation.velocity =
velocity +
(acceleration - Math.sign(velocity) * (kineticFriction ?? 0)) * delta;
animation.lastTimestamp = now;
return false;
},
};
});
}
[2] Add "continuity" by using previousAnimation
in onStart
object to copy last timestamp and velocity from the previous gravity animation. This way we can continue the previous animation while changing the configuration (i.e. update acceleration)
Below we present the updated onStart
callback
return {
onStart: (animation, value, now, previousAnimation) => {
animation.current = value;
animation.lastTimestamp = previousAnimation?.lastTimestamp ?? now;
animation.velocity = previousAnimation?.velocity ?? config.velocity;
},
};
[3] Use animated reaction as presented above to hook sensor with the gravity animation. Note that since the animation never ends, the knob will animate away the progress bar bounds.
Here is how animated reaction can be used to spawn gravity animation on shared value representing the knob position.
const GRAVITY = 9.81 * 100;
useAnimatedReaction(
() => {
return GRAVITY * Math.sin(sensor.value.x);
},
(gravity) => {
const size = measure(aRef);
x.value = withGravity({
clamp: [0, size.width],
acceleration: gravity,
staticFriction: 800,
kineticFriction: 500,
});
}
);
[4] Prevent gravity animation from running when user is interacting with the knob. This can be done by defining isTouching
shared value and updating it accordingly in gesture callbacks.
We first define the new shared value:
const isTouching = useSharedValue(false);
Next, we add onBegin
and onFinalize
callbacks to pan when we update its value:
const panGesture = Gesture.Pan()
.onBegin(() => {
isTouching.value = true;
})
.onFinalize(() => {
isTouching.value = false;
});
Finally, we take the new variable into account in the sensor reaction β we don't want the animation to start when sensor is active:
useAnimatedReaction(
() => {
return isTouching.value ? undefined : GRAVITY * Math.sin(sensor.value.x);
},
(gravity) => {
if (gravity !== undefined) {
x.value = withGravity({
acceleration: gravity,
});
}
}
);
[5] Add bounds as a config parameter for withGravity
and use it to prevent the knob from falling off the cliff.
We update gravity animation such that it extract bounds from config object and uses it later on when updating velocty and position. Note that when we reach bound the bound we want to finish the animation, however if there is a velcoty towards the opposite direction we want for it to continue.
return {
onFrame: (animation, now) => {
const { lastTimestamp, current, velocity } = animation;
const { acceleration, bounds } = config;
const delta = (now - lastTimestamp) / 1000;
animation.current = current + velocity * delta;
animation.velocity =
velocity +
(acceleration - Math.sign(velocity) * (kineticFriction ?? 0)) * delta;
animation.lastTimestamp = now;
if (bounds) {
if (animation.current <= bounds[0]) {
animation.current = bounds[0];
if (animation.velocity <= 0) {
animation.velocity = 0;
return true;
}
} else if (animation.current >= bounds[1]) {
animation.current = bounds[1];
if (animation.velocity >= 0) {
animation.velocity = 0;
return true;
}
}
}
return false;
},
};
[BONUS 1] Add static friction to the gravity animation such that the know does not start moving immediately and with low device angles.
Just check steps/final.tsx β this is the final step π€·
Go to: Dynamic Tabs