1. 前言
近日,被安排做一個開場動畫的任務。雖然RN提供了Animated來自定義動畫,但是本次動畫中的元素頗多,交互甚煩。。。在完成任務的同時,發現很多步驟其實是重復的,于是封裝了一個小組件記錄一下,分享給大家。
2. 初步嘗試
分析一下:雖然這次的動畫需求步驟挺多的,但是把每一步動畫拆解成step1, step2, step3, step4... 講道理應該還是能夠實現的吧?嗯,用Animated.Value()創建值,然后再配上Animated.timing應該就好了。
想到這,反手就是創建一個demo.js,先做個往上飄的氣球試試先吧。
export class Demo1 extends PureComponent {constructor(props) {super(props);}componentWillMount() {this._initAnimation();}componentDidMount() {this._playAnimation();}_initAnimation() {this.topAnimatedValue = new Animated.Value(400);this.balloonStyle = {position: 'absolute',left: 137.5,top: this.topAnimatedValue.interpolate({inputRange: [-999999, 999999],outputRange: [-999999, 999999]})};}_playAnimation() {Animated.timing(this.topAnimatedValue, {toValue: 200,duration: 1500}).start();}render() {return (<View style={styles.demoContainer}><Animated.Imagestyle={[styles.balloonImage, this.balloonStyle]}source={require('../../pic/demo1/balloon.png')}/></View>);}
}
當然,這是再簡單不過的基礎動畫了。。。如果我們讓這里的氣球一開始最好先是從底部的一個點放大,并且有一個漸入的效果,完了之后再往上飄,這該怎么實現呢?于是代碼變成了這樣:
export class Demo1 extends PureComponent {..._interpolateAnimation(animatedValue, inputRange, outputRange) {return animatedValue.interpolate({inputRange, outputRange});}_initAnimation() {this.opacityAnimatedValue = new Animated.Value(0);this.scaleAnimatedValue = new Animated.Value(0);this.topAnimatedValue = new Animated.Value(400);this.balloonStyle = {position: 'absolute',left: 137.5,opacity: this._interpolateAnimation(this.opacityAnimatedValue, [0, 1], [0, 1]),top: this._interpolateAnimation(this.topAnimatedValue, [-999999, 999999], [-999999, 999999]),transform:[{scale: this._interpolateAnimation(this.scaleAnimatedValue, [0, 1], [0, 1])}]};}_playAnimation() {Animated.sequence([this.step1(),this.step2()]).start();}step1() {return Animated.parallel([Animated.timing(this.opacityAnimatedValue, {toValue: 1,duration: 500}),Animated.timing(this.scaleAnimatedValue, {toValue: 1,duration: 500})]);}step2() {return Animated.timing(this.topAnimatedValue, {toValue: 200,duration: 1500});}...
}
插句話:在動畫銜接的時候,還是糾結了一下。因為Animated提供的方法還是比較多的,這里用到了sequence、parallel,分別可以讓動畫順序執行和并行。除此之外,animtaion的start方法是支持傳入一個回調函數的,表示在當前動畫運行結束的時候會觸發這個回調。所以我們還可以這么寫:
_playAnimation() {this.step1(() => this.step2()); // 不同之處1:step2作為step1動畫結束之后的回調傳入}step1(callback) {Animated.parallel([Animated.timing(this.opacityAnimatedValue, {toValue: 1,duration: 500}),Animated.timing(this.scaleAnimatedValue, {toValue: 1,duration: 500})]).start(() => {callback && callback(); // 不同之處2:調用傳入的回調});}step2() {Animated.timing(this.topAnimatedValue, {toValue: 200,duration: 1500}).start();}
雖然同樣能夠實現效果,但是還是覺得這種方式不是很舒服,所以棄之。。。
到這里,我們已經對這個氣球做了漸變、放大、平移等3項操作。但是,如果有5個氣球,還有其他各種元素又該怎么辦呢?這才一個氣球我們就已經用了opacityAnimatedValue,scaleAnimatedValue,topAnimatedValue三個變量來控制,更多的動畫元素那直就gg,不用下班了。。。
3. 實現升級
說實話,要做這么個東西,怎么就那么像在做一個PPT呢。。。
“屏幕就好比是一張PPT背景圖;每一個氣球就是PPT上的元素;你可以通過拖動鼠標來擺放各個氣球,我可以用絕對定位來確定每個氣球的位置;至于動畫嘛,剛才的demo已經證明并不難實現,無非就是控制透明度、xy坐標、縮放比例罷了。”
想到這,心中不免一陣竊喜。哈哈,有路子了,可以對PPT上的這些元素封裝一個通用的組件,然后提供常用的一些動畫方法,剩下的事情就是調用這些動畫方法組裝成更復雜的動畫了。新建一個PPT:“出現、飛躍、淡化、浮入、百葉窗、棋盤。。。”看著這令人眼花繚亂的各種動畫,我想了下:嗯,我還是從最簡單的做起吧。。。
首先,我們可以將動畫分成兩種:一次性動畫和循環動畫。
其次,作為一個元素,它可以用作動畫的屬性主要包括有:opacity, x, y, scale, angle等(這里先只考慮了二維平面的,其實還可以延伸擴展成三維立體的)。
最后,基本動畫都可以拆解為這幾種行為:出現/消失、移動、縮放、旋轉。
3.1 一次性動畫
想到這,反手就是創建一個新文件,代碼如下:
// Comstants.js
export const INF = 999999999;// Helper.js
export const Helper = {sleep(millSeconds) {return new Promise(resolve => {setTimeout(() => resolve(), millSeconds);});},animateInterpolate(animatedValue, inputRange, outputRange) {if(animatedValue && animatedValue.interpolate) {return animatedValue.interpolate({inputRange, outputRange});}}
};// AnimatedContainer.js
import {INF} from "./Constants";
import {Helper} from "./Helper";export class AnimatedContainer extends PureComponent {constructor(props) {super(props);}componentWillMount() {this._initAnimationConfig();}_initAnimationConfig() {const {initialConfig} = this.props;const {opacity = 1, scale = 1, x = 0, y = 0, rotate = 0} = initialConfig;// create animated values: opacity, scale, x, y, rotatethis.opacityAnimatedValue = new Animated.Value(opacity);this.scaleAnimatedValue = new Animated.Value(scale);this.rotateAnimatedValue = new Animated.Value(rotate);this.xAnimatedValue = new Animated.Value(x);this.yAnimatedValue = new Animated.Value(y);this.style = {position: 'absolute',left: this.xAnimatedValue,top: this.yAnimatedValue,opacity: Helper.animateInterpolate(this.opacityAnimatedValue, [0, 1], [0, 1]),transform: [{scale: this.scaleAnimatedValue},{rotate: Helper.animateInterpolate(this.rotateAnimatedValue, [-INF, INF], [`-${INF}rad`, `${INF}rad`])}]};}show() {}hide() {}scaleTo() {}rotateTo() {}moveTo() {}render() {return (<Animated.View style={[this.style, this.props.style]}>{this.props.children}</Animated.View>);}
}AnimatedContainer.defaultProps = {initialConfig: {opacity: 1,scale: 1,x: 0,y: 0,rotate: 0}
};
第一步的骨架這就搭好了,簡單到自己都難以置信。。。接下來就是具體實現每一個動畫的方法了,先拿show/hide開刀。
show(config = {opacity: 1, duration: 500}) {Animated.timing(this.opacityAnimatedValue, {toValue: config.opacity,duration: config.duration}).start();
}hide(config = {opacity: 0, duration: 500}) {Animated.timing(this.opacityAnimatedValue, {toValue: config.opacity,duration: config.duration}).start();
}
試了一下,簡直是文美~
但是!仔細一想,卻有個很嚴重的問題,這里的動畫銜接該怎處理?要想做一個先show,然后過1s之后再hide的動畫該怎么實現?貌似又回到了一開始考慮過的問題。不過這次,我卻是用Promise來解決這個問題。于是代碼又變成了這樣:
sleep(millSeconds) {return new Promise(resolve => setTimeout(() => resolve(), millSeconds));
}show(config = {opacity: 1, duration: 500}) {return new Promise(resolve => {Animated.timing(this.opacityAnimatedValue, {toValue: config.opacity,duration: config.duration}).start(() => resolve());});
}hide(config = {opacity: 0, duration: 500}) {return new Promise(resolve => {Animated.timing(this.opacityAnimatedValue, {toValue: config.opacity,duration: config.duration}).start(() => resolve());});
}
現在我們再來看剛才的動畫,只需這樣就能實現:
playAnimation() {this.animationRef.show() // 先出現.sleep(1000) // 等待1s.then(() => this.animationRef.hide()); // 消失
}
甚至還可以對createPromise這個過程再封裝一波:
_createAnimation(animationConfig = []) {const len = animationConfig.length;if (len === 1) {const {animatedValue, toValue, duration} = animationConfig[0];return Animated.timing(animatedValue, {toValue, duration});} else if (len >= 2) {return Animated.parallel(animationConfig.map(config => {return this._createAnimation([config]);}));}
}_createAnimationPromise(animationConfig = []) {return new Promise(resolve => {const len = animationConfig.length;if(len <= 0) {resolve();} else {this._createAnimation(animationConfig).start(() => resolve());}});
}opacityTo(config = {opacity: .5, duration: 500}) {return this._createAnimationPromise([{toValue: config.opacity,duration: config.duration,animatedValue: this.opacityAnimatedValue}]);
}show(config = {opacity: 1, duration: 500}) {this.opacityTo(config);
}hide(config = {opacity: 0, duration: 500}) {this.opacityTo(config);
}
然后,我們再把其他的幾種基礎動畫(scale, rotate, move)實現也加上:
scaleTo(config = {scale: 1, duration: 1000}) {return this._createAnimationPromise([{toValue: config.scale,duration: config.duration,animatedValue: this.scaleAnimatedValue}]);
}rotateTo(config = {rotate: 0, duration: 500}) {return this._createAnimationPromise([{toValue: config.rotate,duration: config.duration,animatedValue: this.rotateAnimatedValue}]);
}moveTo(config = {x: 0, y: 0, duration: 1000}) {return this._createAnimationPromise([{toValue: config.x,duration: config.duration,animatedValue: this.xAnimatedValue}, {toValue: config.y,duration: config.duration,animatedValue: this.yAnimatedValue}]);
}
3.2 循環動畫
一次性動畫問題就這樣解決了,再來看看循環動畫怎么辦。根據平時的經驗,一個循環播放的動畫一般都會這么寫:
roll() {this.rollAnimation = Animated.timing(this.rotateAnimatedValue, {toValue: Math.PI * 2,duration: 2000});this.rollAnimation.start(() => {this.rotateAnimatedValue.setValue(0);this.roll();});
}play() {this.roll();
}stop() {this.rollAnimation.stop();
}
沒錯,就是在一個動畫的start中傳入回調,而這個回調就是遞歸地調用播放動畫本身這個函數。那要是對應到我們要封裝的這個組件,又該怎么實現呢?
思考良久,為了保持和一次性動畫API的一致性,我們可以給animatedContainer新增了以下幾個函數:
export class AnimatedContainer extends PureComponent {...constructor(props) {super(props);this.cyclicAnimations = {};}_createCyclicAnimation(name, animations) {this.cyclicAnimations[name] = Animated.sequence(animations);}_createCyclicAnimationPromise(name, animations) {return new Promise(resolve => {this._createCyclicAnimation(name, animations);this._playCyclicAnimation(name);resolve();});} _playCyclicAnimation(name) {const animation = this.cyclicAnimations[name];animation.start(() => {animation.reset();this._playCyclicAnimation(name);});}_stopCyclicAnimation(name) {this.cyclicAnimations[name].stop();}...
}
其中,_createCyclicAnimation,_createCyclicAnimationPromise是和一次性動畫的API對應的。但是,不同點在于傳入的參數發生了很大的變化:animationConfg -> (name, animations)
- name是一個標志符,循環動畫之間不能重名。_playCyclicAnimation和_stopCyclicAnimation都是通過name來匹配相應animation并調用的。
- animations是一組動畫,其中每個animation是調用_createAnimation生成的。由于循環動畫可以是由一組一次性動畫組成的,所以在_createCyclicAnimation中也是直接調用了Animated.sequence,而循環播放的實現就在于_playCyclicAnimation中的遞歸調用。
到這里,循環動畫基本也已經封裝完畢。再來封裝兩個循環動畫roll(旋轉),blink(閃爍)試試:
blink(config = {period: 2000}) {return this._createCyclicAnimationPromise('blink', [this._createAnimation([{toValue: 1,duration: config.period / 2,animatedValue: this.opacityAnimatedValue}]),this._createAnimation([{toValue: 0,duration: config.period / 2,animatedValue: this.opacityAnimatedValue}])]);
}stopBlink() {this._stopCyclicAnimation('blink');
}roll(config = {period: 1000}) {return this._createCyclicAnimationPromise('roll', [this._createAnimation([{toValue: Math.PI * 2,duration: config.period,animatedValue: this.rotateAnimatedValue}])]);
}stopRoll() {this._stopCyclicAnimation('roll');
}
4. 實戰
忙活了大半天,總算是把AnimatedContainer封裝好了。先找個素材練練手吧~可是,找個啥呢?“叮”,只見手機上挖財的一個提醒亮了起來。嘿嘿,就你了,挖財的簽到頁面真的很適合(沒有做廣告。。。)效果圖如下:
渲染元素的render代碼就不貼了,但是我們來看看動畫播放的代碼:
startOpeningAnimation() {// 簽到(一次性動畫)Promise.all([this._header.show(),this._header.scaleTo({scale: 1}),this._header.rotateTo({rotate: Math.PI * 2})]).then(() => this._header.sleep(100)).then(() => this._header.moveTo({x: 64, y: 150})).then(() => Promise.all([this._tips.show(),this._ladder.sleep(150).then(() => this._ladder.show())])).then(() => Promise.all([this._today.show(),this._today.moveTo({x: 105, y: 365})]));// 星星閃爍(循環動畫)this._stars.forEach(item => item.sleep(Math.random() * 2000).then(() => item.blink({period: 1000})));
}
光看代碼,是不是就已經腦補整個動畫了~ 肥腸地一目了然,真的是美滋滋。
5. 后續思考
- 講道理,現在這個AnimatedContainer能夠創建的動畫還是稍顯單薄,僅包含了最基礎的一些基本操作。不過,這也說明了還有很大的擴展空間,根據_createCyclicAnimationPromise和_createAnimationPromise這兩個函數,可以自由地封裝我們想要的各種復雜動畫效果。而調用方就只要通過promise的all和then方法來控制動畫順序就行了。個人感覺,甚至有那么一丁點在使用jQuery。。。
- 除此之外,還有一個問題就是:由于這些元素都是絕對定位布局的,那這些元素的x, y坐標值怎么辦?在有視覺標注稿的前提下,那感覺還可行。但是一旦元素的數量上去了,那在使用上還是有點麻煩的。。。所以啊,要是有個什么工具能夠真的像做PPT一樣,支持元素拖拽并實時獲得元素的坐標,那就真的是文美了。。。。。。
老規矩,本文代碼地址:https://github.com/SmallStoneSK/AnimatedContainer