从给产品做一个旋转Loading图标开始,我当时直接用 Animated 加上 transform 里的 rotate 属性就实现了,觉得挺简单。直到后来遇到卡片翻转、侧滑菜单、3D轮播这类需求,才发现 transform 在鸿蒙上其实有不少坑。最典型的一个坑是:transform 会改变组件的视觉位置,但不会改变它的布局位置。你用 translateX 把组件移到了右边,组件原来占据的布局空间还在,结果就是它跟后面的组件叠在一起。我一开始没意识到这一点,排查了很久才发现问题。另一个容易踩的是多个 transform 的顺序会影响最终效果:先 translate 再 rotate,和先 rotate 再 translate,表现完全不同。
一、transform 基础语法
transform 是 RN 组件上的一个样式属性,用来做 2D 或 3D 变换。基础写法和 CSS 类似,但 RN 在很长一段时间里只支持数组语法,每个数组元素只能是一个键值对对象。如果你图省事写成 { scale: 1.5, rotate: '45deg' },在 TypeScript 里会直接报错。RN 0.82 之后还新增了字符串语法,写法跟 CSS 保持一致,两种写法效果等价。我个人更推荐数组语法,因为 TypeScript 的类型推断更友好,字符串语法在拼接动态值的时候容易出错(比如忘了加单位)。
- // 数组语法
- const style1 = { transform: [{ scale: 1.5 }, { rotate: '45deg' }] };
- // 字符串语法(RN 0.82+,部分 RNOH 版本可能未实现)
- const style2 = { transform: 'scale(1.5) rotate(45deg)' };
复制代码
二、常用变换类型
scale / scaleX / scaleY 控制缩放。scale 为 1 时不变,大于 1 是放大,小于 1 是缩小,0 会让组件直接不可见。rotate / rotateX / rotateY / rotateZ 控制旋转。rotate 等价于 rotateZ,都是绕 Z 轴转;rotateX 和 rotateY 要想看到立体感,必须配合 perspective 一起使用。角度单位支持 deg 和 rad,比如 45deg 也可以写成 0.785rad。translateX / translateY 做位移,数值单位是 dp,跟 RN 布局单位一致。skewX / skewY 做倾斜,会让文字看起来变斜。perspective 设置透视距离,值越大 3D 效果越弱,一般 500 到 1000 之间比较自然。matrix 则是一个 6 个值的数组,表示 2D 变换矩阵,除非你在做复杂的图形计算,否则用不上,日常组合 scale/rotate/translate 就够了。
三、变换顺序和 transformOrigin
多个变换按数组顺序依次应用,顺序不同结果完全不同。比如先 translateX(50) 再 rotate(45deg),是先向右移 50 再绕原点旋转;而先 rotate(45deg) 再 translateX(50),是沿着旋转后的 X 轴方向移动 50。我做过一个卡片飞入动画,想让卡片从屏幕右侧飞入同时旋转,一开始写反了,结果卡片沿着旋转后的坐标轴跑偏,正确的做法是先把 translateX 放在 rotate 前面,让卡片先平移到位再旋转。
transformOrigin 设置变换参考点,默认是 center。它支持关键词(top、left、center、bottom、right 等)、px 值、百分比,还支持带 Z 轴的三值写法。在鸿蒙上,如果你想做"从某个角展开"的动画,transformOrigin 是核心。比如弹窗从右上角展开,就设置 transformOrigin: 'top right',再配合 scale 动画。transformOrigin 也可以用数组语法(如 [10, '20%', 0]),配合 Animated API 时不用拼接字符串,传数值更方便,性能也更好。
四、鸿蒙上的实战写法
基于上面这些原理,我在鸿蒙设备上验证了几个常用动效组件,可以直接套用。
卡片翻转:用两个 Animated.View 叠放,正面从 0 转到 180 度,背面从 180 转到 360 度,同时设置 backfaceVisibility: 'hidden' 防止背面在旋转过程中露出来,中间加 perspective 让翻转有立体感。
- const FlipCard = ({ front, back }) => {
- const [flipped, setFlipped] = useState(false);
- const animatedValue = useRef(new Animated.Value(0)).current;
- const flip = () => {
- const toValue = flipped ? 0 : 1;
- Animated.spring(animatedValue, {
- toValue,
- friction: 8,
- tension: 10,
- useNativeDriver: true,
- }).start();
- setFlipped(!flipped);
- };
- const frontInterpolate = animatedValue.interpolate({
- inputRange: [0, 0.5, 1],
- outputRange: ['0deg', '90deg', '180deg'],
- });
- const backInterpolate = animatedValue.interpolate({
- inputRange: [0, 1],
- outputRange: ['180deg', '360deg'],
- });
- return (
- <Pressable onPress={flip}>
- <View>
- <Animated.View style={{
- position: 'absolute',
- backfaceVisibility: 'hidden',
- transform: [{ perspective: 1000 }, { rotateY: frontInterpolate }],
- }}>
- {front}
- </Animated.View>
- <Animated.View style={{
- backfaceVisibility: 'hidden',
- transform: [{ perspective: 1000 }, { rotateY: backInterpolate }],
- }}>
- {back}
- </Animated.View>
- </View>
- </Pressable>
- );
- };
复制代码
缩放按钮:按下时缩到 0.95,松开弹回 1.0。这是 Material Design 里常见的按钮反馈,用 Animated.spring 实现,注意 useNativeDriver 打开。
- const ScaleButton = ({ title, onPress }) => {
- const scaleAnim = useRef(new Animated.Value(1)).current;
- const handlePressIn = () => {
- Animated.spring(scaleAnim, { toValue: 0.95, useNativeDriver: true }).start();
- };
- const handlePressOut = () => {
- Animated.spring(scaleAnim, { toValue: 1, friction: 3, tension: 40, useNativeDriver: true }).start();
- };
- return (
- <Pressable onPress={onPress} onPressIn={handlePressIn} onPressOut={handlePressOut}>
- <Animated.View style={{
- transform: [{ scale: scaleAnim }],
- backgroundColor: '#0A59F7',
- borderRadius: 8,
- padding: 14,
- }}>
- <Text style={{ color: '#FFF', fontWeight: '600' }}>{title}</Text>
- </Animated.View>
- </Pressable>
- );
- };
复制代码
侧滑菜单:本质是 translateX 的位移动画,菜单宽度设成 300dp,隐藏时移到 -300,显示时回到 0。这里 transform 不影响布局这一特性反而要用好,因为菜单本身就是 absolute 定位浮在上层的。
- const SlideMenu = ({ visible, children }) => {
- const translateX = useRef(new Animated.Value(-300)).current;
- useEffect(() => {
- Animated.timing(translateX, {
- toValue: visible ? 0 : -300,
- duration: 250,
- useNativeDriver: true,
- }).start();
- }, [visible, translateX]);
- return (
- <Animated.View style={{
- position: 'absolute',
- left: 0, top: 0, bottom: 0,
- width: 300,
- backgroundColor: '#FFF',
- transform: [{ translateX }],
- elevation: 5,
- }}>
- {children}
- </Animated.View>
- );
- };
复制代码
抖动效果:用 Animated.sequence 串联多段 translateX 动画,给 pressable 包一层,适合做输入校验失败或操作错误时的反馈。
- const ShakeView = ({ children }) => {
- const shakeAnim = useRef(new Animated.Value(0)).current;
- const shake = () => {
- Animated.sequence([
- Animated.timing(shakeAnim, { toValue: 10, duration: 50, useNativeDriver: true }),
- Animated.timing(shakeAnim, { toValue: -10, duration: 50, useNativeDriver: true }),
- Animated.timing(shakeAnim, { toValue: 8, duration: 50, useNativeDriver: true }),
- Animated.timing(shakeAnim, { toValue: -8, duration: 50, useNativeDriver: true }),
- Animated.timing(shakeAnim, { toValue: 0, duration: 50, useNativeDriver: true }),
- ]).start();
- };
- return (
- <Pressable onPress={shake}>
- <Animated.View style={{ transform: [{ translateX: shakeAnim }] }}>
- {children}
- </Animated.View>
- </Pressable>
- );
- };
复制代码
弹窗展开:transformOrigin 设为 'top right',scale 从 0 弹到 1,视觉上就像从页面右上角的位置长出来。
- const ExpandableMenu = ({ visible }) => {
- const scaleAnim = useRef(new Animated.Value(0)).current;
- useEffect(() => {
- Animated.spring(scaleAnim, { toValue: visible ? 1 : 0, useNativeDriver: true }).start();
- }, [visible]);
- return (
- <Animated.View style={{
- transformOrigin: 'top right',
- transform: [{ scale: scaleAnim }],
- backgroundColor: '#FFF',
- borderRadius: 8,
- padding: 16,
- elevation: 5,
- }}>
- <Text>菜单项 1</Text>
- <Text>菜单项 2</Text>
- <Text>菜单项 3</Text>
- </Animated.View>
- );
- };
复制代码
五、鸿蒙上的专属坑
鸿蒙的 RN 运行时(RNOH)整体在进步,但在 transform 上有四个比较典型的坑需要提前规避。
第一个是 3D 变换在部分设备上会退化。rotateX / rotateY 配合 perspective 的立体效果依赖 GPU 能力,低端鸿蒙设备上很可能退化成平面变换,看起来只是宽度被压缩了。这种场景建议做能力降级:检测到设备不支持 3D 时,退化为 2D 效果,保证功能可用。
第二个是 transformOrigin 的百分比值在部分 RNOH 版本上支持不完善。比如 '30% 50%' 可能在部分设备上不生效,建议改用关键词或 px 值,比如 'center' 或 '20px 40px',兼容性更好。
第三个是 backfaceVisibility 在部分鸿蒙设备上可能无效。这直接影响卡片翻转效果,如果翻转时背面露出来,先确认是不是这个属性没生效,必要时换一种实现方案(比如透明度过渡)。
第四个是字符串语法的兼容性。RN 0.82 新增的 transform: 'scale(1.5) rotate(45deg)' 写法,在某些 RNOH 版本上可能还没有实现,建议在鸿蒙上继续使用数组语法,避免踩兼容问题。
六、总结与建议
回顾这段鸿蒙开发经历,踩坑经验可以浓缩成几条:transform 不影响布局位置;变换顺序影响结果;perspective 是 3D 效果的灵魂,不加就没有立体感;backfaceVisibility 需要设为 hidden 才能配合卡片翻转;3D 变换在低端设备上可能退化为平面;transformOrigin 默认是 center,改了会影响所有变换的参考点。
给后来者几条建议:写复杂动画前先把变换顺序想清楚,先平移还是先旋转取决于你要的视觉效果;3D 效果记得加 perspective,一般在 500 到 1000 之间;性能敏感的场景尽量用 useNativeDriver,translate、rotate、scale 都支持原生驱动;低端鸿蒙设备做好 3D 降级为 2D 的方案;transformOrigin 优先用关键词或 px 值,百分比在鸿蒙上兼容性差一些。
本文基于 React Native 0.84 + RNOH 0.84.1 编写,测试设备为 HarmonyOS 6.0。文中代码示例均在鸿蒙设备上验证通过,可直接使用,但不同版本之间仍可能存在差异,最终以你手上的真机测试结果为准。 |