I'm currently trying to fade through a series of images. Basically, I always want to display one image at a time, then animate its opacity from 1 to 0 and that of the next image in the series from 0 to 1, and so on. Basically something like this, which I've already implemented for the web in ReactJS and CSS animations:
However, I seem to keep getting stuck on using React Native's Animated library and refs. I've tried storing the opacity of all the images in an array which itself is contained in an useRef hook. Then, using Animated, I'm trying to perform two parallel animations which change the opacity of the current image index and that of the next index. This is what I've come up with:
export default function StartImageSwitcher() {
const images = Object.values(Images).map((img) => img.imageNoShadow);
const [currentImage, setCurrentImage] = useState(0);
const opacity = useRef<Animated.Value[]>([
new Animated.Value(1),
...Array(images.length - 1).fill(new Animated.Value(0)),
]).current;
useEffect(() => {
let nextImage = currentImage + 1;
if (nextImage >= images.length) nextImage = 0;
Animated.parallel([
Animated.timing(
opacity[currentImage],
{
toValue: 0,
duration: 2000,
useNativeDriver: true,
},
),
Animated.timing(
opacity[nextImage],
{
toValue: 1,
duration: 2000,
useNativeDriver: true,
},
),
]).start(() => {
setCurrentImage(nextImage);
});
}, [currentImage]);
images.map((image, index) => console.log(index, opacity[index]));
return (
<View style={styles.imageWrapper}>
{
images.map((image, index) => (
<Animated.Image style={{ ...styles.image, opacity: opacity[index] }} source={image} key={index} />
))
}
</View>
);
}
However, this doesn't seem to work at all. When mounted, it only shows the first image, then fades that one out and all the other images in and gets stuck there:
Anyone got an idea where I messed up? I feel like I'm not using the useRef() hook in combination with the Animated library like I'm supposed to.
Your solution is pretty clever, and it generally looks like it should work to me. Performance might take a hit though as the number of images increases. I thought of an alternate method: partition the images array and then use setInterval to alternate between two Animated.Images, which get their source from each array.
// from https://stackoverflow.com/questions/41932345/get-current-value-of-animated-value-react-native
const getAnimatedValue = (value: Animated.Value) => Number.parseInt(JSON.stringify(value));
export default function StartImageSwitcher() {
// partition images into two arrays
const images1 = [];
const images2 = [];
Object.values(Images).forEach((img, i) => {
(i % 1 === 0 ? images1 : images2).push(img.imageNoShadow)
});
// use refs for the indexes so values don't become stale in the setInterval closure
const images1Index = useRef(0);
const images2Index = useRef(0);
const image1Opacity = useRef(new Animated.Value(1)).current;
const image2Opacity = useRef(new Animated.Value(0)).current;
useEffect(() => {
const swapImageInterval = setInterval(() => {
const newImage1Opacity = getAnimatedValue(image1Opacity) === 1 ? 0 : 1;
const newImage2Opacity = getAnimatedValue(image2Opacity) === 1 ? 0 : 1;
Animated.parallel([
Animated.timing(image1Opacity, {
toValue: newImage1Opacity,
duration: 2000,
useNativeDriver: true,
}),
Animated.timing(image2Opacity, {
toValue: newImage2Opacity,
duration: 2000,
useNativeDriver: true,
}),
]).start(() => {
if (newImage1Opacity === 1) {
// image 2 is now faded out, so we can swap out its source
const nextIndex = images2Index.current === images2.length - 1 ? 0 : images2Index.current + 1;
images2Index.current = nextIndex;
} else {
// image 1 is faded out, so we can swap out its source
const nextIndex = images1Index.current === images1.length - 1 ? 0 : images1Index.current + 1;
images1Index.current = nextIndex;
}
})
}, 5000)
return () => clearInterval(swapImageInterval);
}, [images1Index, images2Index]);
return (
<View style={styles.imageWrapper}>
<Animated.Image style={{ ...styles.image, opacity: image1Opacity }} source={images1[images1Index.current]} />
<Animated.Image style={{ ...styles.image, opacity: image2Opacity }} source={images2[images2Index.current]} />
</View>
);
}
Related
I'm having the most bizarre problem on my project : I use a custom function to split the lines in spans while the components are mounted, here is the said function
function splitLines(container, opentag, closingtag, className) {
let spans = container.children,
top = 0,
tmp = "";
container.innerHTML = container.textContent.replace(
/\S+/g,
`<span class='${className} inlineBlock'>$&</span>`
);
for (let i = 0; i < spans.length; i++) {
let rect = spans[i].getBoundingClientRect().y;
if (top < rect) {
tmp += closingtag + opentag;
top = rect;
}
tmp += spans[i].outerHTML + " ";
}
container.innerHTML = tmp + closingtag;
}
export default splitLines
it's not totally working , indeed, the lines are not correctly splitted :
but when I change the page and then come back, the problem is solved (but if I refresh, we're back to square one)
Am I not triggering the function as soon as the component is mounted ? Is the function flawed ?
If you have any idea how I could resolve my issue please share them :)
useEffect(() => {
gsap.registerPlugin(ScrollTrigger);
splitLines(
presentationTextRef.current,
"<span class= 'hidden inlineBlock'>",
"</span>",
"presWords"
);
const presTL = gsap.timeline({
scrollTrigger: {
trigger: "#Presentation",
start: "top center",
end: "bottom center",
id: "Presentation",
toggleActions: "play reverse restart reverse",
},
});
const presWords = gsap.utils.toArray("#Presentation .presWords");
presTL.from(presWords, {
yPercent: 200,
stagger: 0.05,
});
return () => {
presTL.kill();
};
}, []);
I found a solution that is more fitted I think to the reactjs way of things (meaning : that doesn't manipulate the DOM directly).
Here it is
import React, { useEffect, useRef } from "react";
// gsap
import gsap, { Power3 } from "gsap";
export default function AnimatedWords(props) {
const { text, delay } = props;
const words = text.split(" ");
const wordsRef = useRef([]);
useEffect(() => {
gsap.to(wordsRef.current, {
y: 0,
stagger: 0.05,
delay: delay ? delay : 0,
ease: Power3.easeOut,
});
});
return (
<span>
{words.map((word, idx) => (
<span style={{ display: "inline-block", overflow: "hidden" }}>
<span
key={idx}
ref={(el) => (wordsRef.current[idx] = el)}
className="animatedLetter"
style={{
display: "inline-block",
transform: "translate(0, 200px)",
}}
>
{/* ' ' is used to create spaces between the words, it produces a non-breaking space*/}
{word}
</span>
</span>
))}
</span>
);
}
What do you think ? use refs in a map function is always such a hassle, I wonder if I should have used createRef ?
Thanks anyway !
I mapped an object array to create a tag element with the details being mapped onto the element. And then I created an animation so on render, the tags zoom in to full scale. However, I was wanting to take it to the next step and wanted to animate each tag individually, so that each tag is animated in order one after the other. To me, this seems like a common use of animations, so how could I do it from my example? Is there any common way to do this that I am missing?
import {LeftIconsRightText} from '#atoms/LeftIconsRightText';
import {LeftTextRightCircle} from '#atoms/LeftTextRightCircle';
import {Text, TextTypes} from '#atoms/Text';
import VectorIcon, {vectorIconTypes} from '#atoms/VectorIcon';
import styled from '#styled-components';
import * as React from 'react';
import {useEffect, useRef} from 'react';
import {Animated, ScrollView} from 'react-native';
export interface ICustomerFeedbackCard {
title: string;
titleIconName: string[];
tagInfo?: {feedback: string; rating: number}[];
}
export const CustomerFeedbackCard: React.FC<ICustomerFeedbackCard> = ({
title,
titleIconName,
tagInfo,
...props
}) => {
const FAST_ZOOM = 800;
const START_ZOOM_SCALE = 0.25;
const FINAL_ZOOM_SCALE = 1;
const zoomAnim = useRef(new Animated.Value(START_ZOOM_SCALE)).current;
/**
* Creates an animation with a
* set duration and scales the
* size by a set factor to create
* a small zoom effect
*/
useEffect(() => {
const zoomIn = () => {
Animated.timing(zoomAnim, {
toValue: FINAL_ZOOM_SCALE,
duration: FAST_ZOOM,
useNativeDriver: true,
}).start();
};
zoomIn();
}, [zoomAnim]);
/**
* Sorts all tags from highest
* to lowest rating numbers
* #returns void
*/
const sortTags = () => {
tagInfo?.sort((a, b) => b.rating - a.rating);
};
/**
* Displays the all the created tags with
* the feedback text and rating number
* #returns JSX.Element
*/
const displayTags = () =>
tagInfo?.map((tag) => (
<TagContainer
style={[
{
transform: [{scale: zoomAnim}],
},
]}>
<LeftTextRightCircle feedback={tag.feedback} rating={tag.rating} />
</TagContainer>
));
return (
<CardContainer {...props}>
<HeaderContainer>
<LeftIconsRightText icons={titleIconName} textDescription={title} />
<Icon name="chevron-right" type={vectorIconTypes.SMALL} />
</HeaderContainer>
<ScrollOutline>
<ScrollContainer>
{sortTags()}
{displayTags()}
</ScrollContainer>
</ScrollOutline>
<FooterContainer>
<TextFooter>Most recent customer compliments</TextFooter>
</FooterContainer>
</CardContainer>
);
};
And here is the object array for reference:
export const FEEDBACKS = [
{feedback: 'Good Service', rating: 5},
{feedback: 'Friendly', rating: 2},
{feedback: 'Very Polite', rating: 2},
{feedback: 'Above & Beyond', rating: 1},
{feedback: 'Followed Instructions', rating: 1},
{feedback: 'Speedy Service', rating: 3},
{feedback: 'Clean', rating: 4},
{feedback: 'Accommodating', rating: 0},
{feedback: 'Enjoyable Experience', rating: 10},
{feedback: 'Great', rating: 8},
];
Edit: I solved it by replacing React-Native-Animated and using an Animated View and instead using Animatable and using an Animatable which has built in delay. Final solution:
const displayTags = () =>
tagInfo?.map((tag, index) => (
<TagContainer animation="zoomIn" duration={1000} delay={index * 1000}>
<LeftTextRightCircle feedback={tag.feedback} rating={tag.rating} />
</TagContainer>
));
Here is a gif of the animation
This is an interesting problem. A clean way you could approach this problem is to develop a wrapper component, DelayedZoom that will render its child component with a delayed zoom. This component would take a delay prop that you can control to add a delay for when the component should begin animation.
function DelayedZoom({delay, speed, endScale, startScale, children}) {
const zoomAnim = useRef(new Animated.Value(startScale)).current;
useEffect(() => {
const zoomIn = () => {
Animated.timing(zoomAnim, {
delay: delay,
toValue: endScale,
duration: speed,
useNativeDriver: true,
}).start();
};
zoomIn();
}, [zoomAnim]);
return (
<Animated.View
style={[
{
transform: [{scale: zoomAnim}],
},
]}>
{children}
</Animated.View>
);
}
After this, you can use this component as follows:
function OtherScreen() {
const tags = FEEDBACKS;
const FAST_ZOOM = 800;
const START_ZOOM_SCALE = 0.25;
const FINAL_ZOOM_SCALE = 1;
function renderTags() {
return tags.map((tag, idx) => {
const delay = idx * 10; // play around with this. Main thing is that you get a sense for when something should start to animate based on its index, idx.
return (
<DelayedZoom
delay={delay}
endScale={FINAL_ZOOM_SCALE}
startScale={START_ZOOM_SCALE}
speed={FAST_ZOOM}>
{/** whatever you want to render with a delayed zoom would go here. In your case it may be TagContainer */}
<TagContainer>
<LeftTextRightCircle feedback={tag.feedback} rating={tag.rating} />
</TagContainer>
</DelayedZoom>
);
});
}
return <View>{renderTags()}</View>;
}
I hope this helps to point you in the right direction!
Also some helpful resources:
Animation delays: https://animationbook.codedaily.io/animated-delay/
Demo
It is a bit of work to implement this, I didn't have your components to try it out so I have created a basic implementation, I hope this will help
import React, { useEffect, useRef, useState } from "react";
import { StyleSheet, Text, View, Animated } from "react-native";
const OBJ = [{ id: 1 }, { id: 2 }, { id: 3 }];
const Item = ({ data, addValue }) => {
const zoomAnim = useRef(new Animated.Value(0)).current;
useEffect(() => {
const zoomIn = () => {
Animated.timing(zoomAnim, {
toValue: 1,
duration: 500,
useNativeDriver: true
}).start(() => {
addValue();
});
};
zoomIn();
}, [zoomAnim]);
return (
<View>
<Animated.View
ref={zoomAnim}
style={[
{
transform: [{ scale: zoomAnim }]
}
]}
>
<Text style={styles.text}>{data}</Text>
</Animated.View>
</View>
);
};
function App() {
const [state, setState] = useState([OBJ[0]]);
const addValue = () => {
const currentId = state[state.length - 1].id;
if (OBJ[currentId]) {
const temp = [...state];
temp.push(OBJ[currentId]);
setState(temp);
}
};
return (
<View style={styles.app}>
{state.map((item) => {
return <Item data={item.id} key={item.id} addValue={addValue} />;
})}
</View>
);
}
const styles = StyleSheet.create({
text: {
fontSize: 20
}
});
export default App;
Basically, I am adding an element to the state at the end of the previous animation, one thing to note is that the key is very important, and don't use the index as a key. instead of Ids you might want to add any other value that is sorted or maybe link an item by passing the id of the previous item.
ADDING A SOLUTION USING REANIMATED AND MOTI
There is this library which you can use moti(https://moti.fyi/) it will work with reanimated, so you need to add reanimated too. Before using Reanimated you must consider that your normal chrome dev tools for that particular application will stop working with reanimated 2.0 and above you can use flipper though.
coming to the solution.
import { View as MotiView } from 'moti';
...
const displayTags = () =>
tagInfo?.map((tag, index) => (
<MotiView
key = {tag.id}
from={{ translateY: 20, opacity: 0 }}
animate={{ translateY: 0, opacity: 1 }}
transition={{ type: 'timing' }}
duration={500}
delay={index * 150}>
<TagContainer
style={[
{
transform: [{scale: zoomAnim}],
},
]}>
<LeftTextRightCircle feedback={tag.feedback} rating={tag.rating} />
</TagContainer>
</MotiView>
));
...
That's it, make sure to use a proper key, don't use index as key.
Side Note: If you are doubtful that sould you use reanimated or not, just go through https://docs.swmansion.com/react-native-reanimated/docs/ this page. Using Moti you can have really cool animation easily also if you reanimated version 2.3.0-alpha.1 then you need not to use Moti but as it is alpha version so it is not advisable to use in production you can wait for its stable release too.
I have a component that displays a grid with images. Upon clicking a specific image, a modal pops up with buttons for navigation on either side of the image. To display the images, i map through the docs array and access the doc.url. There is also an onClick which passes the doc.url and index of the item which then updates the state of selectedImg. Here is the ImageGrid.js component code:
const ImageGrid = ({ setSelectedImg, imageIndex, setImageIndex }) => {
const { docs } = useFirestore('images');
console.log('current docs', docs)
const clickHandler = (url, index) => {
setSelectedImg(url);
setImageIndex(index);
}
return (
<div className='img-grid'>
{ docs && docs.map((doc, index) => (
<motion.div className='img-wrap' key={doc.id}
layout
whileHover={{ opacity: 1 }}
whileTap={{ scale: 0.9 }}
onClick={() => clickHandler(doc.url, index)}
>
<motion.img src={doc.url} alt='uploaded pic'
initial={{ opacity: 0 }}
animate={{ opacity: 1}}
transition={{ delay: 1 }}
/>
</motion.div>
)) }
</div>
)
}
I then have a Modal.js component with two onClick functions for previous and next buttons next to the modal image. See as follows:
const Modal = ({ selectedImg, setSelectedImg, imageIndex, setImageIndex }) => {
const { docs } = useFirestore('images');
const handleClick = (e) => {
if(e.target.classList.contains('backdrop'))
setSelectedImg(null);
}
// Navigation
const prevImage = () => {
setImageIndex(prev => prev - 1);
setSelectedImg(docs[imageIndex].url);
}
const nextImage = () => {
setImageIndex(prev => prev + 1);
setSelectedImg(docs[imageIndex].url);
}
return (
<motion.div className='backdrop' onClick={handleClick}
initial={{ opacity: 0}}
animate={{ opacity: 1}}
>
<FaChevronCircleLeft className='modal-navigation left' onClick={prevImage} />
<motion.img src={selectedImg} alt='enlarged pic'
initial={{ y: '-100vh'}}
animate={{ y: 0 }}
/>
<FaChevronCircleRight className='modal-navigation right' onClick={nextImage}/>
</motion.div>
)
}
This does work but the problem is that once you select an image from the grid and the modal appears for the first time, if you click either navigation button, nothing happens. Then on the second click, it starts to work. From my understanding, this is because the imageIndex state is updating after the button has been clicked, thefore useEffect needs to be used.
I tried to use useEffect in Modal.js where it would only work if [imageIndex] was a dependency. I kept receiving an error because setImageIndex was being used even before the Modal navigation was used.
// Navigation
const prevImage = () => {
setImageIndex(prev => prev - 1);
}
const nextImage = () => {
setImageIndex(prev => prev + 1);
}
useEffect(() => {
setSelectedImg(docs[imageIndex].url);
}, [imageIndex])
How can I pass the index of the current image and have it correspond with the navigation buttons when they're clicked?
Thanks in advance.
I worked it out.
In the Modal.js, I added another piece of state for when the navigation button is clicked. It starts off as false and when the left or right arrow is clicked, the onClick function sets the buttonClicked state to true; The onClick also sets the imageIndex - 1 or + 1 using the previous props.
Then, i added a useEffect function that basically said, every time the button was clicked, setSelectedImg to the image with the new imageIndex. And then set the buttonClicked back to false. [buttonClicked] is the dependency. See code:
const [ buttonClicked, setButtonClicked ] = useState(false);
const handleClick = (e) => {
if(e.target.classList.contains('backdrop'))
setSelectedImg(null);
}
// Navigation
const prevImage = () => {
setImageIndex(prev => prev - 1);
setButtonClicked(true);
}
const nextImage = () => {
setImageIndex(prev => prev + 1);
setButtonClicked(true);
}
useEffect(() => {
if (buttonClicked){
setSelectedImg(docs[imageIndex].url)
setButtonClicked(false);
};
}, [buttonClicked])
With the click event handlers, no need to use if statements for imageIndex arriving at a negative number or more than the amount of images in the array.
I just used inline if statements to show or hide the buttons:
{ imageIndex !== 0 && <FaChevronCircleLeft className='modal-navigation left' onClick={prevImage} /> }
{ imageIndex !== docs.length - 1 && <FaChevronCircleRight className='modal-navigation right' onClick={nextImage}/> }
Hopefully, the title isn't too ambiguous, but I am struggling how to summarise my issue in just a few words.
What I am attempting to achieve is the one image with appear for 1.5 seconds, the opacity of image will go from 0.3 to 1 and a string of text "Completed" appears below the image, and will then rotate and move to the next image where it will have the same outcome, constantly looping through each image.
The issue is, it only seems to rotate once. It will update the styles and add the text below the image but fail to rotate.
I have a 3d carousel component that rotates the items within an array logos when a function is called rotate().
const [rotateDeg, setRotateDeg] = useState(0);
const [currentIndex, setCurrentIndex] = useState(0);
const rotate = () => {
const maxIndex = logos.length - 1;
const incrementIndex = currentIndex + 1;
const newIndex = incrementIndex > maxIndex ? 0 : incrementIndex;
setCurrentIndex(newIndex);
setRotateDeg(rotateDeg - 360 / logos.length);
};
return (
<Container>
<Carousel ref={carousel} logosLength={logos.length} rotateDeg={rotateDeg}>
{logos.map((item, index) => {
const { key } = item;
return (
<Item
key={key}
index={index}
logosLength={logos.length}
currentIndex={currentIndex}
>
<LoadingSpinLogo
item={item}
delay={1500 * (index + 1)}
rotate={() => rotate()}
key={key}
isCurrent={currentIndex === index}
/>
</Item>
);
})}
</Carousel>
</Container>
);
The component LoadingSpinLogo is where I seem to be having an issue.
I have tried to pass different dependencies into the useEffect callback but it seems to cause weird issues with the delay.
const LoadingSpinLogo = ({ item, rotate, delay, isCurrent }) => {
const [completed, setCompleted] = useState(false);
const [divStyle, setDivStyle] = useState({ opacity: 0.3 });
const props = useSpring(divStyle);
const customStyles = {
height: "78px",
width: "115px",
backgroundRepeat: "no-repeat",
backgroundPosition: "center center",
display: "block",
margin: "auto"
};
const updateCompleted = () => {
setCompleted(true);
setDivStyle({ opacity: 1, from: { opacity: 0.3 } });
rotate();
};
useEffect(() => {
const timeoutID = setTimeout(function() {
updateCompleted();
}, delay);
return () => {
// Clean up the subscription
window.clearInterval(timeoutID);
};
}, []);
return (
<LoadingSpinContainer>
<animated.div style={props}>
<ImageServer png={item.url} customStyles={customStyles} />
</animated.div>
{completed ? "Completed" : null}
</LoadingSpinContainer>
);
};
Here is a CodeSanbox of my components.
Any idea where I am going wrong here?
Any help would be greatly appreciated.
Removing the [] in your useEffect function seems to works fine for me
useEffect(() => {
const timeoutID = setTimeout(function() {
updateCompleted();
}, delay);
return () => {
// Clean up the subscription
window.clearInterval(timeoutID);
};
}, );
My issue if the following:
The duration of the video is 6.357 seconds. The progress.currentTime only goes to 6.154 and stops.
Since that this.state.progress = 0.97 (not 1.0). As an outcome my ProgressBar doesn't go to the end. It stops on 0.97 position and onProgressPress() doesn't work.
Could someone please assist?
Here is my code:
export default class VideoComp extends Component {
state = {
paused: false,
progress: 0,
duration: 0
}
onProgressPress = (e) => {
const position = e.nativeEvent.locationX;
const progress = (position / 250) * this.state.duration;
this.player.seek(progress);
}
onMainButtonPress = () => {
if(this.state.progress >= 1) {
this.player.seek(0);
};
this.setState(state => {
return {
paused: !state.paused
}
})
}
handleEnd = () => {
this.setState({
paused: true
})
}
handleProgress = (progress) => {
this.setState({
progress: progress.currentTime / this.state.duration
});
}
handleLoad = (meta) => {
this.setState({
duration: meta.duration
})
}
render() {
const { width } = Dimensions.get('window');
const height = width * 0.5625;
return(
<View style = {styles.videoWrapper}>
<Video
source = {{uri: this.props.videoURL}}
ref = {ref => this.player = ref}
style = {{width: '100%', height}}
paused = {this.state.paused}
resizeMode = 'contain'
onLoad = {this.handleLoad}
onProgress = {this.handleProgress}
onEnd = {this.handleEnd}
/>
<View style = {styles.controls}>
<TouchableWithoutFeedback onPress = {this.onMainButtonPress}>
<IconSimpleLine name = {!this.state.paused ? 'control-pause' : 'control-play'} color = {text} size = {20}/>
</TouchableWithoutFeedback>
<TouchableWithoutFeedback onPress = {this.onProgressPress}>
<View>
<ProgressBar
progress = {this.state.progress}
width = {250}
height = {5}
color = {text}
borderColor = {text}
unfilledColor = 'rgba(255, 255, 255, 0.3)'
/>
</View>
</TouchableWithoutFeedback>
<Text style = {styles.duration}>
{secondsToTime(Math.floor(this.state.progress * this.state.duration))}
</Text>
</View>
</View>
)
}
}
UPDATE
I tried the next:
handleProgress = (progress) => {
this.setState({
progress: Math.floor(progress.currentTime) / this.state.duration
});
}
handleLoad = (meta) => {
this.setState({
duration: Math.floor(meta.duration)
})
}
The ProgressBar line now goes to the very end but it moves by a second. I mean, it moves a bit, stops on one second, moves a bit farther, stops on one second and so on. It doesn't move smoothly (each millisecond).
But it's not the correct solution.
the Video component has an onEnd prop. use that to update your state to 100%
Try to user parseFloat() for calculation purpose.
in your question, I don't know which value is coming in float or in a fraction number but just for example use as this.
this.setState({
progress: parseFloat(progress.currentTime / this.state.duration)
});
and
const progress = parseFloat(position / 250)) * this.state.duration;
Hope it will help you.