Skip to content

Instantly share code, notes, and snippets.

@sregg
Created July 22, 2023 19:16
Show Gist options
  • Save sregg/9d99030f7518a35fa971eb7f07f014c8 to your computer and use it in GitHub Desktop.
Save sregg/9d99030f7518a35fa971eb7f07f014c8 to your computer and use it in GitHub Desktop.
Cookin Landing Screen Animations
import { useNavigation } from '@react-navigation/native';
import React, { useCallback, useEffect, useRef } from 'react';
import { StyleSheet, useWindowDimensions, View } from 'react-native';
import { Gesture, GestureDetector } from 'react-native-gesture-handler';
import Animated, {
Extrapolation,
interpolate,
runOnJS,
useAnimatedStyle,
useSharedValue,
withTiming,
} from 'react-native-reanimated';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { AppButton, AppText, PageIndicator, Spacing } from '~/components';
import { ROUTE_NAMES } from '~/modules/navigation/route-names';
import { STRINGS } from '~/strings';
import { colors } from '~/styles/global';
import { LANDING_PAGES } from './LandingPages';
import { styles } from './styles';
import type { SharedValue } from 'react-native-reanimated';
import type { LandingNavigationProps } from './types';
const AUTO_ROTATE_INTERVAL_DELAY = 5000;
type BackgroundImageProps = {
source: number;
index: number;
pageIndexProgress: SharedValue<number>;
};
const BackgroundImage = ({
source,
index,
pageIndexProgress,
}: BackgroundImageProps) => {
const animatedStyles = useAnimatedStyle(() => ({
opacity: interpolate(
pageIndexProgress.value,
[index - 1, index, index + 1],
[0, 1, 0],
Extrapolation.CLAMP
),
}));
return (
<Animated.Image
style={[styles.bgImage, animatedStyles]}
source={source}
resizeMode="cover"
/>
);
};
type SlidingTextsProps = {
title: string;
subtitle: string;
index: number;
pageIndexProgress: SharedValue<number>;
};
const SlidingTexts = ({
title,
subtitle,
index,
pageIndexProgress,
}: SlidingTextsProps) => {
const { width } = useWindowDimensions();
const animatedStyles = useAnimatedStyle(() => ({
opacity: interpolate(
pageIndexProgress.value,
[index - 1, index, index + 1],
[0, 1, 0],
Extrapolation.CLAMP
),
transform: [
{
translateX: interpolate(
pageIndexProgress.value,
LANDING_PAGES.map((_, i) => i),
LANDING_PAGES.map((_, i) => -i * width),
Extrapolation.CLAMP
),
},
],
}));
return (
<Animated.View
style={[
{
width,
},
styles.textContainer,
animatedStyles,
]}
>
<AppText textStyle="serif-bold" fontSize={36} customStyle={styles.title}>
{title}
</AppText>
<Spacing h={24} />
<AppText fontSize={14} customStyle={styles.subtitle}>
{subtitle}
</AppText>
</Animated.View>
);
};
export const Landing = () => {
const { width } = useWindowDimensions();
const autoRotateIntervalRef = useRef<NodeJS.Timer | null>(null);
const userHasInteracted = useRef(false);
const navigation = useNavigation<LandingNavigationProps>();
const insets = useSafeAreaInsets();
const pageIndexProgress = useSharedValue(0);
const pageIndexAtStart = useSharedValue(0);
const [pageIndex, setPageIndex] = React.useState(0);
const autoRotateProgress = useSharedValue(0);
const stickersAnimatedStyles = useAnimatedStyle(
() => ({
opacity: interpolate(
pageIndexProgress.value,
[pageIndex - 1, pageIndex, pageIndex + 1],
[0, 1, 0],
Extrapolation.CLAMP
),
}),
[pageIndex]
);
// automatically rotate through the pages every 3 seconds
const cancelAutoRotate = useCallback(() => {
if (autoRotateIntervalRef.current) {
clearInterval(autoRotateIntervalRef.current);
autoRotateProgress.value = 0;
}
}, [autoRotateProgress]);
useEffect(() => {
if (userHasInteracted.current) {
return;
}
autoRotateIntervalRef.current = setInterval(() => {
const newPageIndex = (pageIndexProgress.value + 1) % LANDING_PAGES.length;
pageIndexProgress.value = withTiming(newPageIndex);
setPageIndex(newPageIndex);
autoRotateProgress.value = 0;
autoRotateProgress.value = withTiming(1, {
duration: AUTO_ROTATE_INTERVAL_DELAY,
});
}, AUTO_ROTATE_INTERVAL_DELAY);
autoRotateProgress.value = withTiming(1, {
duration: AUTO_ROTATE_INTERVAL_DELAY,
});
return cancelAutoRotate;
}, [cancelAutoRotate, pageIndexProgress, autoRotateProgress]);
const onScreenTouch = () => {
userHasInteracted.current = true;
cancelAutoRotate();
};
const onGetStarted = () => {
navigation.navigate(ROUTE_NAMES.SIGN_UP);
};
const onSignIn = () => {
navigation.navigate(ROUTE_NAMES.SIGN_IN);
};
const panGesture = Gesture.Pan()
.onStart(() => {
pageIndexAtStart.value = pageIndexProgress.value;
runOnJS(onScreenTouch)();
})
.onUpdate((e) => {
pageIndexProgress.value = interpolate(
e.translationX,
// use half of width so when we round in onEnd, we snap exactly at 1/4th the screen
[-width / 2, 0, width / 2],
[
Math.min(pageIndexAtStart.value + 1, LANDING_PAGES.length - 1),
pageIndexAtStart.value,
Math.max(pageIndexAtStart.value - 1, 0),
],
Extrapolation.CLAMP
);
})
.onEnd(() => {
const rounded = Math.round(pageIndexProgress.value);
pageIndexProgress.value = withTiming(rounded, undefined);
runOnJS(setPageIndex)(rounded);
});
return (
<GestureDetector gesture={panGesture}>
<View style={StyleSheet.absoluteFill}>
{LANDING_PAGES.map((page, index) => (
<BackgroundImage
key={index}
index={index}
source={page.bgImage}
pageIndexProgress={pageIndexProgress}
/>
))}
<View style={styles.stickersContainer}>
{LANDING_PAGES[pageIndex].stickers.map((sticker, index) => (
<Animated.Image
key={`page-${pageIndex}-sticker-${index}`}
style={[sticker.style, stickersAnimatedStyles]}
source={sticker.image}
resizeMode="contain"
entering={LANDING_PAGES[
pageIndex
].stickersEnteringAnimation.randomDelay()}
/>
))}
</View>
<View
style={[
styles.contentContainer,
{ marginBottom: 20 + insets.bottom },
]}
>
<View style={styles.pageIndicatorContainer}>
<PageIndicator
pageCount={LANDING_PAGES.length}
currentPageColor={colors.berry500}
otherPagesColor={colors.lightGray}
currentPage={pageIndexProgress}
autoRotateProgress={autoRotateProgress}
/>
</View>
<Spacing h={20} />
<View style={styles.textsContainerRow}>
{LANDING_PAGES.map((_, index) => (
<SlidingTexts
key={index}
index={index}
title={STRINGS.LANDING.PAGE_TITLES[index]}
subtitle={STRINGS.LANDING.PAGE_SUBTITLES[index]}
pageIndexProgress={pageIndexProgress}
/>
))}
</View>
<Spacing h={24} />
<View style={styles.buttonsContainer}>
<AppButton title={STRINGS.LANDING.CTA} onPress={onGetStarted} />
<AppButton
buttonType="text-only"
customTextStyle={styles.signInButtonText}
title={STRINGS.LANDING.SIGN_IN}
onPress={onSignIn}
/>
</View>
</View>
</View>
</GestureDetector>
);
};
import { BounceInUp, ZoomIn } from 'react-native-reanimated';
import type { BaseAnimationBuilder } from 'react-native-reanimated';
export type LandingPage = {
bgImage: number;
stickers: Sticker[];
stickersEnteringAnimation: BaseAnimationBuilder;
};
export type Sticker = {
image: number;
style: {
position: 'absolute';
width: number;
height: number;
top?: string;
left?: string;
right?: string;
bottom?: string;
};
};
export const LANDING_PAGES: LandingPage[] = [
{
bgImage: require('~/assets/images/landing-page-bg-1.png'),
stickersEnteringAnimation: new BounceInUp(),
stickers: [
{
image: require('~/assets/images/landing-page-1-sticker-1.png'),
style: {
position: 'absolute',
width: 75,
height: 75,
top: '15%',
left: '10%',
},
},
{
image: require('~/assets/images/landing-page-1-sticker-2.png'),
style: {
position: 'absolute',
width: 44,
height: 44,
top: '20%',
right: '20%',
},
},
{
image: require('~/assets/images/landing-page-1-sticker-3.png'),
style: {
position: 'absolute',
width: 148,
height: 138,
top: '40%',
left: '33%',
},
},
{
image: require('~/assets/images/landing-page-1-sticker-4.png'),
style: {
position: 'absolute',
width: 68,
height: 75,
bottom: '15%',
left: '8%',
},
},
{
image: require('~/assets/images/landing-page-1-sticker-5.png'),
style: {
position: 'absolute',
width: 46,
height: 50,
bottom: '20%',
right: '15%',
},
},
],
},
{
bgImage: require('~/assets/images/landing-page-bg-2.png'),
stickersEnteringAnimation: ZoomIn.springify(),
stickers: [
{
image: require('~/assets/images/landing-page-2-sticker-1.png'),
style: {
position: 'absolute',
width: 66,
height: 66,
top: '15%',
right: '30%',
},
},
{
image: require('~/assets/images/landing-page-2-sticker-2.png'),
style: {
position: 'absolute',
width: 50,
height: 50,
top: '40%',
left: '15%',
},
},
{
image: require('~/assets/images/landing-page-2-sticker-3.png'),
style: {
position: 'absolute',
width: 98,
height: 75,
bottom: '10%',
left: '15%',
},
},
{
image: require('~/assets/images/landing-page-2-sticker-4.png'),
style: {
position: 'absolute',
width: 40,
height: 40,
bottom: '10%',
right: '15%',
},
},
],
},
{
bgImage: require('~/assets/images/landing-page-bg-3.png'),
stickersEnteringAnimation: ZoomIn.springify(),
stickers: [
{
image: require('~/assets/images/landing-page-3-sticker-1.png'),
style: {
position: 'absolute',
width: 114,
height: 114,
top: '40%',
left: '2%',
},
},
{
image: require('~/assets/images/landing-page-3-sticker-2.png'),
style: {
position: 'absolute',
width: 150,
height: 90,
bottom: '10%',
right: '2%',
},
},
],
},
];
import React from 'react';
import { View } from 'react-native';
import Animated, {
Extrapolation,
interpolate,
interpolateColor,
useAnimatedStyle,
} from 'react-native-reanimated';
import { styles } from './styles';
import type { PageIndicatorProps } from './types';
import type { SharedValue } from 'react-native-reanimated';
type AnimatedDotProps = Pick<
PageIndicatorProps,
'currentPageColor' | 'otherPagesColor'
> & {
index: number;
currentPage: SharedValue<number>;
autoRotateProgress?: SharedValue<number>;
};
const AnimatedDot = ({
index,
currentPageColor,
otherPagesColor,
currentPage,
autoRotateProgress,
}: AnimatedDotProps) => {
const animatedStyles = useAnimatedStyle(() => ({
width: interpolate(
currentPage.value,
[index - 1, index, index + 1],
[
styles.otherPage.width,
styles.currentPage.width,
styles.otherPage.width,
],
Extrapolation.CLAMP
),
backgroundColor:
!autoRotateProgress || autoRotateProgress.value === 0
? interpolateColor(
currentPage.value,
[index - 1, index, index + 1],
[otherPagesColor, currentPageColor, otherPagesColor]
)
: otherPagesColor,
}));
const autoRotateProgressStyles = useAnimatedStyle(() => ({
width:
autoRotateProgress && currentPage.value === index
? interpolate(
autoRotateProgress.value,
[0, 1],
[styles.otherPage.width, styles.currentPage.width],
Extrapolation.CLAMP
)
: 0,
}));
return (
<View key={index}>
<Animated.View style={[styles.base, animatedStyles]} />
<Animated.View
style={[
styles.base,
styles.progress,
{ backgroundColor: currentPageColor },
autoRotateProgressStyles,
]}
/>
</View>
);
};
type DotProps = Pick<
PageIndicatorProps,
'currentPageColor' | 'otherPagesColor'
> & {
index: number;
currentPage: number;
};
const Dot = ({
index,
currentPage,
currentPageColor,
otherPagesColor,
}: DotProps) => {
const isCurrent = index === currentPage;
return (
<View
key={index}
style={[
styles.base,
isCurrent ? styles.currentPage : styles.otherPage,
{ backgroundColor: isCurrent ? currentPageColor : otherPagesColor },
]}
/>
);
};
export const PageIndicator = ({
pageCount,
currentPage,
currentPageColor,
otherPagesColor,
autoRotateProgress,
}: PageIndicatorProps) => (
<View style={styles.container}>
{Array.from({ length: pageCount }).map((_, index) =>
typeof currentPage === 'number' ? (
<Dot
key={index}
currentPage={currentPage}
index={index}
currentPageColor={currentPageColor}
otherPagesColor={otherPagesColor}
/>
) : (
<AnimatedDot
key={index}
index={index}
currentPageColor={currentPageColor}
otherPagesColor={otherPagesColor}
currentPage={currentPage}
autoRotateProgress={autoRotateProgress}
/>
)
)}
</View>
);
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment