RN自定義元件封裝 - 播放類似PPT動畫

SmallStoneSK發表於2018-04-07

原文地址:https://github.com/SmallStoneSK/Blog/issues/1

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.Image
          style={[styles.balloonImage, this.balloonStyle]}
          source={require('../../pic/demo1/balloon.png')}
          />
      </View>
    );
  }
}
複製程式碼
RN自定義元件封裝 - 播放類似PPT動畫

當然,這是再簡單不過的基礎動畫了。。。如果我們讓這裡的氣球一開始最好先是從底部的一個點放大,並且有一個漸入的效果,完了之後再往上飄,這該怎麼實現呢?於是程式碼變成了這樣:

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
    });
  }

  ...
}
複製程式碼
RN自定義元件封裝 - 播放類似PPT動畫

插句話:在動畫銜接的時候,還是糾結了一下。因為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, rotate
    this.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)

  1. name是一個標誌符,迴圈動畫之間不能重名。_playCyclicAnimation和_stopCyclicAnimation都是通過name來匹配相應animation並呼叫的。
  2. 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封裝好了。先找個素材練練手吧~可是,找個啥呢?“叮”,只見手機上挖財的一個提醒亮了起來。嘿嘿,就你了,挖財的簽到頁面真的很適合(沒有做廣告。。。)效果圖如下:

RN自定義元件封裝 - 播放類似PPT動畫

渲染元素的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. 後續思考

  1. 講道理,現在這個AnimatedContainer能夠建立的動畫還是稍顯單薄,僅包含了最基礎的一些基本操作。不過,這也說明了還有很大的擴充套件空間,根據_createCyclicAnimationPromise和_createAnimationPromise這兩個函式,可以自由地封裝我們想要的各種複雜動畫效果。而呼叫方就只要通過promise的all和then方法來控制動畫順序就行了。個人感覺,甚至有那麼一丁點在使用jQuery。。。

  2. 除此之外,還有一個問題就是:由於這些元素都是絕對定位佈局的,那這些元素的x, y座標值怎麼辦?在有視覺標註稿的前提下,那感覺還可行。但是一旦元素的數量上去了,那在使用上還是有點麻煩的。。。所以啊,要是有個什麼工具能夠真的像做PPT一樣,支援元素拖拽並實時獲得元素的座標,那就真的是文美了。。。。。。

老規矩,本文程式碼地址:github.com/SmallStoneS…

相關文章