ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

鸿蒙端React Native Animated组件上下滑动入场动画实现与优化

鸿蒙端React Native Animated组件上下滑动入场动画实现与优化 做鸿蒙端的React Native开发最头疼的其实不是业务逻辑而是那些看起来不起眼、但直接影响App质感的细节。今天聊的这个项目就是用Animated在鸿蒙端实现组件的上下滑动入场动画听着简单真正落地的时候涉及环境适配、动画时序、性能调优、生命周期协同一大堆问题。我从实际项目里把关键环节都拆出来包括完整的代码实现、参数计算思路以及几个鸿蒙端特有的坑希望对正在做RN鸿蒙化的朋友有参考价值。1. 先把这个项目要解决的事说清楚1.1 为什么是React Native又是为什么选鸿蒙跨平台开发从来不是一道选择题而是一道资源分配题。一个App同时要覆盖iOS、Android现在又多了一个鸿蒙如果三个端各写一套原生代码光UI细节就够团队喝一壶的。React Native在这里的价值是业务逻辑、状态管理、大部分UI层可以复用只需要针对各端特性做适配层。鸿蒙端的RN适配现在已经不是概念阶段了社区有专门的鸿蒙化RN套件支持很多业务组件可以直接跑起来。我参与的这个项目目标是在鸿蒙App里实现一个从底部滑入的筛选面板组件。这种组件在电商、资讯类App里非常常见用户点击筛选按钮面板从屏幕底部平滑升起加载数据呈现内容。之所以选Animated而不是其他动画方案是因为Animated是RN官方内置的动画系统跨端一致性最好不依赖额外原生库在鸿蒙适配过程中少一层不确定性。1.2 Animated在鸿蒙端的定位和适用边界Animated的核心思路是用声明式的方式描述动画你定义动画的初始值、目标值、持续时间和缓动函数然后系统在每一帧回调中更新样式值。对于上下滑动入场这种位移透明度组合动画Animated的timing实例就能覆盖百分之九十的场景。但要注意边界。Animated擅长的是典型的UI动效比如入场、退场、拖拽跟随、透明度渐变这类。如果要做粒子效果、复杂物理仿真、逐帧动画这样的重活还是得考虑react-native-reanimated或者原生能力。鸿蒙端的RN生态还在完善中使用Animated这种内置方案的最大好处就是避开了第三方动画库在鸿蒙上的兼容性风险。实测下来Animated在鸿蒙端的基础动画能力表现是稳定的。2. 工程准备与设计思路拆解2.1 鸿蒙端React Native环境的搭建先说环境。鸿蒙端跑RN目前主流路径是通过鸿蒙化的RN SDK配合DevEco Studio来构建原生工程。整体思路是先用RN脚手架创建跨端工程然后生成鸿蒙原生工程最后把RN的js bundle接入到鸿蒙壳工程中。关键步骤大概是这几步安装React Native脚手架创建基础工程。安装鸿蒙化RN所需的npm依赖包这里要注意版本必须匹配RN版本和鸿蒙适配版本不能随意混搭。在工程中引入鸿蒙原生工程模板通常是通过脚手架命令生成。用DevEco Studio打开鸿蒙工程配置签名、权限和模块依赖。把JS侧代码打包成bundle文件放到鸿蒙工程的资源目录下或者通过本地服务加载。这一步最常见的坑是版本匹配。RN的版本、鸿蒙适配SDK的版本、DevEco Studio的版本三者必须形成一个稳定的组合。我刚开始搭建时就因为RN版本过高导致鸿蒙适配包编译报错最后回退版本才解决。建议在项目启动前就去查一下当前鸿蒙化RN套件官方支持的RN版本范围锁定版本组合再动手。还有一个容易忽略的细节鸿蒙工程的网络权限配置。如果js bundle是通过本地服务加载的需要在鸿蒙工程的module.json5里配置网络访问权限否则App启动后会白屏或者找不到bundle。2.2 入场动画的状态设计动画不只是一段代码它是一套状态流转。以底部筛选面板为例面板有三种状态关闭态、入场中、完全展开。设计动画时对应的状态序列是触发入场前面板的translateY等于面板自身高度完全隐藏在屏幕外。点击按钮后translateY从面板高度逐渐变为0同时opacity从0变为1。动画结束后面板停留在translateY为0的位置也就是完全可见状态。为什么要把动画和状态分离因为单纯的动画代码如果耦合在业务逻辑里后续加需求时就会很痛苦。比如面板需要支持中途关闭、需要在动画过程中处理快速连点问题如果没有清晰的状态定义这些边界Case会很难处理。我习惯先定义一个状态机明确每个状态下组件的样式值和交互能力状态translateY值opacity值交互能力关闭面板高度0不可交互入场中面板高度→00→1忽略交互完全展开01正常交互这个表格很好地指导了代码实现。状态机明确了之后动画其实就是几个状态之间的样式value切换代码结构会清晰很多。2.3 Animated的几个核心概念先理清在贴代码之前有几个Animated的概念必须理清Animated.Value、Animated.timing、Animated.View。Value是动画的驱动源它的变化会通过原生驱动或者JS驱动来通知视图刷新。Timing是描述Value随时间变化的函数你需要给它配置toValue、duration、easing、useNativeDriver这几个关键参数。Animated.View则是可参与动画的View组件它接收style中带有动画值的样式。有一个重点easing的选择直接影响动画的质感。底部面板入场用Easing.out(Easing.quad)或者Easing.cubic都是不错的选择它的特点是先快后慢视觉上很符合面板从底部弹起的物理感受。如果全部用线性函数动画会显得僵硬特别是面板完全展开那一刻会有一种突兀的“撞停”感。另外useNativeDriver这个参数一定要设为true。这里有个原因在鸿蒙端RN动画能不能走原生驱动通道直接决定了动画是否流畅。原生驱动意味着动画在原生侧完成属性更新不经过JS桥性能提升明显。对于translateY和opacity这类纯样式属性原生驱动是完全可以支持的。3. 核心实现上下滑动入场动画的完整落地3.1 组件结构设计实现动画之前先把组件结构设计好。我采用的方案是封装一个通用的SlideUpPanel组件对外暴露visible和onClose两个props内部自行处理动画逻辑。这样设计的好处是业务侧不需要关心动画细节只需要控制visible值。组件内部结构分为三层底层是遮罩层半透明黑色背景中间是面板容器层承载translateY动画顶层是面板内容区业务侧传入的children。遮罩层通常还要处理点击关闭的功能以及入场退场的透明度动画。关于遮罩层有两点需要特别注意遮罩层的透明度动画和面板的位移动画应该并行触发而不是串联。如果串行用户会看到遮罩先变暗、面板再滑入的分离感很不自然。遮罩层本身也要做动画值驱动不能用固定的半透明样式。原因是通过动画值驱动可以让遮罩和面板在时间轴上同步并且退场时遮罩能平滑淡出而不是瞬间消失。面板容器层和内容区为什么要分开因为面板的圆角、阴影、背景色这些样式如果直接放在动画容器上在某些状态下可能会触发不必要的重绘。单独拆一层动画容器只负责位移内容区负责视觉表现职责清晰性能也好。3.2 核心动画代码实现下面是完整的代码实现。我以函数组件配合Hooks的方式来写这样更符合目前RN社区的主流写法。import React, { useEffect, useRef } from react; import { Animated, Dimensions, Easing, TouchableWithoutFeedback, View, StyleSheet, } from react-native; import { useSafeAreaInsets } from react-native-safe-area-context; const { height: SCREEN_HEIGHT } Dimensions.get(window); const SlideUpPanel ({ visible, onClose, children, panelHeight SCREEN_HEIGHT * 0.5 }) { const translateY useRef(new Animated.Value(panelHeight)).current; const opacity useRef(new Animated.Value(0)).current; const insets useSafeAreaInsets(); useEffect(() { if (visible) { startEnterAnimation(); } else { startExitAnimation(); } }, [visible]); const startEnterAnimation () { Animated.parallel([ Animated.timing(translateY, { toValue: 0, duration: 320, easing: Easing.out(Easing.quad), useNativeDriver: true, }), Animated.timing(opacity, { toValue: 1, duration: 280, easing: Easing.out(Easing.quad), useNativeDriver: true, }), ]).start(); }; const startExitAnimation () { Animated.parallel([ Animated.timing(translateY, { toValue: panelHeight, duration: 220, easing: Easing.in(Easing.quad), useNativeDriver: true, }), Animated.timing(opacity, { toValue: 0, duration: 220, easing: Easing.in(Easing.quad), useNativeDriver: true, }), ]).start(); }; if (!visible) { return null; } return ( View style{StyleSheet.absoluteFill} TouchableWithoutFeedback onPress{onClose} Animated.View style{[ styles.mask, { opacity, backgroundColor: rgba(0, 0, 0, 0.5), }, ]} / /TouchableWithoutFeedback Animated.View style{[ styles.panelContainer, { height: panelHeight, transform: [{ translateY }], paddingBottom: insets.bottom, }, ]} {children} /Animated.View /View ); }; const styles StyleSheet.create({ mask: { flex: 1, backgroundColor: rgba(0, 0, 0, 0.5), }, panelContainer: { position: absolute, left: 0, right: 0, bottom: 0, backgroundColor: #ffffff, borderTopLeftRadius: 16, borderTopRightRadius: 16, overflow: hidden, }, }); export default SlideUpPanel;这段代码有几个细节值得展开说。第一个是动画并联的时序。入场动画里translateY和opacity都是Animated.parallel并行执行但透明度动画的duration短一些280ms vs 320ms效果是面板滑入到大约百分之八十几的时候就基本完全可见最后一段距离是纯位移。这个细微的时长差让整个入场动作更有层次而不是所有属性同步到终点。第二个是退场动画。注意我用了Easing.in(Easing.quad)和入场的方向正好相反。退场是用户主动关闭视觉上应该让面板快速离开屏幕配合重力感而不是慢悠悠地消失。duration也缩短到220ms保证交互反馈的及时性。第三个是if (!visible) return null的位置。这里有个设计考量如果直接把整个组件卸载下次visible变为true时动画就是从初始值重新开始的。因为useRef创建的动画值在组件卸载时会丢失。但translationY和opacity的初始值都定义在useRef创建时所以重新挂载后会自动回到起始状态。关于这个设计方案需要特别说明一下上述代码是我在实际项目中采用的方案。把visible为false时直接返回null好处是面板完全关闭时不占用视图层级不会影响底下列表的触摸事件。如果业务要求面板关闭后仍然保留在视图树中比如需要监听某些状态变化那就不能直接return null而是要改成在动画结束后再隐藏。3.3 组件使用侧的控制逻辑组件写好之后使用侧的逻辑同样重要。这涉及到父组件如何控制子组件的动画状态、如何避免快速连点带来的冲突。const [panelVisible, setPanelVisible] useState(false); const isAnimatingRef useRef(false); const handleOpenPanel () { if (isAnimatingRef.current) { return; } isAnimatingRef.current true; setPanelVisible(true); }; const handleClosePanel () { if (isAnimatingRef.current) { return; } isAnimatingRef.current true; setPanelVisible(false); }; const handleAnimationEnd () { isAnimatingRef.current false; };这段代码里用了一个isAnimatingRef来防止动画过程中的重复触发。它的原理是在动画进行期间任何新的打开或关闭操作都被直接忽略只有当前动画结束后才能进行下一次操作。为什么不做成“动画进行中点击可以反向”的效果因为对于筛选面板这种场景用户不会频繁开关快速连点时屏蔽掉反而更稳健。如果做的是类似抽屉那种需要随时反方向响应的组件才需要考虑打断动画的逻辑。这里有一个关于Animated动画结束回调的易错点Animated.parallel().start()的回调函数在动画被中途打断的情况下会收到一个{ finished: false }的参数。如果你不在结束回调里根据finished状态来判断是否重置isAnimatingRef就会遇到一个经典问题——动画被打断后组件再也无法触发新的动画。我在实际使用中踩过这个坑处理方式是const startEnterAnimation () { Animated.parallel([...]).start(({ finished }) { if (finished) { isAnimatingRef.current false; } }); };只有动画正常走完才释放交互锁。如果动画被中断保持锁定状态等下一次完整动画结束再解锁。3.4 与页面生命周期配合的细节把动画组件嵌入到真实页面中还需要考虑页面生命周期的影响。比如一个列表页用户滚到一半调出筛选面板此时如果列表继续滚动面板悬浮在上方视觉上会非常乱。所以通常的做法是面板打开的同时给列表加上scrollEnabled{false}关闭后再恢复。另一个细节是Android物理返回键的处理。鸿蒙系统同样有物理返回手势或返回键用户如果按了返回应该关闭面板而不是退出页面。这个逻辑需要在页面级去处理。在RN里可以通过BackHandler这个API监听返回事件根据面板当前状态决定拦截还是放行。useEffect(() { const subscription BackHandler.addEventListener(hardwareBackPress, () { if (panelVisible) { handleClosePanel(); return true; } return false; }); return () subscription.remove(); }, [panelVisible]);这段代码放在使用SlideUpPanel的父组件中。返回键按下时如果面板处于打开状态就触发关闭逻辑并返回true表示消费了这次事件否则返回false让事件继续传递给系统执行正常的页面退出。还有一个与生命周期相关的优化点如果在面板入场动画还没结束时用户就按了返回键此时panelVisible已经是true关闭逻辑可以正常触发。但由于isAnimatingRef锁的存在关闭动作会被忽略。这里会有一个可感知的延迟——用户要等入场动画播完再按一次返回才能关闭。比较顺畅的做法是在handleClosePanel里检测到正在动画中时先重置动画值再关闭但实现复杂度会上来。对于筛选面板这类轻交互场景保持锁其实是可以接受的。4. 鸿蒙端适配实战性能和兼容性的几个重点问题4.1 关于启动白屏和首帧渲染很多人在鸿蒙端跑RN应用时遇到的第一个问题是启动白屏。搜索热词里也有“React Native启动白屏”这个关键词这不是个小众问题。白屏的原因通常有两个一是JS bundle的加载时机问题二是视图渲染发生在JS bundle执行完成之前。回到Animated本身入场动画和白屏的关系在于如果你的组件在页面刚加载时就自动播放入场动画而这个时候bundle还没完全执行到动画代码就会出现用户看到页面空白几秒然后突然弹出内容的观感。解决思路是把入场动画的触发时机从useEffect的立即执行改为页面加载完成后再触发。更简单的方案是保证App启动时先展示原生侧提供的启动图等RN侧的第一帧准备好之后再移除启动图。鸿蒙端的RN适配中这个启动图移除事件通常是通过一个原生的SplashScreen模块来控制的。RN侧的页面在你自己的代码里可控但启动图需要调用原生模块来关闭。在首次打开包含入场动画的页面时我会确认启动图已经被关闭否则动画会被原生启动图的遮蔽挡住用户感知到的就是“卡了一下”而不是顺滑的入场。4.2 动画性能在鸿蒙端的实测表现动画性能是跨端开发中最容易出问题的一环。文字上说说“性能很好”没有意义我在鸿蒙端的真机上实测了几个关键指标。默认配置下底部面板入场动画240ms的duration使用原生驱动整机帧率能稳定在55-60帧左右。如果在动画过程中同时加载大量列表数据帧率会掉到40帧左右但不太会出现明显卡顿。这里的关键优化点是动画期间避免在主线程做重的JS运算。鸿蒙端的RN框架和Android端类似JS线程和UI线程的职责分离但JS线程的阻塞仍会间接影响动画的调度。在面板滑入的过程中我会避免做这类操作避免在动画开始的同一帧内执行复杂的数组过滤或排序。避免在动画期间触发setState导致大列表重渲染。避免在面板内同步加载网络图片图片解码会占用UI线程资源。如果确实需要在面板显示后加载数据我会把数据加载延迟到动画结束的回调中触发和startAnimation(() loadData())这种模式配合。4.3 常见问题速查表和很多老开发聊天时发现大家在鸿蒙端跑RN动画时遇到的问题挺集中的我整理了一个速查表供参考。问题现象根本原因解决方案动画卡顿严重面板一帧一帧跳useNativeDriver未设置为true检查动画值驱动的属性是否支持原生驱动translateY和opacity原生驱动没问题动画不生效面板瞬间出现动画值初始化和目标值相等确认入场动画的起点值是面板高度的正数而不是0快速开关面板后无法再次打开start回调未判断finished状态在start回调中判断{ finished }只在finished为true时释放交互锁遮罩透明度突变opacity未走Animated而是直接给样式遮罩的opacity也必须由动画值驱动鸿蒙端构建成功但启动白屏网络权限未配置或bundle路径不对检查module.json5的权限配置以及bundle资源路径退出动画过程中点击屏幕无遮挡退场时遮罩层提前卸载确保visible状态在动画完全结束前不改变通过动画回调再更新真正的visible状态这里多说一句退场时遮罩层提前卸载的问题。有些人在关闭面板时直接把visible置为false组件内部于是立即return null遮罩和面板瞬间消失退场动画根本没机会执行。正确的做法是visible控制的是“是否应该显示”但组件内部还需要一个renderContent的状态来区分“动画进行中”和“真正卸载”。我自己封装时一般会加一个isRendered的ref在退场动画完全结束后再置为false实现真正的延迟卸载。4.4 一个容易被忽略的细节transform方向在鸿蒙端实现上下滑动入场时还要注意transform的translateY方向。RN和Web的坐标系统一致Y轴向下为正方向。所以从底部滑入translateY的初始值应该是正值等于面板高度目标值是0。如果你是从顶部滑入初始值就应该是负的面板高度目标值同样为0。这个方向搞反了的话面板会从上方以奇怪的角度掉进来或者干脆从屏幕底部向下滑出。另外在鸿蒙端的实际渲染中如果面板高度使用了百分比计算而父容器高度不确定可能会出现translateY初始值超过实际屏幕高度导致动画初帧面板位置异常的问题。我习惯用Dimensions.get(window).height来兜底同时允许业务侧传入自定义高度不依赖百分比。5. 从入场动画到组件复用5.1 一个动画组件的边界在哪里SlideUpPanel做出来之后它的应用场景远远不止筛选面板。任何从底部弹出的浮层都可以复用分享面板、操作菜单、商品详情卡片、登录引导、公告弹窗。这是组件化的价值所在——动画逻辑封装一次业务侧只需要通过visible和children来使用。但组件化也有边界。不要试图让一个组件支撑所有场景。比如如果你的业务需要支持入场动画播放时的手势拖拽跟手关闭那么当前这个基于Animated.timing的实现就不够了你需要引入Animated.event配合PanResponder或者直接上手势响应系统。类似的还有“展开到一半自动回弹”这种带边界条件的动画这些需求都不适合硬塞进同一个组件里。硬塞的结果往往是props接口爆炸组件内部if-else堆成山团队没人愿意改。5.2 参数化配置的思路为了让SlideUpPanel能覆盖更多场景可以预留几个参数动画时长、缓动函数、面板高度、是否启用遮罩。但参数也不能无限制地加有些东西应该约定死而不是暴露出去。我的原则是时长给默认值但允许覆盖缓动函数只提供两三个预设选项不开放任意函数面板高度是必传项设计为默认值加可覆盖。这里有一个关于时长的经验值。入场动画超过400ms就会让人明显感觉到“慢”低于200ms又会显得仓促。底部面板的入场最舒服的区间是250ms到350ms之间。我最终选320ms是基于一个考虑面板高度通常占据半屏以上位移距离大太短的时间会让面板的运动显得“赶”太长又拖沓。退场动画则应该比入场略短。这是交互设计里的一个通用原则——用户主动发起的关闭动作应该得到快速响应。220ms的退场时长在实际观感上是干净利落的。5.3 与组件通信机制的衔接RN开发中的组件通信也是一个绕不开的话题。从父组件到子组件props传递就够用了。从子组件向父组件通信通常用回调函数。SlideUpPanel关闭时父组件需要知道这个结果来更新自己的状态这就是onClose这个回调存在的意义。但在复杂场景下比如面板内部有多个筛选条件用户更改条件后需要实时通知父组件更新列表数据这个时候回调层层传递会让人抓狂。我的经验是面板这种一次性挂载、按需显隐的组件用简单的回调就足够了真正复杂的状态共享交给Context或者全局状态管理去处理不要在组件树上用props连续穿透传递四五层。鸿蒙端的RN开发在这方面和Android、iOS端没有区别组件通信的选型更多取决于业务复杂度。入场动画组件本身不涉及深层通信需求保持接口简洁就行。6. 最后再分享几点实际体会这个项目最深的感触是动画在跨端开发里是最容易被低估的模块。产品需求文档里经常就一句话“加一个入场动画吧”但落地时涉及原生驱动、生命周期时序、状态锁、性能优化、兼容性测试这么多层面的问题。如果只是随便写个Animated.timing串在上面看起来能用真正上真机体验差距一下就出来了。我建议在做鸿蒙端RN动画时养成几个习惯所有动画值都用useRef包裹不要在渲染方法里直接new Animated.Value否则每次重渲染都会重建动画对象表现为动画不连贯。动画结束回调一定用{ finished }判断状态不要无条件重置交互锁。需要原生驱动的动画属性transform、opacity首选useNativeDriver如果要动画的样式属性不支持原生驱动比如height要么改用法要么换用布局动画。鸿蒙端的适配版本一定要锁定不要手贱频繁升级RN版本特别是在项目中期升级带来的兼容性排查成本比收益大得多。动画组件的属性要“能简则简”能约定死就不开放配置避免做成大杂烩。还有一个小技巧开发调试动画时可以在代码里临时把duration调成原来的3到5倍这样能肉眼看清楚每一帧的位置变化方便调试运动轨迹是否合理。调完再改回去不会有任何副作用。像是入场动画里如果面板轨迹“走了一个弧形”大概率是translateY和opacity的时序没配合好调慢duration很快就能发现原因。这个SlideUpPanel组件现在还在持续演进后续计划是在内部加入手势拖拽关闭的支持让面板在展开状态下可以通过向下拖拽来退场配合Animated.event来实现跟手效果。思路已经有了等落地上线后再来补一篇实践笔记。
返回列表