التحريكات في React Native

من موسوعة حسوب
اذهب إلى التنقل اذهب إلى البحث

التحريكات (Animations) مهمة جدًا لتجربة مستخدمٍ رائعة. يجب أن تتغلب الأشياء الثابتة على الجمود عندما تبدأ في التحرك. تملك الأجسام المتحركة زخمًا (أو قوّة دفعٍ: momentum) ونادراً ما تتوقف على الفور. تسمح لك التحريكات بإنشاء حركات فيزيائيّةٍ مُقنعةٍ في واجهة تطبيقك.

يوفر React Native نظامي تحريك يُكمِّلان بعضهما البعض:

  • واجهة Animated للتحكم الحُبَيْبِيّ (granular) والتفاعليّ بقيمٍ محددة.
  • واجهة LayoutAnimation للتعامل مع تحريك التخطيط العام (global layout).

واجهة ‎Animated‎ البرمجية

صُمِّمَت واجهة ‎Animated البرمجيّة لتسهيل التعبير عن مجموعة متنوعة من أنماط التحريك والتفاعل بطريقة فائقة الأداء. تركّز واجهة ‎Animated‎ على العلاقات التعريفية بين المدخلات (inputs) والمخرجات (outputs)، مع وجود تحويلات قابلة للضّبط بينهما، إضافة إلى تابعي ‎start‎ و‎stop‎ بسيطين للتحكم في تنفيذ التّحريك على أساس الوقت.

تُصدّر واجهة ‎Animated‎ أربعة أنواع من المكونات القابلة للتّحريك: ‎View‎، و‎Text‎، و‎Image‎، و‎ScrollView‎، ويمكنك كذلك إنشاء مُكوّنات خاصّة بك باستخدام التّابع ‎Animated.createAnimatedComponent()‎‎.

إليك مثال فيه تبْزُغُ (fades in) واجهة حاوية ببطء عندما تصيَّر أي تضاف إلى الشاشة (تجربة حية):

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

const FadeInView = (props) => {
  const fadeAnim = useRef(new Animated.Value(0)).current  // ‫القيمة الابتدائية للعتامة opacity: 0
  React.useEffect(() => {
    Animated.timing(
      fadeAnim,
      {
        toValue: 1,
        duration: 10000,
      }
    ).start();
  }, [fadeAnim])

  return (
    <Animated.View                 // عرضٌ مُحرَّكٌ خاصّ
      style={{
        ...props.style,
        opacity: fadeAnim,         // ربط العتامَة بالقيمة المُحرَّكة
      }}
    >
      {props.children}
    </Animated.View>
  );
}

// يُمكنك الآن استخدام المكوّن
// `FadeInView`
// كعرضٍ بدلًا من
// `View`
// في مُكوّناتك

export default () => {
  return (
    <View style={{flex: 1, alignItems: 'center', justifyContent: 'center'}}>
      <FadeInView style={{width: 250, height: 50, backgroundColor: 'powderblue'}}>
        <Text style={{fontSize: 28, textAlign: 'center', margin: 10}}>Fading in</Text>
      </FadeInView>
    </View>
  )
}

لننظر في تفاصيل الشيفرة أعلاه. في الدّالة البانية (constructor) للمكوّن FadeInView، نُهيّئُ قيمةَ Animated.Value جديدةً تُمثِّل قيمةً مُتحرِّكةً ونُسمِّيها fadeAnim كجزء من الحالة (‎state‎). تُربَط خاصيةُ عتامةٍ (opacity) على الواجهة ‎View‎ مع هذه القيمة المتحركة. وراء الكواليس، تُستَخرَج القيمة الرقمية وتُستخدَم لضبط العتامة.

عندما يوصَل المُكوّن تُضبَطُ العتامة إلى 0. وبعد ذلك تبدأ حركة متسارعة (easing animation) تطبَّق على القيمة المتحركة fadeAnim، ما سيُحدِّث جميع القيم المربوطة التابعة لها (في هذه الحالة، هناك قيمة مربوطةٌ واحدةٌ فقط تُمثِّل العتامة) على كل إطارٍ (frame) أثناء نقل القيمة إلى القيمة النهائية 1.

يتم هذا بطريقة محسّنةٍ أسرعَ من استدعاءِ setState لتغيير الحالة وإعادة التصيير. ولأنّ الضبطَ تعريفيٌّ (declarative configuration) بأكمله، فسنكون قادرين على إجراء مزيد من التحسينات التي تقوم بسَلْسَلَةِ (serialize) الضّبط وتشغيل التحريك على سِلسِلَةٍ ذات أولوية عالية (high-priority thread).

ضبط التحريكات

يُمكن ضبط التحريكات بعدّة معاملاتٍ وخيارات. إذ يمكن تعديل دوال التسارع (easing functions) مخصّصة ومحددة مسبقًا، وتعديل التأخير (delay)، والمُدَدِ (durations)، وعوامل الِاضْمِحْلَالِ (decay factors)، وثوابت الوَثْبِ (spring constants)، وغير ذلك حسب نوع التّحريك.

تُوفّر واجهة Animated العديد من أنواع التّحريك، وأكثرها استخدامًا هو التحريكُ ‎Animated.timing()‎‎، الذي يدعم تحريك قيمةٍ بمرور الوقت باستخدام إحدى دوال التّخفيف المُعرَّفة مُسبقًا (predefined)، أو يمكنك استخدام دوال خاصّة بك. عادةً ما تُستخدَم دوال التّخفيف في التّحريك لإظهار تسارعٍ تدريجيّ للأشياء ولإبطائها.

افتراضيًّا، سيستخدم التّابعُ ‎timing‎ منحنى ظهورٍ مُخفَّف واختفاء مُخفَّف easeInOut، ما يُظهر تسارعًا تدريجيًّا إلى السرعة الكاملة ويختتم بتباطؤٍ تدريجيّ إلى التوقف. يمكنك تحديد دالّة تسارع مختلفة عبر تمريرها إلى المُعامل ‎easing‎. يُمكنك كذلك تحديد مدة ‎duration‎ مخصّصةٍ، أو تأخيرٍ ‎delay‎ قبل بدء التّحريك.

على سبيل المثال، يُمكننا إنشاء تحريك يدوم لثانيّتين لكائنٍ يرتدّ (backs up) ارتدادًا طفيفًا قبل الانتقال إلى موضعه النهائي كما يلي:

Animated.timing(this.state.xPosition, {
  toValue: 100,
  easing: Easing.back(),
  duration: 2000
}).start();

انظر قسم ضبط التحريكات في توثيق واجهة ‎Animated للاستزادة حول جميع خيارات الضّبط المدعومة.

تركيب التحريكات

يمكن دمج التحريكات وتشغيلها بالتسلسل أو بالتوازي. يمكن تشغيل التحريكات التسلسلية فور انتهاء التحريك الذي يسبقها، أو يمكن تشغيلها بعد مدّة تأخير محددة. توفر واجهة Animated عدة توابع لهذا، مثل ‎sequence()‎، و‎delay()‎، كل تابعٍ يأخذ ببساطةٍ مصفوفةً من التحريكات المرغوب تشغيلها، ويستدعي تلقائيًّا ‎start()‎، أو ‎stop()‎ حسب الحاجة.

على سبيل المثال، يهبط (coasts) التحريك التالي تدريجيًّا إلى نقطة توقّف، ثم يرتدّ واثبًا (springs back) أثناء الالتفاف (twirling) بالتوازي:

Animated.sequence([
  // تلاشى، ثمّ ارتدّ إلى نقطة البداية واِلْتفّ
  Animated.decay(position, {
    // اهبِط إلى نُقطَة توقّف
    velocity: { x: gestureState.vx, y: gestureState.vy }, // velocity from gesture release
    deceleration: 0.997
  }),
  Animated.parallel([
    // بعد التّلاشي (بالتوازي)
    Animated.spring(position, {
      toValue: { x: 0, y: 0 } // return to start
    }),
    Animated.timing(twirl, {
      // and twirl
      toValue: 360
    })
  ])
]).start(); // ابدأ سلسلة التحريكات

إذا أُوقِفَ تحريكٌ أو قُوطِع، فستُوقَف جميع التحريكات الأخرى في المجموعة كذلك. يحتوي التّابع ‎Animated.parallel‎ خيارًا باسم ‎stopTogether‎ يمكن ضبطُ قيمتِه إلى false لتعطيل هذا السلوك.

لقائمةٍ كاملة بأساليب التركيب، انظر قسم تركيب التّحريكات في توثيق واجهة ‎Animated‎ البرمجيّة.

دمج القيم المتحركة

يمكنك الجمع بين قيمتين متحركتين عن طريق الجمع أو الضرب أو القسمة أو عمليّة باقي القسمة (modulo) لإنشاء قيمة متحركة جديدة.

هناك بعض الحالات التي تحتاج فيها قيمة متحرِّكةٌ إلى قلب قيمة متحركة أخرى بغرض حساب التحريك. مثال على ذلك هو قلب مقياسٍ (من 2x إلى 0.5x):

const a = new Animated.Value(1);
const b = Animated.divide(1, a);

Animated.spring(a, {
  toValue: 2
}).start();

الاستيفاء (Interpolation)

يمكن تمرير كل خاصية عبر استيفاءٍ أولاً. يربط الاستيفاء مجالات المدخلات (input ranges) مع مجالات المخرجات (output ranges)، وعادة ما يستخدم استيفاءٌ خطّي، ولكنّ هذه الطريقة تدعم كذلك دوال التسارع. افتراضيًّا، سيُستكمَل المنحنى استقرائيًّا (extrapolate) خارج المجالات المعطاة، لكن يمكنك شدّ (clamp) قيمة المُخرج كذلك كي لا تخرج عن حدود المجالات المُعطاة.

هذا مثال على ربطٍ بسيط لتحويل المجال ‎0-‎1 إلى المجال ‎0-100‎:

value.interpolate({
  inputRange: [0, 1],
  outputRange: [0, 100]
});

على سبيل المثال، قد ترغب بنقل قيمة التحريك Animated.Value الخاصة بك من 0 إلى 1، مع تحريك الموضع من 150px إلى 0px و العتامة من 0 إلى 1. يمكن القيام بذلك بسهولة عن طريق تعديل النمط ‎style‎ من المثال أعلاه هكذا:

 style={{
    opacity: this.state.fadeAnim, // ربط القيمة المتحرّكة مُباشرةً
    transform: [{
      translateY: this.state.fadeAnim.interpolate({
        inputRange: [0, 1],
        outputRange: [150, 0]  // 0 : 150, 0.5 : 75, 1 : 0
      }),
    }],
  }}

يدعم التّابع ‎‎interpolate()‎‎ كذلك شرائح مجالاتٍ متعددة، ما يُسهل تحديد المناطق الهامدة (dead zones) وحيلٍ أخرى.

على سبيل المثال، للحصول على علاقة نفيٍ (negation relationship) عند النّقطة ‎-300‎ تنتقِل إلى ‎0‎ عند النّقطة ‎-100‎، ثم تنتقِل إلى ‎1‎ عند النّقطة ‎0‎، ثم تعود مُجدّدًا إلى الصفر عند النّقطة ‎100‎ ثمّ تُـتبَع بمنطقة هامدة تبقى عند النّقطة ‎0‎ لما وراء ذلك، فستكون الشيفرة مشابهة لما يلي:

value.interpolate({
  inputRange: [-300, -100, 0, 100, 101],
  outputRange: [300, 0, 1, 0, 0]
});

ما سيُنشئ ترابطات كالتالي:

Input | Output
------|-------
  -400|    450
  -300|    300
  -200|    150
  -100|      0
   -50|    0.5
     0|      1
    50|    0.5
   100|      0
   101|      0
   200|      0

يدعم التّابع ‎‎interpolate()‎‎ كذلك ربط القيم إلى سلاسل نصيّة، ما يسمح لك بتحريك الألوان والقيم ذات الوحدات (units). مثلًا، إن أردت تحريك دورانٍ فستكتب ما يلي:

value.interpolate({
  inputRange: [0, 360],
  outputRange: ['0deg', '360deg']
});

يدعم التّابع ‎‎interpolate()‎‎ كذلك دوال تسارع/تخفيف محدّدة، والتي يوجد عدد منها في وحدة Easing‎‎. إضافةً إلى أنّ للتّابعِ ‎‎interpolate()‎‎ سلوكًا قابلًا للضّبط لاستقراء (extrapolation) المجال المُخرَج ‎outputRange‎. يمكنك ضبط الاستقراء عبر ضبط خيارات ‎extrapolate‎، و‎extrapolateLeft‎، و‎extrapolateRight‎. القيمة الافتراضية هي ‎extend‎ للتوسيع، لكن يمكنك استخدام ‎clamp‎ لمنع القيمة المُخرجَة من تجاوز مجال ‎outputRange‎.

تتبع القيم الديناميكية

يمكن للقيم المتحركة تَتبّعُ القيم الأخرى كذلك. ما عليك سوى تعيين قيمةِ ‎toValue‎ خاصّةٍ بتحريكٍ إلى قيمة متحرّكة أخرى بدلاً من عددٍ عادي. على سبيل المثال، يمكن إنجاز تحريك "Chat Heads" كذلك المستخدم في تطبيق Messenger على Android عبر تثبيت ‎ spring()‎على قيمة متحركة أخرى، أو باستعمال ‎timing()‎ ومدّة ‎duration‎ تُساوي 0 للتَتبّعٍ مُشدَّد. ويمكن كذلك إنجاز التّحريك عبر تركيب التحريكات بالاستيفاء:

Animated.spring(follower, { toValue: leader }).start();
Animated.timing(opacity, {
  toValue: pan.x.interpolate({
    inputRange: [0, 300],
    outputRange: [1, 0]
  })
}).start();

ستُنجَز القيم المتحركة ‎leader‎ و‎follower‎ باستخدام‎Animated.ValueXY()‎. القيمة‎ValueXY ‎ طريقةٌ سهلة للتعامل مع التفاعلات ثنائية الأبعاد، مثل المسح (panning) أو السحب (dragging). وهي غلافٌ (wrapper) بسيط يحتوي بشكل أساسي على نسختي ‎Animated.Value‎ وبعض الدوال المساعدة التي تستدعى عبرها، مما يجعل ‎ValueXY‎ بديلًا للقيمة ‎Value‎ في العديد من الحالات. ما يسمح لنا بتتبّع القيمة x و y في المثال أعلاه.

تتبع الإيماءات

يمكن ربط الإيماءات (Gestures) مثل المسح (panning) أو التمرير (scrolling)، وأحداثٍ (events) أخرى مباشرةً بالقيم المتحركة باستخدام Animated.event. يتم ذلك باستخدام بنية ربطٍ منظمة بحيث يمكن استخراج القيم من كائنات الأحداث (event objects) المعقدة. المستوى الأول هو مصفوفة للسماح بالرّبط عبر عدة مُعاملات، وتحتوي هذه المصفوفة على كائنات متداخلة.

على سبيل المثال، عند التّعامل مع إيماءات التمرير الأفقي (horizontal scrolling)، يمكنك القيام بما يلي لربط ‎event.nativeEvent.contentOffset.x‎ بقيمةٍ مُتحرِّكة (تُهيّئ بالدالة ‎Animated.Value‎) باسم ‎scrollX‎:

 onScroll={Animated.event(
   // scrollX = e.nativeEvent.contentOffset.x
   [{ nativeEvent: {
        contentOffset: {
          x: scrollX
        }
      }
    }]
 )}

ينفّذ المثال التالي سير تمرير أفقي (horizontal scrolling carousel) حيث تتحرك مؤشرات موضع التمرير باستخدام Animated.event المستخدم في واجهة التمرير ScrollView

مثال واجهة تمرير مع حدث التحريك

import React, { useRef } from "react";
import {
  SafeAreaView,
  ScrollView,
  Text,
  StyleSheet,
  View,
  ImageBackground,
  Animated,
  useWindowDimensions
} from "react-native";

const images = new Array(6).fill('https://images.unsplash.com/photo-1556740749-887f6717d7e4');

const App = () => {
  const scrollX = useRef(new Animated.Value(0)).current;

  const { width: windowWidth } = useWindowDimensions();

  return (
    <SafeAreaView style={styles.container}>
      <View style={styles.scrollContainer}>
        <ScrollView
          horizontal={true}
          style={styles.scrollViewStyle}
          pagingEnabled
          showsHorizontalScrollIndicator={false}
          onScroll={Animated.event([
            {
              nativeEvent: {
                contentOffset: {
                  x: scrollX
                }
              }
            }
          ])}
          scrollEventThrottle={1}
        >
          {images.map((image, imageIndex) => {
            return (
              <View
                style={{ width: windowWidth, height: 250 }}
                key={imageIndex}
              >
                <ImageBackground source={{ uri: image }} style={styles.card}>
                  <View style={styles.textContainer}>
                    <Text style={styles.infoText}>
                      {"Image - " + imageIndex}
                    </Text>
                  </View>
                </ImageBackground>
              </View>
            );
          })}
        </ScrollView>
        <View style={styles.indicatorContainer}>
          {images.map((image, imageIndex) => {
            const width = scrollX.interpolate({
              inputRange: [
                windowWidth * (imageIndex - 1),
                windowWidth * imageIndex,
                windowWidth * (imageIndex + 1)
              ],
              outputRange: [8, 16, 8],
              extrapolate: "clamp"
            });
            return (
              <Animated.View
                key={imageIndex}
                style={[styles.normalDot, { width }]}
              />
            );
          })}
        </View>
      </View>
    </SafeAreaView>
  );
}

const styles = StyleSheet.create({
  container: {
    flex: 1,
    alignItems: "center",
    justifyContent: "center"
  },
  scrollContainer: {
    height: 300,
    alignItems: "center",
    justifyContent: "center"
  },
  card: {
    flex: 1,
    marginVertical: 4,
    marginHorizontal: 16,
    borderRadius: 5,
    overflow: "hidden",
    alignItems: "center",
    justifyContent: "center"
  },
  textContainer: {
    backgroundColor: "rgba(0,0,0, 0.7)",
    paddingHorizontal: 24,
    paddingVertical: 8,
    borderRadius: 5
  },
  infoText: {
    color: "white",
    fontSize: 16,
    fontWeight: "bold"
  },
  normalDot: {
    height: 8,
    width: 8,
    borderRadius: 4,
    backgroundColor: "silver",
    marginHorizontal: 4
  },
  indicatorContainer: {
    flexDirection: "row",
    alignItems: "center",
    justifyContent: "center"
  }
});

export default App;
import React, { Component } from "react";
import {
  SafeAreaView,
  ScrollView,
  Text,
  StyleSheet,
  View,
  ImageBackground,
  Animated,
  Dimensions
} from "react-native";

const images = new Array(6).fill('https://images.unsplash.com/photo-1556740749-887f6717d7e4');

const window = Dimensions.get("window");

export default class App extends Component {
  scrollX = new Animated.Value(0);

  state = {
    dimensions: {
      window
    }
  };

  onDimensionsChange = ({ window }) => {
    this.setState({ dimensions: { window } });
  };

  componentDidMount() {
    Dimensions.addEventListener("change", this.onDimensionsChange);
  }

  componentWillUnmount() {
    Dimensions.removeEventListener("change", this.onDimensionsChange);
  }

  render() {
    const windowWidth = this.state.dimensions.window.width;

    return (
      <SafeAreaView style={styles.container}>
        <View style={styles.scrollContainer}>
          <ScrollView
            horizontal={true}
            style={styles.scrollViewStyle}
            pagingEnabled
            showsHorizontalScrollIndicator={false}
            onScroll={Animated.event([
              {
                nativeEvent: {
                  contentOffset: {
                    x: this.scrollX
                  }
                }
              }
            ])}
            scrollEventThrottle={1}
          >
            {images.map((image, imageIndex) => {
              return (
                <View
                  style={{
                    width: windowWidth,
                    height: 250
                  }}
                  key={imageIndex}
                >
                  <ImageBackground source={{ uri: image }} style={styles.card}>
                    <View style={styles.textContainer}>
                      <Text style={styles.infoText}>
                        {"Image - " + imageIndex}
                      </Text>
                    </View>
                  </ImageBackground>
                </View>
              );
            })}
          </ScrollView>
          <View style={styles.indicatorContainer}>
            {images.map((image, imageIndex) => {
              const width = this.scrollX.interpolate({
                inputRange: [
                  windowWidth * (imageIndex - 1),
                  windowWidth * imageIndex,
                  windowWidth * (imageIndex + 1)
                ],
                outputRange: [8, 16, 8],
                extrapolate: "clamp"
              });
              return (
                <Animated.View
                  key={imageIndex}
                  style={[styles.normalDot, { width }]}
                />
              );
            })}
          </View>
        </View>
      </SafeAreaView>
    );
  }
}

const styles = StyleSheet.create({
  container: {
    flex: 1,
    alignItems: "center",
    justifyContent: "center"
  },
  scrollContainer: {
    height: 300,
    alignItems: "center",
    justifyContent: "center"
  },
  card: {
    flex: 1,
    marginVertical: 4,
    marginHorizontal: 16,
    borderRadius: 5,
    overflow: "hidden",
    alignItems: "center",
    justifyContent: "center"
  },
  textContainer: {
    backgroundColor: "rgba(0,0,0, 0.7)",
    paddingHorizontal: 24,
    paddingVertical: 8,
    borderRadius: 5
  },
  infoText: {
    color: "white",
    fontSize: 16,
    fontWeight: "bold"
  },
  normalDot: {
    height: 8,
    width: 8,
    borderRadius: 4,
    backgroundColor: "silver",
    marginHorizontal: 4
  },
  indicatorContainer: {
    flexDirection: "row",
    alignItems: "center",
    justifyContent: "center"
  }
});

عند استخدام واجهة PanResponder، يمكنك استخدام الشيفرة التالية لاستخراج مواضع x و y من ‎gestureState.dx‎ و ‎gestureState.dy‎. نستخدم القيمة ‎null‎ في الموضع الأول للمصفوفة، لأنّنا لا نحتاج إلّا إلى المتغير الثاني الذي يُمرَّر إلى معالج PanResponder، والذي يُمثِّل حالة الإيماءة gestureState.

onPanResponderMove={Animated.event(
  [null, // تجاهل الحدث الأصيل
  
  // ‫استخرج dx و dy من gestureState

  //  'pan.x = gestureState.dx, pan.y = gestureState.dy'مثل 
  {dx: pan.x, dy: pan.y}
])}

إليك مثال لواجهة PanResponder مع حدث التحريك:

import React, { useRef } from "react";
import { Animated, View, StyleSheet, PanResponder, Text } from "react-native";

const App = () => {
  const pan = useRef(new Animated.ValueXY()).current;
  const panResponder = useRef(
    PanResponder.create({
      onMoveShouldSetPanResponder: () => true,
      onPanResponderMove: Animated.event([
        null,
        { dx: pan.x, dy: pan.y }
      ]),
      onPanResponderRelease: () => {
        Animated.spring(pan, { toValue: { x: 0, y: 0 } }).start();
      }
    })
  ).current;

  return (
    <View style={styles.container}>
      <Text style={styles.titleText}>Drag & Release this box!</Text>
      <Animated.View
        style={{
          transform: [{ translateX: pan.x }, { translateY: pan.y }]
        }}
        {...panResponder.panHandlers}
      >
        <View style={styles.box} />
      </Animated.View>
    </View>
  );
}

const styles = StyleSheet.create({
  container: {
    flex: 1,
    alignItems: "center",
    justifyContent: "center"
  },
  titleText: {
    fontSize: 14,
    lineHeight: 24,
    fontWeight: "bold"
  },
  box: {
    height: 150,
    width: 150,
    backgroundColor: "blue",
    borderRadius: 5
  }
});

export default App;
import React, { Component } from "react";
import { Animated, View, StyleSheet, PanResponder, Text } from "react-native";

export default class App extends Component {
  pan = new Animated.ValueXY();
  panResponder = PanResponder.create({
    onMoveShouldSetPanResponder: () => true,
    onPanResponderMove: Animated.event([
      null,
      { dx: this.pan.x, dy: this.pan.y }
    ]),
    onPanResponderRelease: () => {
      Animated.spring(this.pan, { toValue: { x: 0, y: 0 } }).start();
    }
  });

  render() {
    return (
      <View style={styles.container}>
        <Text style={styles.titleText}>Drag & Release this box!</Text>
        <Animated.View
          style={{
            transform: [{ translateX: this.pan.x }, { translateY: this.pan.y }]
          }}
          {...this.panResponder.panHandlers}
        >
          <View style={styles.box} />
        </Animated.View>
      </View>
    );
  }
}

const styles = StyleSheet.create({
  container: {
    flex: 1,
    alignItems: "center",
    justifyContent: "center"
  },
  titleText: {
    fontSize: 14,
    lineHeight: 24,
    fontWeight: "bold"
  },
  box: {
    height: 150,
    width: 150,
    backgroundColor: "blue",
    borderRadius: 5
  }
});

الاستجابة لقيمة التحريك الحالية

قد تلاحظ أنه لا توجد طريقة واضحة لقراءة القيمة الحالية أثناء التّحريك. وذلك لأن القيمة يُمكن أن تكون معروفة فقط في وقت التشغيل الأصيل (native runtime) بسبب تحسينات الأداء. إذا كنت بحاجة إلى تنفيذ شيفرة JavaScript تستجيب للقيمة الحالية، فهناك طريقتان:

  • spring.stopAnimation(callback)‎ دالّةٌ توقِف التحريك وتستدعي الدّالة ‎callback‎ المُمرَّرة مع تمرير القيمة النّهائيّة لها. هذه الطريقة مفيدة عند إجراء التحويل بين الإيماءات (gesture transitions).
  • spring.addListener(callback)‎ دالّةٌ تستدعي ‎callback‎ بشكل غير متزامن (asynchronously) أثناء التّحريك، ما يوفِّر قيمةً حديثة. وهي طريقة مُفيدة لإحداث تغييرات في الحالة، يُمكن بهذا مثلًا جرُّ كائنٍ إلى خيارٍ جديد أثناء سحب المستخدم للكائن وتقريبه إلى الخيار، وهذا لأنّ هذه التغييرات الكبيرة في الحالة تكون أقل حساسيةً لتأخّر الإطارات الطّفيف مقارنةً بالإيماءات المستمرة مثل المسح التي تحتاج إلى تشغيلها بمُعدّل 60 إطارا في الثانية (60fps).

صُمِّمَت واجهة ‎Animated‎ لتكون قابلة للسَّلسَلة (serializable) بالكامل لِيُمكن تشغيل التّحريك بأداءٍ عالٍ، بغض النظر عن حلقة أحداث JavaScript العادية (JavaScript event loop). يؤثّر هذا على الواجهة البرمجيّة، لذا تذكّر هذا إذا بدا لك أنّ إنجاز أمرٍ أصعبُ ممّا لو كان النظام متزامنًا تمامًا. انظر الدّالة ‎Animated.Value.addListener كطريقة للتغلب على بعض هذه القيود، لكن توخّ الحذر عند استخدامها، فقد يكون لها آثار على الأداء مستقبلًا.

استخدام المشغل الأصيل (native driver)

صُمِّمَت واجهة ‎Animated‎ لتكون قابلة للسَّلسَلة. باستخدام مُشغّل التحريكات الأصيل، يُرسل الإطار كل ما يتعلّق بالتحريك إلى اللغة الأصيلة قبل تشغيل التّحريك، ما يسمح للشّيفرة الأصيلة بتنفيذ التّحريك على سلسلة واجهة المستخدم (UI thread) دون الحاجة إلى المرور عبر الجسر (الجسرُ الذي يربط بين شيفرة JavaScript وشيفرة المنصّة الأصيلة) عند كل إطار. عند بدء التّحريك، يُمكن تجميد (block) سلسلة JavaScript دون التأثير على التحريك.

استخدام المُشغِّل الأصيل للتّحريكات العادية بسيط للغاية. ما عليك سوى إضافة الخيار ‎useNativeDriver: true‎ إلى إعداد التحريك عند تشغيله:

Animated.timing(this.state.animatedValue, {
  toValue: 1,
  duration: 500,
  useNativeDriver: true // <-- أضف هذا السطر
}).start();

تتوافق القيم المتحركة مع مُشغّل تحريكات واحد فقط، لذا إذا كنت تستخدم المُشغِّل الأصيل عند بدء تحريك قيمة ما، فتأكّد من أن جميع التّحريكات على هذه القيمة تستخدم المُشغِّل الأصيل كذلك.

يعمل المُشغِّل الأصيل مع الدّالة ‎ Animated.event‎كذلك . هذا مفيد بشكل خاص للتّحريكات التي تتّبع موضع التمرير (scroll position)، لأنّه بدون المُشغِّل الأصيل، سيتأخّر التّحريك دائمًا بإطارٍ واحدٍ بعد الإيماءة بسبب الطبيعة غير المتزامنة لإطار React Native.

<Animated.ScrollView // <-- استعمل عرض التمرير المُحرَّك كغلاف
  scrollEventThrottle={1} // <-- الرّقم واحد يعني التّأكّد من أنّ جميع الأحداث تُلتَقط دون استثناء
  onScroll={Animated.event(
    [
      {
        nativeEvent: {
          contentOffset: { y: this.state.animatedValue }
        }
      }
    ],
    { useNativeDriver: true } // <-- أضف هذا السّطر
  )}>
  {content}
</Animated.ScrollView>

يمكنك رؤية قدرات المُشغّل الأصيل في تطبيق RNTester، عبر تحميل مثال Native Animated Example. يمكنك كذلك إلقاء نظرة على الشيفرة المصدريّة لتفهم كيف طُوّرَت هذه الأمثلة.

محاذير

ليس كل ما يمكنك إنجازه بواجهة Animated مدعومًا حاليًا من المُشغّل الأصيل. القيد الرئيسي هو أنه يمكنك فقط تحريك الخاصيات غير التخطيطية (non-layout properties): ستَعمَل أمورٌ مثل التحويل (‎transform‎) والتعتيم (‎opacity‎)، لكن لا يمكن استخدام خاصيّات ‎flexbox‎ و‎position‎. عند استخدام الدّالة Animated.event، فستعمل فقط مع الأحداث المباشرة (direct events) ولن تعمل مع الأحداث التي تنتقل إلى العناصر الأب (أو ما يُسمّى بغَليان الأحداث: event bubbling). ما يعني أنه لا يعمل مع واجهة PanResponder ولكنه يعمل مع أحداثٍ مثل ‎ScrollView#onScroll‎.

يُمكن للتّحريك أثناء اشتغاله منع مكوناتِ ‎VirtualizedList‎ من تصيير المزيد من الصفوف. إذا كنت بحاجة إلى تشغيل تحريك طويل أو تحريك مُتكرّرٍ أثناء مرور المستخدم عبر قائمةٍ ما، فيمكنك استخدام الخيار ‎isInteraction: false‎ في إعداد التّحريك الخاص بك لمنع حدوث هذه المشكلة.

تذكير

عند استخدام أنماط التحويل (transform styles) مثل rotateY و rotateX وغير ذلك، فتأكّد من وجود نمط التحويل ‎perspective‎. إذ قد لا تُعرَض بعض التّحريكات على نظام Android دونه في الوقت الحالي. مثال:

<Animated.View
  style={{
    transform: [
      { scale: this.state.scale },
      { rotateY: this.state.rotateY },
      { perspective: 1000 } // لن يعمل التحريك على نظام أندرويد دون هذا السّطر
    ]
  }}
/>

أمثلة أخرى

يحتوي تطبيق RNTester على عدّة أمثلة تستعمل واجهة ‎Animated‎:

واجهة ‎LayoutAnimation‎ البرمجية

تُتيح لك واجهة LayoutAnimation البرمجيّة إعداد تحريكاتِ ‎create‎ و‎update‎ بشكل عامّ (globally)، والتي ستُستَخدَم لجميع الواجهات (views) في دورة التصيير (render) والتخطيط (layout) التالية. هذا مُفيد لإجراء تحديثاتٍ لتخطيط flexbox دون بذل جهدِ قياس أو حساب خاصيّاتٍ معينة لتحريكها مباشرةً، وهذا مفيدٌ بشكل خاصّ عندما تؤثر تغييرات التخطيط على المُكوِّنات الأجداد، على سبيل المثال، إنشاء مُكوِّنِ "اقرأ المزيد" يوسِّع مُكوّنًا أبًا ويزيد حجمه ويدفع الصّف الموجود أسفله سيتطلب تنسيقًا واضحًا ومُفصّلًا بين المكونات لتحريكها كلها بشكل متزامن.

لاحظ أنه على الرغم من أنّ واجهة LayoutAnimation مفيدةٌ جدًّا، إلا أنها تُوفر تحكّمًا أقلّ بكثيرٍ من واجهة ‎Animated‎ ومكتبات تحريكٍ أخرى، لذا فقد تحتاج إلى استخدام طريقة أخرى إن لم تتمكن من الحصول على النتيجة المرغوبة باستخدام واجهة ‎LayoutAnimation‎.

ملاحظة: لتعمل الواجهة في نظام Android، ستحتاج إلى ضبط الخيارين التاليين في مُدير واجهة المستخدم ‎UIManager‎:

UIManager.setLayoutAnimationEnabledExperimental &&
  UIManager.setLayoutAnimationEnabledExperimental(true);

مثال (تجربة حية):

import React from 'react';
import {
  NativeModules,
  LayoutAnimation,
  Text,
  TouchableOpacity,
  StyleSheet,
  View,
} from 'react-native';

const { UIManager } = NativeModules;

UIManager.setLayoutAnimationEnabledExperimental &&
  UIManager.setLayoutAnimationEnabledExperimental(true);

export default class App extends React.Component {
  state = {
    w: 100,
    h: 100,
  };

  _onPress = () => {
    //  تحريك التّحديث
    LayoutAnimation.spring();
    this.setState({w: this.state.w + 15, h: this.state.h + 15})
  }

  render() {
    return (
      <View style={styles.container}>
        <View style={[styles.box, {width: this.state.w, height: this.state.h}]} />
        <TouchableOpacity onPress={this._onPress}>
          <View style={styles.button}>
            <Text style={styles.buttonText}>Press me!</Text>
          </View>
        </TouchableOpacity>
      </View>
    );
  }
}

const styles = StyleSheet.create({
  container: {
    flex: 1,
    alignItems: 'center',
    justifyContent: 'center',
  },
  box: {
    width: 200,
    height: 200,
    backgroundColor: 'red',
  },
  button: {
    backgroundColor: 'black',
    paddingHorizontal: 20,
    paddingVertical: 15,
    marginTop: 15,
  },
  buttonText: {
    color: '#fff',
    fontWeight: 'bold',
  },
});

يستخدم هذا المثال قيمةً مُحدَّدةً مُسبقًا، لكن يُمكنك تخصيص التّحريك حسب الحاجة، انظر LayoutAnimation.js‎‎ للمزيد من المعلومات.

ملاحظات إضافية

requestAnimationFrame

requestAnimationFrame‎ شيفرة إملاءٍ (polyfill) تُستخدَم في الويب لتعويض نقص دعم المتصفحات، وقد تكون مألوفة لك. تقبل دالّةً كمُعاملٍ وحيدٍ وتستدعي الدّالة المُمرَّرة قبل إعادة الرسم (repaint) التّالي. وهي لَبِنَةٌ أساسيةٌ للتّحريكات تكمن وراء جميع واجهات التحريكات البرمجيّة المكتوبة بلغة ‎JavaScript‎. لا تحتاج عمومًا إلى استدعائها بنفسك لأنّ واجهات التحريكات البرمجيّة ستتكلّف بإدارة تحديثات الأطر (frame updates) عوضًا عنك.

setNativeProps

كما هو مذكور في قسم المعالجة المباشرة (Direct Manipulation)، تسمح لنا الدالّة ‎setNativeProps‎ بتعديل خاصيّات المكوّنات المدعومة من المنصّة الأصيلة (أي المكوّنات التي تعتمد على الواجهات الأصيلة [native views]، على عكس المكونات المركبّة [composite components]) مباشرةً، دون الحاجة إلى استدعاء ‎setState‎ وإعادة تصيير تسلسل المُكوّنات الهرميّ.

يُمكننا الاعتماد على هذه الميّزة في مثال الارتداد لتحديث المقياس، يُمكن لهذا أن يُساعد إذا كان المُكوّن الذي نُحدّثه متداخلًا تداخلًا عميقًا ولم يُحسَّن باستخدام ‎shouldComponentUpdate‎.

إذا رأيت أنّ التحريكات في تطبيقك تتجاوز بعض الأطر (بأداء أقلّ من 60 إطارًا في الثّانيّة)، فجرّب استخدام ‎setNativeProps‎ أو ‎shouldComponentUpdate‎ لتحسينها. أو يُمكنك تشغيل التحريكات على سلسلة واجهة المستخدم (UI thread) عوضًا عن سلسلة JavaScript باستخدام خيار ‎useNativeDriver‎. قد ترغب كذلك بتأجيل أي عمليّات تتطلّب الكثير من الحسابات إلى حين انتهاء التحريكات باستخدام مدير التفاعلات ‎InteractionManager‎. يُمكنك مراقبة معدّل الأطر باستخدام أداة "FPS Monitor" في قائمة المُطوّرين داخل التّطبيق (In-App Developer Menu).

مصادر