I want to implement an animated accordion list/ drawer / drop-down menu / collapsible card.
The animation should be performant and look like this:
After a lot of searching, I could find many libraries. But I wanted to implement it without any library. Also, some tutorials showed how to build one, but they were not performant.
Finally, this is how I implemented it. The complete snack code is here: https://snack.expo.dev/#vipulchandra04/a85348
I am storing isOpen (whether the menu is open or closed) in a state. Then changing that state on button press. I am using the LayoutAnimation API in React-Native to animate the opening and closing of the list. LayoutAnimation runs the animation natively, thus it is performant.
const Accordion = ({ title, children }) => {
const [isOpen, setIsOpen] = useState(false);
const toggleOpen = () => {
setIsOpen(value => !value);
LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut);
}
return (
<>
<TouchableOpacity onPress={toggleOpen} activeOpacity={0.6}>
{title}
</TouchableOpacity>
<View style={[styles.list, !isOpen ? styles.hidden : undefined]}>
{children}
</View>
</>
);
};
const styles = StyleSheet.create({
hidden: {
height: 0,
},
list: {
overflow: 'hidden'
},
});
With this, it will fix the Vipul's demo's error: if click accordion so fast, children's opacity descending to 0. and add animation for icon
import {
Animated,
LayoutAnimation,
Platform,
StyleProp,
StyleSheet,
UIManager,
View,
ViewStyle,
} from 'react-native';
import Ionicons from 'react-native-vector-icons/Ionicons;
if (
Platform.OS === 'android' &&
UIManager.setLayoutAnimationEnabledExperimental
) {
UIManager.setLayoutAnimationEnabledExperimental(true);
}
const toggleAnimation = duration => {
return {
duration: duration,
update: {
property: LayoutAnimation.Properties.scaleXY,
type: LayoutAnimation.Types.easeInEaseOut,
},
delete: {
property: LayoutAnimation.Properties.opacity,
type: LayoutAnimation.Types.easeInEaseOut,
},
};
};
interface IAccordion {
title?: JSX.Element | JSX.Element[];
children?: JSX.Element | JSX.Element[];
style?: StyleProp<ViewStyle> | undefined;
}
const Accordion = ({title, children, style}: IAccordion) => {
const [isOpen, setIsOpen] = useState(false);
const animationController = useRef(new Animated.Value(0)).current;
const arrowTransform = animationController.interpolate({
inputRange: [0, 1],
outputRange: ['0deg', '90deg'],
});
const onToggle = () => {
setIsOpen(prevState => !prevState);
const duration = 300;
const config = {
duration: duration,
toValue: isOpen ? 0 : 1,
useNativeDriver: true,
};
Animated.timing(animationController, config).start();
LayoutAnimation.configureNext(toggleAnimation(duration));
};
return (
<View style={style ? style : styles.accordion}>
<TouchableOpacity onPress={onToggle} style={styles.heading}>
{title}
<Animated.View style={{transform: [{rotateZ: arrowTransform}]}}>
<Ionicons name={'chevron-forward-outline'} size={18} />
</Animated.View>
</TouchableOpacity>
<View style={[styles.list, !isOpen ? styles.hidden : undefined]}>
{children}
</View>
</View>
);
};
export default Accordion;
I had difficulty using the native API, so I go to third parties. The only thing I couldn't do was make the accordion size automatic.
import { useEffect } from 'react';
import Animated, {
useSharedValue,
useAnimatedStyle,
withTiming,
Easing,
} from 'react-native-reanimated';
import styled from 'styled-components';
const Accordion = ({ children, open, height }) => {
const heightAnimation = useSharedValue(0);
useEffect(() => {
if (open === true) heightAnimation.value = height;
if (open === false) heightAnimation.value = 0;
}, [open]);
const animatedStyle = useAnimatedStyle(() => {
return {
height: withTiming(heightAnimation.value, {
duration: 500,
easing: Easing.bezier(0.25, 0.1, 0.25, 1),
}),
};
});
return (
<>
<Main style={animatedStyle}>{children}</Main>
</>
);
};
const Main = styled(Animated.View)`
width: 100%;
align-items: center;
justify-content: center;
overflow: hidden;
`;
export default Accordion;
Using:
<Accordion height={height} open={open}>
{children}
</Accordion>
As asked here for an example of what I managed to do with it, I tried to get as much out of it as possible.
You can see a deploy here: https://snack.expo.dev/#francisco.ossian/accordion
Libs used, react-native-reanimated
Related
I want to make a flatlist with three items and the selected/current Index item should be bigger as the two items next to.
Current Code:
const translateX = useSharedValue(0);
const scrollHandler = useAnimatedScrollHandler({
onScroll: (e) => {
translateX.value = e.contentOffset.x;
}
})
const animatedStyle = useAnimatedStyle(() => {
const scale = interpolate(translateX.value, [width / 3, 0, width / 3], [0.2, 1, 0.2], Extrapolate.CLAMP);
return {
transform: [{
scale
}]
}
}, []);
const renderI = ({ item }) => (
<Animated.View style={[animatedStyle]}>
<ProductCardAuto
{...item}
onPressProduct={() => handleNavigateToProduct(item)}
onPressReviews={handleOpenModalReviews}
/>
</Animated.View>
)
<Animated.FlatList
data={mockProducts}
renderItem={renderI}
keyExtractor={(item) => item.id}
snapToAlignment="start"
decelerationRate={"normal"}
onScroll={scrollHandler}
horizontal
snapToInterval={120}
/>
I am very thankful for your help. Idk what I am doing wrong. Maybe you can tell me what is wrong with it. it does not work its scale all items to 0.2 if I scroll
Even if the question has been already answered, I would suggest another approach.
What worked for me is react-native-reanimated-carousel, which not only that is very easy to use and very customizable, but is also using react-native-reanimated and that means native performance and precise animations because it runs the animations on the UI thread rather than the JS thread.
EDIT: I noticed that you're actually looking for a reanimated answer, which is great!
Based on the library I showed you, I believe you're looking for a parallax horizontal.
Here's a link to the example: https://github.com/dohooo/react-native-reanimated-carousel/blob/main/exampleExpo/src/pages/parallax/index.tsx
kindof tried to make it acc to what you needed.
You can check this expo : https://snack.expo.dev/d_myr1HdyN
import React,{useState,useEffect,useRef,useCallback} from 'react';
import { Text, View, StyleSheet ,Animated,FlatList,Dimensions,Easing } from 'react-native';
import Constants from 'expo-constants';
// You can import from local files
import AssetExample from './components/AssetExample';
// or any pure javascript modules available in npm
import { Card } from 'react-native-paper';
const data = ["hey there","indian","mango","whatsup","arsenal","jojoba"]
const screenWidth = Dimensions.get("window").width;
const EachItem = (props) => {
const {text = "",index=0,currentView={}} = props || {}
const {visible = false, index:visibleIndex = 0} = currentView;
console.log(visible,visibleIndex,"wuhyyyyy")
const animRef = useRef(new Animated.Value(0)).current
const startAnimation = useCallback(() => {
Animated.timing(animRef,{
toValue:1,
duration:1000,
useNativeDriver:true,
easing:Easing.ease,
}).start()
},[animRef])
const stopAnim = useCallback(() => {
Animated.timing(animRef,{
toValue:0,
duration:1000,
useNativeDriver:true,
easing:Easing.ease,
}).start()
},[animRef])
useEffect(() => {
if(visible === true && visibleIndex===index ){
startAnimation()
}
if(visible === false ){
setTimeout(() =>stopAnim(),1000)
}
},[startAnimation,visible,visibleIndex,index,stopAnim])
const scaleInter = animRef.interpolate({inputRange:[0,1],outputRange:[1,1.5]})
return(
<Animated.View style={{marginHorizontal:20 ,height:100,
backgroundColor:'rgba(102,116,255,0.6)',
width:screenWidth/3,
alignItems:'center',
justifyContent:'center',
transform:[{
scale:scaleInter
}]
}} >
<Text>{text}</Text>
</Animated.View>
)
}
export default function App() {
const [currentView,setCurrentView] = useState({visible:false,index:null})
const onViewRef = React.useRef((viewableItems) => {
console.log(viewableItems?.viewableItems,"viewableItems")
if(viewableItems?.viewableItems.length === 2){
setCurrentView({visible:false,index:null})
}
if(viewableItems?.viewableItems.length === 1){
const currentItemIndex = viewableItems?.viewableItems[0]?.index
setCurrentView({visible:true,index:currentItemIndex})
}
});
const viewConfigRef = React.useRef({ viewAreaCoveragePercentThreshold: 30 });
const eachItem = ({item,index}) => {
return <EachItem text={item} currentView={currentView} index={index} />
}
return (
<View style={styles.container}>
<Animated.FlatList
data={data}
style={{flex:1}}
contentContainerStyle={{paddingTop:100}}
horizontal={true}
onViewableItemsChanged={onViewRef.current}
viewabilityConfig={viewConfigRef.current}
renderItem={eachItem}
/>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
// justifyContent: 'center',
paddingTop: Constants.statusBarHeight,
backgroundColor: '#ecf0f1',
padding: 8,
// alignItems:'center'
},
paragraph: {
margin: 24,
fontSize: 18,
fontWeight: 'bold',
textAlign: 'center',
},
});
Hope it helps. feel free for doubts
I was working with react native reanimated 1.13.1, but I decided to upgrade because of the new content and new features. I have been trying to fix an animation that I had on my Custom Drawer the animation consists on scaling the screen differently when the user accesses the drawer. It was working perfectly on 1.13.1 but on 2.3.0 I can't seem to get the scaling right with props.progress.
This is the code for the drawer.
import React, { useEffect } from 'react';
import { Container } from './styles';
import { createDrawerNavigator } from '#react-navigation/drawer';
import { Register } from '../../screens/Register';
import { CustomDrawerContent } from '../CustomDrawerContent';
import { Dashboard } from '../../screens/Dashboard';
import { Summary } from '../../screens/Summary';
//REDUX
import { connect } from 'react-redux';
import { setSelectedTab } from '../../stores/tab/tabActions';
import { Settings } from '../../screens/Settings';
import { useSharedValue, interpolate, useAnimatedStyle, Extrapolate, withTiming } from 'react-native-reanimated';
const Drawer = createDrawerNavigator();
function CustomDrawer({ selectedTab, setSelectedTab }: any){
function setDefaultOnlyOnce() {
setSelectedTab('Dashboard');
}
let progress = useSharedValue(0);
const scale: any = useAnimatedStyle(() => {
return {
transform: [{
scale: interpolate(
progress.value,
[0, 1],
[1, 0.8],
Extrapolate.CLAMP
)
}]
};
})
const borderRadius: any = useAnimatedStyle(() => {
return {
borderRadius: interpolate(
progress.value,
[0, 1],
[0, 26],
Extrapolate.CLAMP
)
};
})
const animatedStyle = [[ borderRadius, scale ]];
useEffect(() => {
setDefaultOnlyOnce();
}, [])
return (
<Container>
<Drawer.Navigator
drawerType='slide'
overlayColor='transparent'
drawerStyle={{
flex: 1,
width: '70%',
paddingRight: 20,
backgroundColor: 'transparent'
}}
initialRouteName="Dashboard"
drawerContent={props => {
setTimeout(() => {
progress.value = 1;
/*
PREVIOUS VERSION:
setProgress(props.progress as any);
*/
}, 0);
return <CustomDrawerContent
navigation={props.navigation}
selectedTab={selectedTab}
setSelectedTab={setSelectedTab}
/>
}}
>
<Drawer.Screen name="Dashboard">
{props => <Dashboard drawerAnimationStyle={animatedStyle} />}
</Drawer.Screen>
<Drawer.Screen name="Register">
{props => <Register drawerAnimationStyle={animatedStyle} setSelectedTab={setSelectedTab}/>}
</Drawer.Screen>
<Drawer.Screen name="Summary">
{props => <Summary drawerAnimationStyle={animatedStyle}/>}
</Drawer.Screen>
<Drawer.Screen name="Settings">
{props => <Settings drawerAnimationStyle={animatedStyle}/>}
</Drawer.Screen>
</Drawer.Navigator>
</Container>
);
}
function mapStateToProps(state: any) {
return {
selectedTab: state.tabReducer.selectedTab
}
}
function mapDispatchToProps(dispatch: any) {
return {
setSelectedTab: (selectedTab: any) => { return dispatch(setSelectedTab(selectedTab)) }
}
}
export default connect(mapStateToProps, mapDispatchToProps)(CustomDrawer);
I think that the problem might be on setting progress.value where previously it setted the progress with props.progress but that doesn't seem to work for the new version.
When the progress.value is 0 the screen is completely filled (Drawer is closed) and when the progress.value is 1 the screen is resized and the drawer is opened. This, altered in the previous version with the props.progress being set on drawerContent
I am new to programming and my task is to animate the height of my image on scroll. i.e. decrease height on scroll down and increase height to original when scrolling up. However, following the React Native documentation on Animated, I replaced Text component with my Image and I'm unable to get the image showing. It shows when I don't wrap it in <Animated.View>, can anyone explain why and tell me how to fix? Thank you.
This is my current code just trying to do the sample fade animation before I try to animate height and running into issues getting the TopImage component showing:
import React, { useRef } from 'react';
import {
Animated,
View,
ScrollView,
StyleSheet,
} from 'react-native';
import { useHeaderHeight } from '#react-navigation/stack';
import TopImage from '../components/TopImage';
import GroceryList from '../components/GroceryList';
const App = () => {
const { theme } = useContext(ThemeContext);
const styles = createStyleSheet();
const fadeAnim = useRef(new Animated.Value(0)).current;
const fadeIn = () => {
Animated.timing(fadeAnim, {
toValue: 1,
duration: 5000,
useNativeDriver: true,
}).start();
};
const fadeOut = () => {
Animated.timing(fadeAnim, {
toValue: 0,
duration: 3000,
useNativeDriver: true,
}).start();
};
return (
<View style={styles.container}>
<Animated.View
style={
{
// Bind opacity to animated value
opacity: fadeAnim,
},
}>
<TopImage />
</Animated.View>
<ScrollView
style={styles.scrollContainer}
onScrollBeginDrag={() => fadeIn()}>
<GroceryList />
</ScrollView>
</View>
);
};
const createStyleSheet = () => {
const headerHeight = useHeaderHeight();
return StyleSheet.create({
container: { flex: 1 },
scrollContainer: { paddingTop: headerHeight + 100 },
});
};
export default App;
and this is the code for my TopImage component:
import React from 'react';
import { Image, StyleSheet } from 'react-native';
const topImage = require('../images/top.png');
const TopImage = () => {
const styles = createStyleSheet();
return <Image source={topImage} style={styles.image} />;
};
const createStyleSheet = () => {
return StyleSheet.create({
image: {
position: 'absolute',
width: '100%',
height: '50%',
},
});
};
export default TopImage;
The reason it's not showing is because your image is 'absolute' positioned and as such, the outer container (Animated.View) has no height.
If you apply a height to the Animated.View like below. You'll see that the opacity is actually working, you just couldn't see it before because the Animated.View was 0 pixels tall.
<Animated.View
style={
{
// Bind opacity to animated value
opacity: fadeAnim,
height: 300,
},
}>
<TopImage />
</Animated.View>
Alternatively you could make the image position relative, height 0 and animate it to the correct size.
Snack is here
Hello, I'm hard stuck on a silly problem and I'm becoming nut.
I just wanted to make a simple and elegant animation when a screen is focused (in a tab bar navigation). My snack works perfectly until I perform a state change in my screen. Then the animation just won't start, even though the callback from focus listener is called and executed (check logs)... WHY?
I made a button to trigger manually the animation... and it works!????
I think I made the snack clear, but if you need more information, please ask me. I beg you, please help a brother in despair.
Snack is here
If you're lazy to click the Snack:
import React, { useState, useEffect } from "react";
import { Text, View, Animated, Dimensions, StyleSheet, SafeAreaView, Button } from 'react-native';
import { NavigationContainer } from '#react-navigation/native';
import { createBottomTabNavigator } from '#react-navigation/bottom-tabs';
function HomeScreen({navigation}) {
const initialXPos = Dimensions.get("window").height * 0.5 ;
const xPos = new Animated.Value(initialXPos);
const opacity = new Animated.Value(0);
const [change, setChange] = useState(true)
useEffect(() => {
const unsubscribe = navigation.addListener("focus", comingFromBot);
return unsubscribe;
}, []);
const comingFromBot = () => {
xPos.setValue(initialXPos);
opacity.setValue(0);
Animated.parallel([
Animated.spring(xPos, {
toValue: 100,
tension:3,
useNativeDriver: true,
}),
Animated.timing(opacity, {
toValue: 1,
duration: 1000,
useNativeDriver: true,
}),
]).start();
console.log("Animation's Fired!");
};
return (
<SafeAreaView style={{flex:1}}>
<Animated.View style={[
styles.container,
{ transform: [{ translateY: xPos }] },
{ opacity: opacity },
]}>
<Text style={{fontSize:30}}>{change ? "Home!" : "TIMMY!"}</Text>
</Animated.View>
{/* debug */}
<View style={styles.fire}>
<Button title="fire" onPress={() => comingFromBot()}/>
</View>
<View style={styles.change}>
<Button title="change" onPress={() => setChange(!change)}/>
</View>
</SafeAreaView>
);
}
const styles = StyleSheet.create({
container: { flex: 1, alignItems: 'center' },
fire:{position:"absolute", width:"100%", bottom:0},
change:{position:"absolute", width:"100%", bottom:48}
});
function SettingsScreen() {
return (
<View style={{ flex: 1, justifyContent: 'center', alignItems: 'center', padding:8 }}>
<Text>{"Go to Home tab again, and notice the animation.\n\nEXCEPT if we changed the text... WHY?\n\nBUT still works if we fire the animation with the button, but after still won't work on focus detection... HOW?\n\nWorks if you hot reload / hard reload the app... HELP?"}</Text>
</View>
);
}
const Tab = createBottomTabNavigator();
function MyTabs() {
return (
<Tab.Navigator>
<Tab.Screen name="Home" component={HomeScreen} />
<Tab.Screen name="Settings" component={SettingsScreen} />
</Tab.Navigator>
);
}
export default function App() {
return (
<NavigationContainer>
<MyTabs />
</NavigationContainer>
);
}
It doesn't work because you're not following the rules of hooks. The following things are wrong in your code:
You're using variables from outside in useEffect hook, but passing empty dependency array
The animated values need to be in a useState or useRef hook so that they aren't recreated every render
Then the animation just won't start, even though the callback from focus listener is called and executed (check logs)... WHY?
The problem is that the callback is recreated after state update on re-render, so the callback passed to the focus listener isn't the same as what's in render anymore. And since you also don't have your animated values in state/ref, new animated values are also created while the old focus listener is referring to the old values. Basically the log you see is from an old listener and not the new one.
You should use the official eslint plugin and ensure that you fix all the warnings/errors from it so that such problems are avoided.
To fix your code, do the following changes:
const [xPos] = React.useState(() => new Animated.Value(initialXPos));
const [opacity] = React.useState(() => new Animated.Value(0));
const [change, setChange] = useState(true)
useEffect(() => {
const unsubscribe = navigation.addListener("focus", comingFromBot);
return unsubscribe;
}, [navigation, comingFromBot]);
const comingFromBot = useCallback(() => {
xPos.setValue(initialXPos);
opacity.setValue(0);
Animated.parallel([
Animated.spring(xPos, {
toValue: 100,
tension:3,
useNativeDriver: true,
}),
Animated.timing(opacity, {
toValue: 1,
duration: 1000,
useNativeDriver: true,
}),
]).start();
console.log("Animation's Fired!");
}, [xPos, opacity]);
I basically added useCallback, fix the dependency arrays, and moved the animated values to useState hook.
I finally ended with this, thanks to #satya164: Snack
I also wish I read this in documentation before.
HomeScreen's code:
// HomeScreen.js
function HomeScreen({navigation}) {
const initialXPos = Dimensions.get("window").height * 0.5 ;
const xPos = useRef(new Animated.Value(initialXPos)).current
const opacity = useRef(new Animated.Value(0)).current
const [change, setChange] = useState(true)
useEffect(() => {
const unsubscribe = navigation.addListener("focus", comingFromBot);
return unsubscribe;
}, [navigation, comingFromBot]);
const comingFromBot = useCallback(() => {
xPos.setValue(initialXPos);
opacity.setValue(0);
Animated.parallel([
Animated.spring(xPos, {
toValue: 100,
tension:3,
useNativeDriver: true,
}),
Animated.timing(opacity, {
toValue: 1,
duration: 1000,
useNativeDriver: true,
}),
]).start();
console.log("Animation's Fired!");
}, [xPos, opacity, initialXPos ]);
return (
<SafeAreaView style={{flex:1}}>
<Animated.View style={[
styles.container,
{ transform: [{ translateY: xPos }] },
{ opacity: opacity },
]}>
<Text style={{fontSize:30}}>{change ? "Home!" : "TIMMY!"}</Text>
</Animated.View>
{/* debug */}
<View style={styles.fire}>
<Button title="fire" onPress={() => comingFromBot()}/>
</View>
<View style={styles.change}>
<Button title="change" onPress={() => setChange(!change)}/>
</View>
</SafeAreaView>
);
}
in my react-native project, I'm trying to use a callback when the State of LongPressGestureHandler becomes END | FAILED | CANCELLED. The function for this callback uses a global variable reactions that I receive as props. Even after this prop gets updated. The function somehow is using the old reactions. I'll be really thankful if you could explain what I'm doing wrong.
`
import React, { useState, useEffect } from 'react';
import { useNavigation } from '#react-navigation/native';
import { LongPressGestureHandler, State } from 'react-native-gesture-handler';
import { Vibration, View, Text } from 'react-native';
import Animated from 'react-native-reanimated';
import { useGestureHandler } from 'react-native-redash';
import useReaction from '../../../../../../services/dashboard/feed/components/reaction/api';
import assets from '../../../../../../assets';
import styles from './styles';
const {
Value, useCode, block, eq, call, onChange, cond, and, or, set
} = Animated;
export default ({
reactions,
emoji,
componentRefetch,
loggedInUsersId,
itemId,
counts,
}) => {
const { navigate } = useNavigation();
const [giveReaction, giveUnReaction] = useReaction(componentRefetch);
const reacted = reactions.find(
(reaction) => (reaction.user ? reaction.user.id === loggedInUsersId : false)
&& reaction.emoji === emoji
);
const [popAnim] = useState(new Value(1));
const onEmojiPress = async () => {
console.log('Emoji Pressed');
console.log(reactions.length);
if (reacted) {
await giveUnReaction(itemId, emoji, reacted.id);
} else {
await giveReaction(itemId, emoji);
}
};
const onEmojiLongPress = () => {
console.log('Emoji long Pressed');
Vibration.vibrate(1);
navigate(assets.strings.dashboard.feeds.reactantsPopup.NAME, {
reactions,
emoji,
});
};
const longPressState = new Value(State.UNDETERMINED);
const longPressGestureHandler = useGestureHandler({ state: longPressState });
const shouldScale = new Value(-1);
useCode(
() => block([
cond(
eq(longPressState, State.BEGAN),
set(shouldScale, 1)
),
cond(
eq(longPressState, State.FAILED),
call([], onEmojiPress)
),
cond(
or(
eq(longPressState, State.CANCELLED),
eq(longPressState, State.END),
),
call([], onEmojiLongPress)
),
]),
[]
);
return (
<LongPressGestureHandler {...longPressGestureHandler} minDurationMs={100}>
<Animated.View
style={{
...styles.icon,
borderColor: reacted
? assets.colors.appDefaults.primaryColor
: '#eeeeee',
backgroundColor: reacted
? assets.colors.appDefaults.primaryColorLight
: '#eeeeee',
}}
>
<Animated.View
style={{
transform: [
{ scale: popAnim },
{
translateY: popAnim.interpolate({
inputRange: [1, 1.3],
outputRange: [0, -2],
}),
},
],
}}
>
<Text
style={{
color: reacted ? assets.colors.appDefaults.primaryColor : 'black',
}}
>
{`${emoji ?? '👍'} `}
</Text>
</Animated.View>
<Text
style={{
color: reacted ? assets.colors.appDefaults.primaryColor : 'black',
}}
>
{`${counts[emoji]}`}
</Text>
</Animated.View>
</LongPressGestureHandler>
);
};
`
I found out that(by experimenting) useCode sends a set of instructions to native side, in order to change the set of instructions to latest prop update, we need to fire the useCode on prop update.
useCode(, [dependencyThatGetsUpdated])