RN Custom Component Package-Plays PPT-like Animation

  Front end, react-native, react.js

1. Preface

Recently, I was assigned to do an opening animation task. Although RN provides Animated self-defined animation, there are many elements in this animation and the interaction is very annoying. . . While completing the task, I found that many steps were actually repeated, so I packaged a small component to record it and share it with everyone.

2. Preliminary attempt

Analysis: Although there are many steps required for animation this time, it should still be possible to disassemble each step of animation into step1, step2, step3, step4, etc. Well, create a value with Animated.Value (), and then match it with Animated.timing

Considering this, backhand is to create a demo.js, first try to make a balloon floating upwards.

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

balloon.gif

Of course, this is the simplest basic animation. . . If we let the balloon here first enlarge from a point at the bottom and have a gradual effect, then float upwards after it is finished, how will this be achieved? So the code looks like this:

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

  ...
}

balloon-2.gif

Insert a sentenceWhen the animation is connected, I still struggle with it. Because Animated still provides many methods, sequence and parallel are used here to allow animation to be executed in sequence and parallel respectively. In addition, animtaion’s start method supports passing in a callback function, indicating that the callback will be triggered at the end of the current animation run. So we can also write like this:

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

Although the effect can also be achieved, I still feel this way is not very comfortable, so I abandoned it. . .

Here, we have done three operations on the balloon: gradual change, enlargement and translation. But what if there are five balloons and other elements? This is just one balloon. We have already used three variables, opacityAnimatedValue, scaleAnimatedValue and topAnimatedValue, to control it. More animation elements will be gg without leaving work. . .

3. Upgrade

To tell the truth, to do such a thing, how can it be so like doing a PPT? . .

“The screen is like a PPT background map; Each balloon is an element on PPT. You can place each balloon by dragging the mouse. I can use absolute positioning to determine the position of each balloon. As for animation, demo just now has proved that it is not difficult to realize. It is nothing more than controlling transparency, xy coordinates and scaling. “

At the thought of this, I felt a thrill of joy. Ha ha, there is a way, you can package a common component for these elements on PPT, then provide some commonly used animation methods, the rest is to call these animation methods to assemble more complex animation. Create a new PPT: “Appear, leap, fade, float in, shutter, chessboard. . 。” Looking at this dazzling variety of animations, I thought: Well, I’d better start with the simplest. . .

First of all, we can divide animation into two types: one-off animation and circular animation.
SecondlyAs an element, its attributes that can be used as animation mainly include: opacity, x, y, scale, angle, etc. (only two-dimensional plane is considered here, but it can be extended to three-dimensional one).
Last, the basic animation can be disassembled into these kinds of behaviors: appearing/disappearing, moving, zooming and rotating.

3.1 One-off Animation

Think of this, backhand is to create a new file, the code is as follows:

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

The skeleton of the first step has been completed. It is so simple that I can hardly believe it. . . Next is the concrete method to realize each animation, first take 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();
}

Have a try, it is Wen mei ~

But! After careful consideration, there is a very serious problem. How to deal with the animation convergence here? How do you want to make an animation that shows first, then hide after 1s? It seems that we have returned to the problem we considered at the beginning. However, this time, I used Promise to solve this problem. So the code is like this again:

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

Now let’s look at the animation just now, just like this can be achieved:

playAnimation() {
  this.animationRef
    .show()                                 // 先出现
    .sleep(1000)                            // 等待1s
    .then(() => this.animationRef.hide());  // 消失
}

You can even encapsulate another wave of 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);
}

Then, we add several other basic animation (scale, rotate, move) implementations as well:

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 Cycle Animation

The one-off animation problem has been solved in this way. Let’s look at how circular animation works. According to the usual experience, a circular animation usually reads as follows:

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

Yes, a callback is passed in the start of an animation, and this callback recursively calls the function of playing the animation itself. If it corresponds to the component we want to package, how should it be implemented?

After thinking for a long time, in order to maintain the consistency with the one-time animation API, we can add the following functions to the 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();
  }

  ...
}

Among them, _ CreateClickAnimation, _ CreateClickAnimation Promise corresponds to the API of one-time animation. However, the difference is that the parameters passed in have changed greatly: animationconfg-> (name, animations)

  1. Name is an identifier, and cannot be repeated between circular animations. _ PlayCycle animation and _ StopcYCLICATION are both called by name to match the corresponding animation.
  2. Animations are a set of animations, where each animation is generated by calling _ createanization. Since circular animation can be composed of a set of one-off animations, Animated.sequence is also directly called in _ createcycyclization, and the implementation of circular playback lies in the recursive call in _ playcycle.

By this time, the circular animation has basically been packaged. To encapsulate the two-cycle animation roll, blink try:

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. Actual combat

After much work, I finally packed the AnimatedContainer. Let’s find a material to practice first ~ but, what? “Ding”, I saw a reminder on the mobile phone to dig up money lit up. Hey hey, it’s up to you, the sign-in page for digging up money is really suitable. . . The effect diagram is as follows:

WACAI-DEMO.GIF

The render code for rendering elements will not be pasted, but let’s look at the code for animation playback:

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

Just look at the code, whether you have already filled up the whole animation with your brain ~ it is clear at a glance that you are really flattered.

5. Follow-up thinking

  1. Reasonably, the animation that this AnimatedContainer can create is still a bit thin and contains only the most basic basic operations. However, this also shows that there is still much room for expansion. According to the two functions of _ CreateClickAnimationPromise and _createAnimationPromise, we can freely package all kinds of complex animation effects we want. The caller only needs to control the animation sequence through promise’s all and then methods. Personally, I feel that even a little bit of jQuery is being used. . .
  2. In addition, there is another question: since these elements are absolutely positioned, what about the x and y coordinate values of these elements? On the premise of visual annotation draft, the feeling is still feasible. However, once the number of elements increases, it is still a bit troublesome to use. . . So, if there is a tool that can really support drag and drop of elements and obtain coordinates of elements in real time like PPT, it would be really beautiful. . . . . .

As usual, this article code address:https://github.com/SmallStoneSK/AnimatedContainer