expo-onboarding
Version:
Dead simple onboarding for your React Native/Expo app.
351 lines (347 loc) • 14.2 kB
JavaScript
import { BlurView } from 'expo-blur';
import { SymbolView } from 'expo-symbols';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Animated, Easing, Image, Linking, Platform, ScrollView, Text, View, useWindowDimensions, } from 'react-native';
// Animation constants
const ANIMATION_CONFIG = {
FADE_IN_DURATION: 600,
MOVE_UP_DURATION: 800,
FEATURE_DURATION: 750,
STAGGER_DELAY: 420,
BUFFER_DELAY: 200,
INITIAL_SCALE: 0.7,
BACK_EASING_FACTOR: 1.2,
FEATURE_TRANSLATE_OFFSET: 42,
SCREEN_HEIGHT_FACTOR: 0.25,
};
// Text parsing function for links
function parseTextWithLinks(description, links = []) {
if (!links || links.length === 0) {
return [{ type: 'text', content: description }];
}
// Create array of all link positions in the text
const linkPositions = [];
// Find all occurrences of each link text
for (const link of links) {
let startIndex = 0;
while (true) {
const index = description.indexOf(link.sectionText, startIndex);
if (index === -1)
break;
linkPositions.push({
start: index,
end: index + link.sectionText.length,
text: link.sectionText,
url: link.sectionUrl,
});
startIndex = index + 1;
}
}
// Sort by position in text for optimal processing
linkPositions.sort((a, b) => a.start - b.start);
// Build segments array
const segments = [];
let currentIndex = 0;
for (const linkPos of linkPositions) {
// Add text before link (if any)
if (currentIndex < linkPos.start) {
segments.push({
type: 'text',
content: description.slice(currentIndex, linkPos.start),
});
}
// Add link segment
segments.push({
type: 'link',
content: linkPos.text,
url: linkPos.url,
});
currentIndex = linkPos.end;
}
// Add remaining text after last link (if any)
if (currentIndex < description.length) {
segments.push({
type: 'text',
content: description.slice(currentIndex),
});
}
return segments;
}
// RichText component to render text with clickable links
function RichText({ description, links, tintColor, style, }) {
const segments = useMemo(() => parseTextWithLinks(description, links), [description, links]);
const handleLinkPress = useCallback(async (url) => {
try {
await Linking.openURL(url);
}
catch {
alert('Could not open link');
}
}, []);
return (<Text style={style}>
{segments.map((segment, index) => {
if (segment.type === 'link') {
return (<Text key={index} style={{
...style,
color: tintColor,
fontWeight: 700,
}} onPress={() => handleLinkPress(segment.url)}>
{segment.content}
</Text>);
}
return <Text key={index}>{segment.content}</Text>;
})}
</Text>);
}
/**
* OnboardingView - A beautiful, animated onboarding component for React Native apps.
*
* This component creates a sleek introduction screen that showcases your app's key features
* with smooth animations, SF Symbols integration, and highly customizable styling options.
* Perfect for first-time user experiences and feature introductions.
*
* Features:
* - Smooth fade-in and slide-up animations
* - SF Symbols integration for consistent iconography
* - Customizable typography and colors
* - Support for feature links and interactive elements
* - Responsive design that adapts to different screen sizes
* - Built-in blur effects and modern styling
*
* @param features {OnboardingFeature[]} Array of features to display in the onboarding flow. Each feature object contains:
* - `title`: The feature's display title
* - `description`: Detailed explanation (supports rich text links)
* - `systemImage`: SF Symbol name (e.g., "star.fill", "lock.shield.fill")
* - `icon`: Optional custom React component for Android (overrides systemImage)
* - `links`: Optional array of text segments to make clickable
* @param icon {ImageSourcePropType} The app icon to display at the top. Accepts require() paths, URIs, or image objects.
* @param appName {string} Your app's display name shown prominently at the top of the screen.
* @param tintColor {string} Primary brand color for icons, links, and accents (hex, rgb, or named color).
* @param titleStyle {object} Typography styling for the main app title:
* - `fontFamily`, `fontSize`, `fontWeight`, `lineHeight`, `color`
* @param featureTitleStyle {object} Typography styling for each feature's title:
* - `fontFamily`, `fontSize`, `fontWeight`, `lineHeight`, `color`
* @param featureDescriptionStyle {object} Typography styling for each feature's description:
* - `fontFamily`, `fontSize`, `fontWeight`, `lineHeight`, `color`
* @param ButtonComponent {ComponentType} React component for rendering interactive buttons and links.
*
* @example
* ```tsx
* <OnboardingView
* appName="My Amazing App"
* icon={require('./assets/app-icon.png')}
* tintColor="#007AFF"
* features={[
* {
* title: "Fast & Reliable",
* description: "Experience lightning-fast performance",
* systemImage: "bolt.fill"
* },
* {
* title: "Secure by Design",
* description: "Your data is protected with end-to-end encryption",
* systemImage: "lock.shield.fill"
* }
* ]}
* titleStyle={{ fontSize: 28, fontWeight: 'bold' }}
* featureTitleStyle={{ fontSize: 18, fontWeight: '600' }}
* featureDescriptionStyle={{ fontSize: 16, color: '#666' }}
* ButtonComponent={MyCustomButton}
* />
* ```
*/
export default function OnboardingView({ appName, icon, features, titleStyle, featureTitleStyle, featureDescriptionStyle, tintColor, ButtonComponent, }) {
const { height: screenHeight } = useWindowDimensions();
// Shared values for animations
const iconTitleOpacity = useRef(new Animated.Value(0)).current;
const iconTitleScale = useRef(new Animated.Value(ANIMATION_CONFIG.INITIAL_SCALE)).current;
const iconTitleTranslateY = useRef(new Animated.Value(screenHeight * ANIMATION_CONFIG.SCREEN_HEIGHT_FACTOR)).current;
const blurViewOpacity = useRef(new Animated.Value(0)).current;
// State to trigger feature animations
const [shouldAnimateFeatures, setShouldAnimateFeatures] = useState(false);
// Start animation sequence on mount
useEffect(() => {
// Step 1: Icon and title fade in + zoom in
Animated.timing(iconTitleOpacity, {
toValue: 1,
duration: ANIMATION_CONFIG.FADE_IN_DURATION,
easing: Easing.out(Easing.quad),
useNativeDriver: true,
}).start();
Animated.timing(iconTitleScale, {
toValue: 1,
duration: ANIMATION_CONFIG.FADE_IN_DURATION,
easing: Easing.out(Easing.back(ANIMATION_CONFIG.BACK_EASING_FACTOR)),
useNativeDriver: true,
}).start();
// Step 2: Move them up after fade+zoom completes
setTimeout(() => {
Animated.timing(iconTitleTranslateY, {
toValue: 0,
duration: ANIMATION_CONFIG.MOVE_UP_DURATION,
easing: Easing.out(Easing.cubic),
useNativeDriver: true,
}).start();
}, ANIMATION_CONFIG.FADE_IN_DURATION);
// Step 3: Trigger features to animate after move completes
const featureAnimationTriggerDelay = ANIMATION_CONFIG.FADE_IN_DURATION +
ANIMATION_CONFIG.MOVE_UP_DURATION +
ANIMATION_CONFIG.BUFFER_DELAY;
setTimeout(() => {
setShouldAnimateFeatures(true);
}, featureAnimationTriggerDelay);
// Step 4: Blur view animates after all features complete
setTimeout(() => {
Animated.timing(blurViewOpacity, {
toValue: 1,
duration: ANIMATION_CONFIG.FEATURE_DURATION,
easing: Easing.out(Easing.quad),
useNativeDriver: true,
}).start();
}, featureAnimationTriggerDelay +
features.length * ANIMATION_CONFIG.STAGGER_DELAY +
ANIMATION_CONFIG.BUFFER_DELAY);
}, [iconTitleOpacity, iconTitleScale, iconTitleTranslateY, blurViewOpacity, features.length]);
// Animated styles
const iconTitleAnimatedStyle = {
opacity: iconTitleOpacity,
transform: [{ translateY: iconTitleTranslateY }, { scale: iconTitleScale }],
};
const blurViewAnimatedStyle = {
opacity: blurViewOpacity,
};
const memoizedTitleStyle = useMemo(() => ({
color: 'white',
textAlign: 'center',
fontSize: 36,
fontWeight: 800,
...titleStyle,
}), [titleStyle]);
return (<View style={{
flex: 1,
flexDirection: 'column',
}}>
<ScrollView style={{
flex: 1,
paddingHorizontal: 42,
}} contentContainerStyle={{
flexDirection: 'column',
gap: 64,
paddingBottom: 200,
}} showsHorizontalScrollIndicator={false} showsVerticalScrollIndicator={false}>
<Animated.View style={[
{
flexDirection: 'column',
gap: 28,
},
iconTitleAnimatedStyle,
]}>
<View style={{
width: 100,
height: 100,
alignSelf: 'center',
overflow: 'hidden',
borderRadius: 16.5,
}}>
<Image source={icon} style={{ width: 100, height: 100 }}/>
</View>
<Text style={memoizedTitleStyle}>
Welcome to <Text style={{ color: tintColor }}>{appName}</Text>
</Text>
</Animated.View>
<View style={{
flexDirection: 'column',
gap: 42,
}}>
{features.map((feature, index) => (<Feature key={index} title={feature.title} description={feature.description} systemImage={feature.systemImage} IconComponent={feature.icon} tintColor={tintColor} titleStyle={featureTitleStyle} descriptionStyle={featureDescriptionStyle} links={feature.links} animationDelay={index * ANIMATION_CONFIG.STAGGER_DELAY} shouldAnimate={shouldAnimateFeatures}/>))}
</View>
</ScrollView>
{ButtonComponent && (<Animated.View style={[
{
position: 'absolute',
bottom: 0,
left: 0,
right: 0,
},
blurViewAnimatedStyle,
]}>
<BlurView style={{
alignItems: 'center',
justifyContent: 'center',
backgroundColor: '#00000090',
paddingBottom: 52,
paddingTop: 24,
}} intensity={12}>
<ButtonComponent />
</BlurView>
</Animated.View>)}
</View>);
}
function Feature({ title, description, systemImage, IconComponent, tintColor, titleStyle, descriptionStyle, links, animationDelay = 0, shouldAnimate = false, }) {
const opacity = useRef(new Animated.Value(0)).current;
const translateY = useRef(new Animated.Value(ANIMATION_CONFIG.FEATURE_TRANSLATE_OFFSET)).current;
// Trigger animation when shouldAnimate changes
useEffect(() => {
if (shouldAnimate) {
setTimeout(() => {
Animated.parallel([
Animated.timing(opacity, {
toValue: 1,
duration: ANIMATION_CONFIG.FEATURE_DURATION,
easing: Easing.out(Easing.quad),
useNativeDriver: true,
}),
Animated.timing(translateY, {
toValue: 0,
duration: ANIMATION_CONFIG.FEATURE_DURATION,
easing: Easing.out(Easing.quad),
useNativeDriver: true,
}),
]).start();
}, animationDelay);
}
}, [shouldAnimate, animationDelay, opacity, translateY]);
const isAndroid = useMemo(() => Platform.OS === 'android', []);
const memoizedTitleStyle = useMemo(() => ({
color: 'white',
fontSize: 18,
fontWeight: 600,
...titleStyle,
}), [titleStyle]);
const memoizedDescriptionStyle = useMemo(() => ({
color: 'gray',
fontSize: 18,
fontWeight: 400,
lineHeight: 24,
...descriptionStyle,
}), [descriptionStyle]);
const animatedStyle = {
opacity: opacity,
transform: [{ translateY: translateY }],
};
const staticStyle = {
width: '100%',
maxWidth: 550,
alignSelf: 'center',
};
return (<Animated.View style={[animatedStyle, staticStyle]}>
<View style={{
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'flex-start',
gap: 12,
}}>
{isAndroid ? (IconComponent ? (<IconComponent />) : (<View style={{
width: 42,
height: 42,
backgroundColor: tintColor,
borderRadius: 21,
}}/>)) : (<SymbolView name={systemImage} size={42} tintColor={tintColor}/>)}
<View style={{ flexDirection: 'column', gap: 2, flex: 1 }}>
<Text style={memoizedTitleStyle}>{title}</Text>
<RichText description={description} links={links} tintColor={tintColor} style={memoizedDescriptionStyle}/>
</View>
</View>
</Animated.View>);
}
//# sourceMappingURL=OnboardingView.js.map