Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add animated cascade demo #138

Merged
merged 30 commits into from
May 28, 2023
Merged
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Prev Previous commit
Next Next commit
Dropdown demo works.
  • Loading branch information
Christopher David committed May 27, 2023
commit aae2141f5482c0a325add02a57715f2c0cdf23a5
32 changes: 30 additions & 2 deletions app/screens/CascadeDemo/CascadeDemo.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,38 @@
import { StatusBar } from "expo-status-bar"
import { View } from "react-native"
import { StyleSheet, View } from "react-native"
import { Dropdown } from "./dropdown"

// Defining the options to be passed down to the Dropdown component (except the header option)
// All the iconName values are from the expo/vector-icons package (AntDesign)
const options = [
{ label: "Charts", iconName: "barschart" },
{ label: "Book", iconName: "book" },
{ label: "Calendar", iconName: "calendar" },
{ label: "Camera", iconName: "camera" },
]

export const CascadeDemo = () => {
return (
<View style={{ flex: 1, backgroundColor: "black" }}>
<View style={styles.container}>
<StatusBar style="light" />
<Dropdown
options={options}
header={{ label: "Header", iconName: "ellipsis1" }}
onPick={(val) => {
console.log({
val,
})
}}
/>
</View>
)
}

const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: "#000",
alignItems: "center",
justifyContent: "center",
},
})
203 changes: 203 additions & 0 deletions app/screens/CascadeDemo/dropdown/dropdrop-item.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,203 @@
import React, { useCallback } from 'react';
import Animated, {
Extrapolate,
interpolate,
useAnimatedStyle,
useSharedValue,
withTiming,
} from 'react-native-reanimated';
import { StyleSheet, Text, View } from 'react-native';
import Color from 'color';
import { AntDesign, MaterialIcons } from '@expo/vector-icons';

type DropdownOptionType = {
label: string;
iconName: string;
};

type DropdownItemProps = {
onPress?: (
item: DropdownOptionType & {
isHeader: boolean;
},
) => void;
progress: Animated.SharedValue<number>;
isHeader: boolean;
index: number;
itemHeight: number;
maxDropDownHeight: number;
optionsLength: number;
} & DropdownOptionType;

const DropdownItem: React.FC<DropdownItemProps> = React.memo(
({
onPress,
progress,
isHeader,
index,
optionsLength,
maxDropDownHeight,
itemHeight,
label,
iconName,
}) => {
// Creating a shared value that keeps track of the scale of the item when it's tapped
const tapGestureScale = useSharedValue(1);

const onTouchStart = useCallback(() => {
tapGestureScale.value = withTiming(0.95);
}, [tapGestureScale]);

const onTouchEnd = useCallback(() => {
tapGestureScale.value = withTiming(1);
onPress && onPress({ label, isHeader, iconName });
}, [tapGestureScale, onPress, label, isHeader, iconName]);

// Calculating the background color of the item based on its index
// That's kind of a hacky way to do it, but it works :)
// Basically you can achieve a similar result by using the main color (in this case #1B1B1B)
// as the background color of the item and than update the opacity of the item
// However, this will update the opacity of the item's children as well (the icon and the text)
// Note: the lighten values decrement as the index increases
const lighten = 1 - (optionsLength - index) / optionsLength;
// Note: I really love the Color library :) It's super useful for manipulating colors (https://www.npmjs.com/package/color)
const collapsedBackgroundColor = Color('#1B1B1B').lighten(lighten).hex();
const expandedBackgroundColor = '#1B1B1B';

const rItemStyle = useAnimatedStyle(() => {
// Calculating the bottom position of the item based on its index
// That's useful in order to make the items stack on top of each other (when the dropdown is collapsed)
// and to make them spread out (when the dropdown is expanded)
const bottom = interpolate(
progress.value,
[0, 1],
[index * 15, maxDropDownHeight / 2 - index * (itemHeight + 10)],
);

// Calculating the scale of the item based on its index (note that this will only be applied when the dropdown is collapsed)
const scale = interpolate(progress.value, [0, 1], [1 - index * 0.05, 1]);

// if progress.value < 0.5, the dropdown is collapsed, so we use the collapsedBackgroundColor
// otherwise, the dropdown is expanded, so we use the expandedBackgroundColor (which is the same as the main color)
const backgroundColor =
progress.value < 0.5
? collapsedBackgroundColor
: expandedBackgroundColor;

return {
bottom: bottom,
backgroundColor: backgroundColor,
zIndex: optionsLength - index,
transform: [
{
// Here we're considering both the scale of the item and the scale of the tap gesture
scale: scale * tapGestureScale.value,
},
],
};
}, [index, optionsLength]);

// When the dropdown is collapsed, we want to hide the icon and the text (except for the header)
const rContentStyle = useAnimatedStyle(() => {
const opacity = interpolate(
progress.value,
[0, 1],
[isHeader ? 1 : 0, 1],
);
return {
opacity: opacity,
};
}, []);

// When the dropdown is collapsed, we want to rotate the arrow icon (just for the header)
const rArrowContainerStyle = useAnimatedStyle(() => {
const rotation = interpolate(
progress.value,
[0, 1],
[0, Math.PI / 2],
Extrapolate.CLAMP,
);
const rotateRad = `${rotation}rad`;

return {
transform: [
{
rotate: isHeader ? rotateRad : '0deg',
},
],
};
}, []);

return (
<Animated.View
onTouchStart={onTouchStart}
onTouchEnd={onTouchEnd}
style={[styles.item, { height: itemHeight }, rItemStyle]}>
<Animated.View style={[styles.content, rContentStyle]}>
<View style={styles.iconBox}>
{/* eslint-disable-next-line @typescript-eslint/ban-ts-comment */}
{/* @ts-ignore */}
<AntDesign name={iconName} color={'white'} size={20} />
</View>
<Text style={styles.title}>{label}</Text>
<View
style={{
flex: 1,
}}
/>
<View style={styles.arrowBox}>
<Animated.View style={rArrowContainerStyle}>
{/* eslint-disable-next-line @typescript-eslint/ban-ts-comment */}
{/* @ts-ignore */}
<MaterialIcons
name={isHeader ? 'arrow-forward-ios' : 'arrow-forward'}
size={20}
color={
isHeader ? 'rgba(255,255,255,0.8)' : 'rgba(255,255,255,0.5)'
}
/>
</Animated.View>
</View>
</Animated.View>
</Animated.View>
);
},
);

const styles = StyleSheet.create({
item: {
width: '80%',
position: 'absolute',
borderRadius: 10,
padding: 15,
},
content: {
flex: 1,
flexDirection: 'row',
alignItems: 'center',
},
iconBox: {
height: '80%',
aspectRatio: 1,
backgroundColor: '#0C0C0C',
marginRight: 12,
borderRadius: 10,
justifyContent: 'center',
alignItems: 'center',
},
arrowBox: {
height: '80%',
justifyContent: 'center',
alignItems: 'center',
marginRight: 5,
},
title: {
color: 'white',
textTransform: 'uppercase',
fontSize: 16,
letterSpacing: 1.2,
},
});

export { DropdownItem };
export type { DropdownOptionType };
97 changes: 97 additions & 0 deletions app/screens/CascadeDemo/dropdown/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
// Importing necessary dependencies and types
import React, { useCallback } from 'react';
import type { StyleProp, ViewStyle } from 'react-native';
import { View } from 'react-native';
import {
useDerivedValue,
useSharedValue,
withSpring,
} from 'react-native-reanimated';

import type { DropdownOptionType } from './dropdrop-item';
import { DropdownItem } from './dropdrop-item';

// Defining the props expected by the Dropdown component
type DropdownProps = {
options: DropdownOptionType[];
onPick: (option: DropdownOptionType) => void;
contentContainerStyle?: StyleProp<ViewStyle>;
header: DropdownOptionType;
dropDownItemHeight?: number;
};

// Defining some constants to be used in the component
const DROP_DOWN_ITEM_HEIGHT = 75;
const DROP_DOWN_ITEM_PADDING = 10;

// Defining the Dropdown component
const Dropdown: React.FC<DropdownProps> = React.memo(
({
options,
header,
onPick,
contentContainerStyle,
dropDownItemHeight = DROP_DOWN_ITEM_HEIGHT,
}) => {
// Creating an array of options to be passed down to DropdownItem component
const fullOptions = [header, ...options];

// Creating a shared value that keeps track of whether the dropdown is expanded or not
const isToggled = useSharedValue(false);

// Creating a derived value that maps the isToggled value to a spring animation value
// Almost all the animations in this app are done using this progress value :)
const progress = useDerivedValue(() => {
return withSpring(isToggled.value ? 1 : 0);
}, []);

// Calculating the height of the dropdown when it's fully expanded
const fullDropDownExpandedHeight =
(dropDownItemHeight + DROP_DOWN_ITEM_PADDING) * options.length;

// Callback function for when a dropdown item is picked
const onPickDropdownItem = useCallback(
(option: DropdownOptionType & { isHeader: boolean }) => {
if (option.isHeader) {
// Toggling the isToggled value if the header option is picked
isToggled.value = !isToggled.value;
return;
}
// Calling the onPick function if a non-header option is picked
onPick && onPick(option);
},
[isToggled, onPick],
);

return (
// Rendering the Dropdown component with DropdownItem components inside it
<View
style={[
{
alignItems: 'center',
},
contentContainerStyle,
]}>
{fullOptions.map((option, index) => {
return (
<DropdownItem
key={index}
label={option.label}
iconName={option.iconName}
onPress={onPickDropdownItem}
progress={progress}
isHeader={index === 0}
index={index}
itemHeight={dropDownItemHeight}
maxDropDownHeight={fullDropDownExpandedHeight}
optionsLength={fullOptions.length}
/>
);
})}
</View>
);
},
);

// Exporting the Dropdown component
export { Dropdown };