[React Native] ANIMATED APIs

[React Native] ANIMATED APIs

Mặc dù giờ  đã có thằng reanimated 2  ngon vkl, nhưng dù sao chúng ta cũng phải uống nước nhớ nguồn phải không quí vị?
Thật ra tôi cũng éo thích viết về cái này đâu, một phần vì
tôi ngu animation vkl ngại chèn ảnh GIF , nhưng tại đang được giao cho tìm hiểu để seminar cái này thì coi như một công đôi việc cho cả bạn cả tôi luôn.

Tổng quan về Animation trong RN

Ai cũng biết, để cho một ứng dụng của mình nhìn xịn xò trong mắt người dùng, ta phải cho nó những animation xịn xò, ảo tung chảo. Mặc dù không biết có đẹp không nhưng mà trong mắt người dùng kiểu gì trông cũng nguy hiểm.
Vậy nên, RN cũng cấp cho chúng ta 2 complementary animation systems: Animated cho các đơn vị với các giá trị cụ thể, và LayoutAnimation cho tất cả màn hình ( chủ yếu là transactions).

Tóm lại thì:

  • Animated API cho các components trong một màn hình
  • LayoutAnimated cho quá trình chuyển qua lại (transactions) giữa các màn hình

Tư tưởng của Animated là tập trung vào Input/Output, trong đó thực hiện transform giá trị ở giữa quá trình In -> Out, và phương thức start/stop để kiểm soát việc thực thi animation dựa trên thời gian.

RN Animated APIs

Đây chính là cái mà chúng ta sử dụng nhiều nhất, để vẽ nên các animations xịn xò thì ta sẽ sử dụng nó.

Quy trình chính khi tạo một animation là như sau:

  1. Tạo một Animated.Value
  2. Kết hợp với một hoặc nhiều style attribute của animatable component
  3. Khởi chạy animation bằng một loại animation đã được Animated API cung cấp ( Animated.timing(), Animated.decay(), ...)

Mặc định thì các React Components đều không thể thực hiện animation được, nên Animated cung cấp cho chúng ta: Animated.createAnimatedComponent(), hàm này có thể biến bất kì React Component nào từ không thể animation thành có thể sử dụng animation ( animatable component).
Animated API đã export sẵn cho chúng ta 6 animatable components sau đây:

  1. Animated.View
  2. Animated.Text
  3. Animated.Image
  4. Animated.ScrollView
  5. Animated.FlatList
  6. Animated.ListView

Animated Values

Animated values có 2 loại chính cho các bạn lựa chọn:

  1. Animated.Value(): Dùng cho các giá trị đơn
  2. Animated.ValueXY(): Dành cho các vector

Animated.Value():

Là class tiêu chuẩn cho việc drive animations. Thường được khởi tạo như sau :
new Animated.Value(initValue);

ex: const aniValue = useRef(new Animated.Value(0)).current

*Trên tài liệu chính thức của React Native thì facebook team có recommend nên sử dụng useRef. Mình giải thích chút về tại sao lại dùng useRef mà không phải state. Đơn giản vì một trong những khác biệt lớn nhất giữa 2 thằng này là useRef thì không trigger re-render, còn state thì lại .
Có thể bạn chưa biết:
useRef = useState({current: initValue})[0]
hoặc bẩn bựa hơn thì thế này
[value,] = useState()

Được rồi, vậy tại sao lại phải dùng Animated.Value() chứ không phải một regular variables bình thường?

Bây giờ bạn phải hiểu cách animation trong react native hoạt động:
Ví dụ: Bạn có một animation component có value animation size = 0, bạn muốn value này transform đến giá trị
size = 100. Nếu bạn dùng regular variable thì, tất nhiên rồi, component của bạn sẽ nhảy thẳng một phát từ 0 -> 100, không có animation nào xảy ra cả.

Nhắc lại chút về định nghĩa: Animation (hoạt ảnh) thực chất là một chuỗi các hình ảnh tĩnh nối tiếp nhau liên tục.

Vậy nên, nếu bạn dùng như trên thì thực chất cái "animation" của bạn chỉ có 2 hình tĩnh: 1 ở size = 0 và 2 ở
size = 100

Animated.Value giúp chúng ta tạo ra các chuỗi "hình tĩnh" liên tục nối tiếp nhau trong quá trình In -> Out (Kéo lên trên đọc lại tư tưởng của nó nào các bạn)

Ví dụ: Lại ví dụ trên, bạn muốn nó sẽ như thế này:

size = 0(In) -> size = 100(Out)

Animated.Value sẽ giúp bạn sinh ra các giá trị nối tiếp liên tục giữa In -> Out, các bạn có thể gọi đây là "frame"
(0) -> (1) -> (2) -> (3) -> ... -> (100)

Frame screen rate (Fps) tiêu chuẩn là 60fps, vậy thì nghĩa là: Trong 1s thì screen của bạn sẽ render 60 lần, đơn giản hơn thì nghĩa là trong 1s từ In -> Out sẽ được Animated.Value tính toán chia thành 60 "frame".

Animated.ValueXY()

Giờ nếu bạn hiểu cái thằng trên thì thằng này cũng tương tự vậy thôi, giờ thay vì chỉ có một giá trị đơn thì nó có giá trị vector(x,y)
ValueXY thường không được khởi tạo mặc định, mà sẽ được gán cho x,y của một component. Thằng này thường được dùng kèm với PanResponder để làm mấy cái kéo thả, giống như mấy cái bong bóng message vậy

via GIPHY

Code hình trên, xem thôi không hiểu cũng không sao:

import React, {useRef} from 'react';
import {Animated, PanResponder, View} from 'react-native';

const PanResponders = () => {
  const pan = useRef(new Animated.ValueXY()).current;

  const panResponder = useRef(
    PanResponder.create({
      onMoveShouldSetPanResponder: () => true,
      onPanResponderGrant: () => {
        pan.setOffset({
          x: pan.x._value,
          y: pan.y._value,
        });
      },
      onPanResponderMove: Animated.event([null, {dx: pan.x, dy: pan.y}], {
        useNativeDriver: false,
      }),
      onPanResponderRelease: () => {
        pan.flattenOffset();
      },
    }),
  ).current;

  return (
    <View style={{flex: 1}}>
      <Animated.View
        style={[
          {
            width: 100,
            height: 100,
            borderRadius: 50,
            backgroundColor: 'red',
          },
          pan.getLayout(),
        ]}
        {...panResponder.panHandlers}
      />
    </View>
  );
};

export default PanResponders;

Được rồi, coi như chúng ta tạm ổn với các animated values đi, giờ thì bấm nút sang phần tiếp theo nào!

Animation types

Hiện tại thì Animated đang cung cấp cho chúng ta 3 anh tài:

  1. Animated.timing: Thực hiện animation theo thời gian quy định
  2. Animated.decay: Thực hiện animation mô phỏng chuyển động có tốc độ giảm dần về cuối
  3. Animated.spring: Thực hiện animation mô phỏng cơ chế vật lý của lò xo

Config của 3 thằng này đều có :

  1. toValue : value animation bạn muốn đạt tới
  2. useNativeDriver: Sử dụng Native Thread để xử lý thay vì JS Thread, mình sẽ giải thích ở dưới. Mặc định false
  3. isInteraction: Có hoặc không cho phép animation này tạo một interaction handle trên InteractionManager, mặc định là true. Ở đây bạn không cần quá quan tâm về thằng này, đại khái là thằng này giúp bạn lên lịch những task và thực thi chúng sau khi tương tác/animation hoàn thành.

useNativeDriver

Theo kiến trúc hiện tại, thì RN có 2 thread:

  1. JS Thread
  2. UI Thread (Main thread, Native thread, ...)

Nếu bạn set useNativeDriver: false thì thằng JS Thread sẽ phải xử lý hết cả render animation cả JS code blah blah, nếu tác vụ quá nặng thì nó sẽ gây drop fps, tệ hơn là treo. Còn nếu bạn set useNativeDriver: true thì lúc này phần render Animation sẽ do thằng UI Thread đảm nhiệm, lúc này thằng JS Thread sẽ chỉ phải xử lý phần JS code, nên sẽ giảm thiểu khả năng bị drop fps.

Vậy thì đặt ra câu hỏi, nếu nó ngon vkl thế tại sao không để mặc định là true? Câu trả lời là vì thằng này chỉ support cho những thuộc tính non-layout (transform,opacity,...) còn lại mấy cái liên quan đến layout như flexbox hay position thì nó không dùng được. Đây chính là cái giới hạn chính của thằng này.

Animated.timing

timing cho phép chúng ta định nghĩa thời gian animation thực hiện. Thường được sử dụng nếu bạn cần giá trị ban đầu của animation này đạt đến giá trị bạn mong muốn trong một khoảng thời gian quy định.

Theo như tài liệu chính thức của React Native, thì cơ chế chuyển động của timing được xác định bằng easing function. Mình sẽ giải thích chút về thằng này, easing function là các hàm số thể hiện tốc độ thay đổi của tham số theo thời gian.
Ex: linear function = f(t) = t - Phương trình tuyến tính chuyển động thẳng đều.

RN cung cấp cho ta module Easing đã được import sẵn các easing function và một số animation build từ những easing functions đó.

Cú pháp sử dụng : timing(value, config)

  • Nhận vào 2 tham số:
  1. value: Animated.value
  2. config:
    • duration: Thời gian animation thực hiện
    • easing: Sử dụng Easing module, mặc định là Easing.inOut(Easing.ease)
    • delay: Giống như setTimeOut, delay một khoảng thời gian(ms) rồi mới thực hiện animation
import React, {useRef} from 'react';
import {Animated, Easing, Text, TouchableOpacity, View} from 'react-native';

const ChangePosition = () => {
  const aniValue = useRef(new Animated.Value(0)).current;

  function moveToRandomPosition() {
    Animated.timing(aniValue, {
      toValue: Math.floor(Math.random() * 300),
      duration: 2000, //Thời gian thực thi
      delay: 2000, // Thời gian delay trước khi thực thi
      easing: Easing.linear,
      useNativeDriver: true,
    }).start();
  }

  return (
    <View style={{margin: 16}}>
      <Animated.View
        style={{
          width: 100,
          height: 100,
          borderRadius: 50,
          backgroundColor: 'red',
          transform: [
            {
              translateX: aniValue,
            },
          ],
        }}
      />
      <TouchableOpacity onPress={moveToRandomPosition} style={{paddingTop: 16}}>
        <Text>Move to random position</Text>
      </TouchableOpacity>
    </View>
  );
};

export default ChangePosition;

via GIPHY

Animated.decay

decay cho phép chúng ta thực hiện một animation có tốc độ giảm dần về 0 dựa trên decay coefficient.
Hiểu đơn giản thì thằng decay này tạo ra animation bắt đầu với một vận tốc nào đó, xong rồi chậm dần đều tới khi nào dừng. Thằng này thường được kết hợp với PanResponder để làm mấy animation "ném, quăng", bạn cứ tưởng tượng giống như khi bạn vuốt mạnh một scrollview vậy, thì lúc đầu nó sẽ trôi rất nhanh xong sau đó sẽ chậm lại cho đến khi dừng hẳn.

Cú pháp sử dụng : decay(value, config)

  • Nhận vào 2 tham số:
  1. value: Animated.value
  2. config:
    • velocity: Vận tốc của animation (Bắt buộc)
    • deceleration: Giảm tốc - vận tốc giảm đi, mặc định là 0.997

Ví dụ về đồng chí này mình sẽ trình bày trong một post về PanResponder.
Thằng này được dùng rất nhiều, ví dụ trong <ScrollView> khi "hất xuống" thì list sẽ trôi nhanh lúc đầu xong chậm dần...

Animated.spring

spring cho phép chúng ta thực hiện một animation mô phỏng cơ chế vật lý ngoài đời thực

Chú ý: Bạn chỉ có thể sử dụng MỘT trong 2 cặp sau: bounciness/speed, tension/friction,
hoặc stiffness/damping/mass nhưng không nhiều hơn 1

Cú pháp sử dụng : spring(value, config)

  • Nhận vào 2 tham số:
  1. value: Animated.value
  2. config:
    • friction: Kiểm soát độ đàn hồi/vượt mức. Mặc định là 7
    • tension: Kiểm soát tốc độ. Mặc định là 40
    • speed: Kiểm soát tốc độ của animation. Mặc định là 12
    • bouciness: Kiểm soát độ đàn hồi. Mặc định là 8
    • stiffness: Hệ số độ cứng của lò xo. Mặc định 100
    • daming: Hệ số giảm xóc do ma sát. Mặc định 10
    • mass: Khối lượng của vật gắn vào cuối lò xo. Mặc định 1

stiffness/damping/mass được xây dựng trên nguyên mẫu phương trình của damped harmonic oscillator. (Cái này hình như là một kiểu dao động điều hoà, mình cũng không rõ nữa xD )

import React, {useRef} from 'react';
import {Animated, Easing, Text, TouchableOpacity, View} from 'react-native';

const ChangePosition = () => {
  const aniValue = useRef(new Animated.Value(0)).current;

  function moveToRandomPosition() {
    Animated.spring(aniValue, {
      toValue: Math.floor(Math.random() * 300),
      speed: 30,
      bounciness: 20,
      useNativeDriver: true,
    }).start();
  }

  return (
    <View style={{margin: 16}}>
      <Animated.View
        style={{
          width: 100,
          height: 100,
          borderRadius: 50,
          backgroundColor: 'red',
          transform: [
            {
              translateX: aniValue,
            },
          ],
        }}
      />
      <TouchableOpacity onPress={moveToRandomPosition} style={{paddingTop: 16}}>
	<Text>Move to random position have bounce</Text>      </TouchableOpacity>
    </View>
  );
};

export default ChangePosition;

via GIPHY

Interpolation (Nội suy)

Hiểu đơn giản thì đây là một hàm được dùng để map Input với Output. Thằng này còn có tên khác là Keyframe, bạn nào từng theo media hay có kinh nghiệm làm video các kiểu chắc biết cái này. Chủ yếu dùng trong trường hợp bạn muốn kiểm soát đường đi của animation

Ví dụ mình có một animation có input = 1 -> output = 100
Nếu như bình thường thì nó vẫn chạy thẳng từ 1 đến 100, nhưng giờ mình muốn nó chạy từ 1 -> 50 -> 30 -> 100 thì phải dùng tới interpolate()

transform: [
            {
              translateX: aniValue.interpolate({
                inputRange: [0, 0.2, 0.8, 1],
                outputRange: [0, 50, 30, 100],
                extrapolate: 'clamp'
              }),
            },
          ],

Như đoạn code trên, thì khi input range đạt đến giá trị 0.2 thì output range sẽ là 50, và khi lên tới 0.8 sẽ từ 50 -> 30 và sau đó là 30 -> 100
Lưu ý: Giá trị trong inputRange phải luôn tăng, không được giảm
Interpolation mặc định sử dụng linear nhưng bạn có thể set cho nó easing functions khác
Ngoài ra bạn có thể kết hợp được nhiều animation bằng interpolation.
Ngoài ra, bạn còn thấy đoạn code trên còn có 1 thuộc tính là extrapolate (Ngoại suy), hiểu đơn giản thì nó là cách tính các giá trị ngoài phạm vi ( Ví dụ như đoạn code trên thì khi inputRange có giá trị vượt ngoài 1, thì extrapolate sẽ giúp chúng ta tính giá trị outputRange)
extrapolate có 3 loại chính:

  • extend - Ngoại suy dựa theo nội các giá trị nội suy trước đó (Mặc định)
  • Clamp - Ngoại suy sẽ bằng với giá trị cuối cùng của nội suy (Ngĩa là khi inputRange có bằng 1.5 thì output vẫn bằng 100)
  • identity - Lấy bất cứ giá trị nào sau giá trị nội suy, nhưng không dựa theo nội suy trước đó (Cái này mình chưa thấy ai dùng bao giờ)
import React, {useRef} from 'react';
import {Animated, Easing, Text, TouchableOpacity, View} from 'react-native';

const Interpolation = () => {
  const aniValue = useRef(new Animated.Value(0)).current;

  function moveToRandomPosition() {
    Animated.timing(aniValue, {
      toValue: 1,
      useNativeDriver: true,
    }).start();
  }

  return (
    <View style={{margin: 16}}>
      <Animated.View
        style={{
          width: 100,
          height: 100,
          borderRadius: 50,
          backgroundColor: 'red',
          transform: [
            {
              translateX: aniValue.interpolate({
                inputRange: [0, 0.2, 0.8, 1],
                outputRange: [0, 150, 100, 200],
              }),
            },
            {
              scale: aniValue.interpolate({
                inputRange: [0, 0.2, 0.8, 1],
                outputRange: [1, 0.6, 1.5, 1],
              }),
            },
          ],
        }}
      />
      <TouchableOpacity onPress={moveToRandomPosition} style={{paddingTop: 16}}>
        <Text>Move to random position but with interpolation</Text>
      </TouchableOpacity>
    </View>
  );
};

export default Interpolation;

via GIPHY

Composing animations

Animated API cung cấp cho chúng ta những hàm kết hợp sau đây, tất cả đều nhận vào một mảng animations:

  1. Animated.parallel(animations, config?): Animations sẽ được chạy đồng thời song song với nhau
  2. Animated.sequence(animations): Animations trong danh sách sẽ được chạy tuần tự
  3. Animated.stagger(time, animations): Giống parallel nhưng animation sau sẽ bị delay 1 khoảng thời gian (ms) so với cái trước (Không cần phải đợi cái trước chạy xong)
  4. Animated.delay(): Giống setTimeout, delay một khoảng thời gian(ms) rồi mới chạy animation
import React, {useRef} from 'react';
import {Animated, Easing, Text, TouchableOpacity, View} from 'react-native';
import {ScrollView} from 'react-native-gesture-handler';

const Composing = () => {
  const aniValue1 = useRef(new Animated.Value(0)).current;
  const aniValue2 = useRef(new Animated.Value(0)).current;

  function moveToRandomPosition() {
    Animated.sequence([
      Animated.timing(aniValue1, {
        toValue: 200,
        duration: 3000,
        easing: Easing.linear,
        useNativeDriver: true,
      }),
      Animated.timing(aniValue2, {
        toValue: 200,
        duration: 3000,
        easing: Easing.linear,
        useNativeDriver: true,
      }),
    ]).start();
  }

  return (
    <View style={{margin: 16}}>
      <Animated.View
        style={{
          width: 100,
          height: 100,
          borderRadius: 50,
          backgroundColor: 'red',
          transform: [
            {
              translateX: aniValue1,
            },
          ],
        }}
      />
      <Animated.View
        style={{
          width: 100,
          height: 100,
          borderRadius: 50,
          marginTop: 16,
          backgroundColor: 'red',
          transform: [
            {
              translateX: aniValue2,
            },
          ],
        }}
      />
      <TouchableOpacity onPress={moveToRandomPosition} style={{paddingTop: 16}}>
        <Text>Move to specified position with composing</Text>
      </TouchableOpacity>
    </View>
  );
};

export default Composing;

via GIPHY

Ở đây mình đã giới thiệu đến các bạn nguyên lý cũng như cách tạo animations cơ bản. Và hãy đón đọc phần PanResponder, để có thể control về nó ở một mức độ cao hơn.
Thân ái và tạm biệt!