引言
三十功名尘与土,八千里路云和月。莫等闲,白了少年头,空悲切。——岳飞《满江红》
在脉脉做 React Native
也有一小段时间了,大概小半年吧,期间主要负责搜索相关的开发。和之前的 PC
端的开发不同的是,C
端(移动端)会更加注重用户的体验,而流程、有意义的动画对于移动用户的使用体验的提升,是非常重要的。
接下来,让我们来聊聊如何在 React Native
中,实现各种各样的小动画吧~
前置知识
如何完成一个基础的动画
创建动画,首先需要创建一个 Animated.Value
,将它连接到动画组件的一个或多个样式属性,然后使用Animated.timing()
通过动画效果展示数据的变化。
不要直接修改 Animated.Value
的值,应该使用 useRef Hook
或者 state
返回一个动画值的引用,然后使用 xxx.setValue(val)
来设置那种不需要动画过程的值;使用 Animated.timing
等函数来设置需要过程的动画。更加详细的教程、API解析还请移步官方文档,这里不再赘述。
可以设置什么样的属性用来呈现动画
下面是我最近开发的时候碰到的一个报错:
大致就是说,在 React Native
中,Native 模块不支持使用 height
属性来实现动画。翻了一下官方的博客,在 Using Native Driver for Animated 中有提到:只能使用 transform
和 opacity
这种非布局属性来实现动画,但是不支持使用 Flexbox
和 position
这种影响布局的样式属性来实现动画。
一些常用的示例
接下来,带大家看几个工作中常见的小动画:
最简单的动画:反馈提示 Message 组件
如上图,我们需要在进入这个页面的时候,在顶部弹出一个提示。下面是相关代码:
import React, { useEffect, useState, useRef } from 'react';
import { Animated, Easing, Text } from 'react-native';
function AnimatedMessage() {
const translateY = useRef(new Animated.Value(0)); // 初始化动画
const [display, setDisplay] = useState('flex');
useEffect(() => {
openAnimation(); // 成功加载组件之后,触发动画
}, []);
const openAnimation = () => {
// 触发第一阶段展开提示信息的动画
Animated.timing(translateY.current, {
toValue: 36,
duration: 1500,
easing: Easing.elastic(0.8),
}).start(closeAnimation);
};
const closeAnimation = () => {
// 触发第二阶段收起提示信息的动画
setTimeout(() => {
Animated.timing(translateY.current, {
toValue: 0,
duration: 1500,
easing: Easing.elastic(0.8),
}).start(() => {
// 彻底隐藏该元素
setDisplay('none');
});
}, 800);
};
const wrapperStyles = {
position: 'absolute',
top: 0,
left: 0,
width: '100%',
transform: [{ translateY: translateY.current }],
display,
};
return (
<Animated.View style={wrapperStyles}>
<Text
style={{
fontFamily: 'PingFangSC-Regular',
color: '#724804',
fontSize: 13,
}}
numberOfLines={1}
>
会员搜索权益,本次为你找到 999+ 个人脉
</Text>
</Animated.View>
);
}
export default AnimatedMessage;
我们来分解一些这个过程。
首先,我们有一个 Animated.View
组件,使用绝对定位,让它位于页面的顶部。我们可以通过修改 transform:translateY 属性来实现元素在 Y 轴的偏移。
组件完成初始化之后,调用 openAnimation
函数来触发动画的第一阶段。
在动画的第一阶段,我们使用 Animated.timing
修改元素的 translateY
值,0 -> 36,使得整个过程呈一个线性的变化。这样,我们就能看到整个元素从列表顶部慢慢的冒出来了。接下来,我们需要再让组件原路返回,在其 start
回调中,触发动画的第二阶段 closeAnimation
。
在动画的第二阶段,我们再次使用 Animated.timing
修改元素元素的 translateY
值,36 -> 0,这样,我们就可以看到组件慢慢的收起了。最后在其 start
回调中,修改 display:none
,彻底的隐藏元素。
组合动画:弹窗 Modal 组件
在我们日常工作中,弹窗是一个更加常见的使用场景。而官方的 Modal
组件只是一个可以盖在整个视图上的组件,其他的效果还需要开发者自行实现。首先,看下最终效果:
如上图,当点击「职位要求」之后,需要出现一个黑色半透明的遮罩,以及一个盖在上面的内容区块。下面是相关代码:
import React, { useEffect, useState, useRef } from 'react';
import {
Modal,
Dimensions,
Animated,
TouchableWithoutFeedback,
View,
Easing,
} from 'react-native';
import _ from 'underscore';
const screenHeight = Dimensions.get('screen').height;
function BasicModal(props) {
const {
visible = false,
// Wrapper
style = {},
// Body
bodyStyle = {},
// Mask
maskStyle,
onBodyLayout,
onClose,
} = props;
const [animatedVisible, setAnimatedVisible] = useState(visible);
// ========================= Anim =========================
const animRef = useRef({
backgroundOpacityValue: new Animated.Value(0),
transformYValue: new Animated.Value(0),
});
const updateAnimValue = val => {
animRef.current.backgroundOpacityValue.setValue(val);
animRef.current.transformYValue.setValue(val);
};
const showAnimating = () => {
updateAnimValue(0);
Animated.parallel([
Animated.timing(animRef.current.backgroundOpacityValue, {
toValue: 1,
duration: 300,
easing: Easing.easeIn,
useNativeDriver: true,
}),
Animated.spring(animRef.current.transformYValue, {
toValue: 1,
tension: 50,
friction: 8,
useNativeDriver: true,
}),
]).start();
setAnimatedVisible(true);
};
const hideAnimating = () => {
Animated.parallel([
Animated.timing(animRef.current.backgroundOpacityValue, {
toValue: 0,
duration: 100,
easing: Easing.linear,
useNativeDriver: true,
}),
Animated.spring(animRef.current.transformYValue, {
toValue: 0,
tension: 50,
friction: 7,
useNativeDriver: true,
}),
]).start();
setTimeout(() => {
setAnimatedVisible(false);
}, 200);
};
// ========================= Effect =========================
useEffect(() => {
if (visible) {
showAnimating();
} else {
hideAnimating();
}
return () => {};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [visible]);
// ========================= Event =========================
const handleClose = _.debounce(
() => {
hideAnimating();
onClose?.();
},
100,
true
);
// ========================= render =========================
const renderBackground = () => {
const bgOpacityAnim = animRef.current.backgroundOpacityValue.interpolate({
inputRange: [0, 1],
outputRange: [0, 0.8],
});
return (
<TouchableWithoutFeedback onPress={handleClose}>
<Animated.View
style={{
position: 'absolute',
opacity: bgOpacityAnim,
left: 0,
top: 0,
right: 0,
bottom: 0,
backgroundColor: '#333333',
...maskStyle,
}}
/>
</TouchableWithoutFeedback>
);
};
const renderPanelContent = () => {
const transformYValue = animRef.current.transformYValue.interpolate({
inputRange: [0, 1],
outputRange: [screenHeight, 0],
});
const contentStyle = {
...style,
};
return (
<Animated.View
style={{
transform: [
{
translateY: transformYValue,
},
],
position: 'absolute',
bottom: 30,
left: 16,
right: 16,
flex: 1,
}}
>
<Animated.View style={contentStyle}>
{/* Body */}
<View style={bodyStyle}>{props.children}</View>
</Animated.View>
</Animated.View>
);
};
const renderPanel = () => {
return (
<View style={{ flex: 1 }}>
{renderBackground()}
{renderPanelContent()}
</View>
);
};
return (
<Modal
visible={animatedVisible}
animationType={'none'}
transparent={true}
presentationStyle={'overFullScreen'}
onRequestClose={handleClose}
hardwareAccelerated={true}
>
<View
style={{ top: 0, left: 0, bottom: 0, right: 0, position: 'absolute' }}
onLayout={onBodyLayout}
/>
{renderPanel()}
</Modal>
);
}
export default BasicModal;
我们来分解一些整个过程。
这个组件的实现思路,是通过监听 props.visible
的改变,来触发弹窗「出现」「收起」的动画。
props.visible
= true 的时候,弹窗「出现」的动画 showAnimating
会被触发。这个动画由两部分组成,分别是底部的遮罩出现,透明度 0 -> 0.8;内容从底部弹出,Y轴 transform:translateY 屏幕高度 (Dimensions.get('screen').height)-> 0。
多个动画可以通过parallel
(同时执行)来实现。底部的遮罩的出现是一个线性的过程,使用的 Animated.timing()
函数即可实现;而内容的出现,有一个弹簧的效果,可以使用 Animated.spring()
来实现。
任何动画的过程,都可以理解为一个 0 -> 1 的过程。因此,当我们在设置 Animated
的动画函数时,都可以将 toValue
设置为 0 或者 1 作为动画开始和结束的值。然后在 render
函数中,调用 interpolate
将这个“标准”的输入,映射成实际的值。
当我们点击遮罩、底部按钮、右上角关闭按钮的时候,会传入 props.visible
= false 。弹窗「收起」的动画 hideAnimating
会被触发。
差值:跟随手势的 Tag
当我们在手机上浏览信息时,伴随着我们滑动的手势,在列表进行翻页的同时,也会触发一些动画。
如上图所示,当我们向下滑动时,会展开一些Tag;向上滑动时,会收起这些Tag。这里,就需要监听滚动事件,然后触发相关的动画,具体的代码如下:
import React, { useEffect, useState, useRef } from "react";
import {
Modal,
Dimensions,
Animated,
TouchableWithoutFeedback,
View,
Easing,
} from "react-native";
const screenHeight = Dimensions.get("screen").height;
function ScrollList(props) {
const {
visible = false,
// Wrapper
style = {},
// Body
bodyStyle = {},
// Mask
maskStyle,
onBodyLayout,
onClose,
} = props;
// ========================= Anim =========================
const animRef = useRef({
scrollY: new Animated.Value(0),
transformYValue: new Animated.Value(0),
translateY: null,
translateYListener: null,
});
// ========================= Effect =========================
useEffect(() => {
const scrollY = animRef.current.scrollY;
let tagHeight = 36;
let tagHeightInput = tagHeight;
// 根据不同平台,调整动画帧数
if (Platform.OS === "android") {
tagHeightInput *= 2;
} else {
tagHeightInput *= 1.2;
}
const scrollYClamped = diffClamp(scrollY, 0, tagHeightInput);
const translateY = scrollYClamped.interpolate({
inputRange: [0, tagHeightInput], // 修改动画帧数
outputRange: [0, -tagHeight],
});
animRef.current.translateYListener = translateY.addListener(({ value }) => {
animRef.current.translateYNumber.setValue(value);
});
animRef.current.translateY = translateY;
return () => {
animRef.current.translateY.removeListener(
animRef.current.translateYListener
);
};
}, []);
// ========================= Event =========================
// ========================= render =========================
const tagStyles = {};
const listStyles = {
flex: 1,
zIndex: -1,
};
// 当列表位置发生偏移的时候,需要增加一个负的 margin 来缩小列表的可视区域,否则会出现遮挡
if (animRef.current.translateYNumber) {
listStyles.marginBottom = animRef.current.translateYNumber;
}
tagStyles.transform = [{ translateY: animRef.current.translateY }];
listStyles.transform = [{ translateY: animRef.current.translateY }];
return (
<View style={{ flex: 1 }}>
<Animated.View style={tagStyles}>{tags}</Animated.View>
<Animated.ScrollView // <-- 使用可动画化的ScrollView组件
style={listStyles}
scrollEventThrottle={1} // <-- 设为1以确保滚动事件的触发频率足够密集
onScroll={Animated.event(
[
{
nativeEvent: {
contentOffset: { y: animRef.current.scrollY },
},
},
],
{ useNativeDriver: true } // <-- 加上这一行
)}
>
{content}
</Animated.ScrollView>
</View>
);
}
export default ScrollList;
我们来分解一些整个过程。
可以将整个页面分为2个区域,顶部的Tag,和下面的列表。
进入页面之后,为列表添加滚动事件的监听。因为列表以及顶部的Tag上下的偏移量有一个最大值,和最小值,如果超过这个值即使触发滚动事件也不会修改偏移量。
所以,可以使用 diffClamp
得到一个限制在最小、最大值的新的动画值。然后,使用 interpolation
计算出实际的偏移量。
需要注意的是,如果真机实际体验不佳,设置 interpolation
的时候,可以根据不同平台,调整动画帧数,增加 inputRange
的范围。
结语
在学习 React Native
之初,一个问题伴随着我,就是 React Native
都可以实现哪些动效?通过本文的梳理之后,也有了一个大致的答案。
本文更多的是抛砖引玉,和大家聊聊使用 React Native
实现动画的一些思路,上面例子中的代码,是从工程中脱敏之后总结的? ,并不是可以直接运行的Demo。
PS:对脉脉感兴趣的小伙伴,欢迎发送简历到 496691544@qq.com ,我可以帮忙内推~
参考
- Using Native Driver for Animated · React Native
- 动画 · React Native 中文网
- Animated · React Native 中文网
常见问题FAQ
- 免费下载或者VIP会员专享资源能否直接商用?
- 本站所有资源版权均属于原作者所有,这里所提供资源均只能用于参考学习用,请勿直接商用。若由于商用引起版权纠纷,一切责任均由使用者承担。更多说明请参考 VIP介绍。
- 提示下载完但解压或打开不了?
- 找不到素材资源介绍文章里的示例图片?
- 模板不会安装或需要功能定制以及二次开发?
发表评论
还没有评论,快来抢沙发吧!