التحريكات في 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).