Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

react native高级应用之手势动画 #60

Open
wuyunqiang opened this issue May 27, 2024 · 0 comments
Open

react native高级应用之手势动画 #60

wuyunqiang opened this issue May 27, 2024 · 0 comments

Comments

@wuyunqiang
Copy link
Owner

你将掌握的知识点:

  1. Animated动画库使用方式
  2. 布局与定位能力
  3. 如何借用ScrollView的滚动能力
  4. PanResponder手势系统
  5. Touchable系列组件
  6. 点击事件 & 手势冲突如何解决

效果展示:

4.gif

9.gif

完整代码:
react-native-radius-view

功能分析

  1. 需要支持点击切换的能力
  2. 需要支持滑动滚动的能力
  3. 当滚动时需要依次滚动到上一个组件的位置。
  4. 可以跨多个组件滚动 当一侧没有内容时 不可以在滚动。
  5. 整体的布局可以分为中心点 然后右侧区域 左侧区域 交替填充数据。
  6. 数据的内容并不是无限的范围【1,9】

整体排列如图:
截屏2024-05-20 下午2.50.41.png

需要解决的问题

  1. 如何实现点击和手势滑动逻辑
  2. 如何确定将要滚动到那个位置
    例如用户向左滑动一次 0->2的位置 2->4 4->6 6->8 8已经在最边缘了保持不变。右侧同理 依次滚动到前面的空位。
  3. 左右滑动的边界在哪里
  4. 滚动的动画如何实现

问题1:如何实现点击和手势滑动

通过Touchable & scrollview组合

当基于滚动行为做一些逻辑或者动画时 我们首先想到的肯定是基于react native官方提供的已有的组件。在这个场景下是scrollview。那么他是否可以实现我们的需求呢?

scrollview滚动需要满足内容大于视口的条件。这一点我们的场景是不满足的。但是我们可以通过填充空白元素 撑起scrollview的内容 使用组件的滚动能力。然后通过绝对定位 将元素定位到页面的第一屏。可以保证同时响应滚动和点击的能力。

如图所示:
截屏2024-05-20 下午5.24.38.png

问题:scrollview的内容即使设置了绝对定位 依然会跟随页面滚动。后发现文档里有stickyHeaderIndices属性可以设置不随滚动移动 然鹅... 不支持与属性horizontal={true}一起使用。。。

通过Touchable & PanResponder一起使用

PanResponder

在 React Native 中,PanResponder 是用来处理用户手势的系统。它提供了一系列的 API,通过这些 API,你可以定义如何响应用户的拖拽、滑动等手势操作。
核心代码如下:

    const panResponder = PanResponder.create({
        // 其他东西想要成为响应者。这个视图应该释放响应者吗
        onPanResponderTerminationRequest: () => true,
        // 设置成为响应者
        onStartShouldSetPanResponder: () => false,
        onMoveShouldSetPanResponder(evt, gestureState) {
            // 取消的情况gestureState数据会被清空 这里保存状态
            if (cycleList.length > 1 && Math.abs(gestureState.dx) > 15) {
                panDx.current = { ...gestureState };
                return true;
            }
            return false;
        },
        onStartShouldSetPanResponderCapture: () => false,
        // 抬起
        onPanResponderRelease(evt, gestureState) {
            onTouchEnd(gestureState);
        },
        // 取消
        onPanResponderTerminate(evt, gestureState) {
            onTouchEnd(panDx.current || gestureState);
        },
    });

gestureState 对象

每个 PanResponder 回调中都会接受到一个 gestureState 对象,它有以下关键属性:

  • dx 和 dy: 从触摸操作开始到当前事件触发点的累积距离。
  • moveX 和 moveY: 最近一次移动事件发生时屏幕上的触点位置。
  • x0 和 y0: 用户在屏幕上初始触点的位置。
  • vx 和 vy: 在 onPanResponderMove 时的手指移动速度。
  • numberActiveTouches: 屏幕上触点的数量。

注意

如果手势被取消 当onPanResponderTerminate被执行时 gestureState对象会被清空 因此如果想基于gestureState在onPanResponderTerminate中执行逻辑时 需要手动保存下这个对象。

点击与手势冲突

通过设置开始不响应并且在滚动时拦截 可以解决点击和手势事件冲突的问题。

onStartShouldSetPanResponder: () => false,
onMoveShouldSetPanResponder(evt, gestureState) {
            if (cycleList.length > 1 && Math.abs(gestureState.dx) > 15) {
                panDx.current = { ...gestureState };
                return true;
            }
            return false;
        },

问题2:如何确定将要滚动到那个位置

滚动动画的本质是从屏幕的(x,y)移动到另一个(x,y)。那么我们如何获取到对应的坐标呢?

我们可以通过onLayout获取每个视图的坐标点,并基于此计算出视图的中心坐标。然后保存在ref中。

   const layoutListRef = useRef<LayoutItem[]>([]);
    const onItemLayout = (e, index) => {
        layoutListRef.current[index] = {
            centerX: e.nativeEvent.layout.x + e.nativeEvent.layout.width / 2,
            centerY: e.nativeEvent.layout.y + e.nativeEvent.layout.height / 2,
        };
    };
 <Animated.View
                        style={stylesList}
                        onLayout={e => onItemLayout(e, index)}
                        key={index}
                    >
                        <TouchableWithoutFeedback onPress={() => onClick(index)}>
                            {renderItem(data, ani, index)}
                        </TouchableWithoutFeedback>
                    </Animated.View>

问题3:左右滑动的边界在哪里

因为数组长度为【1,9】我们可以通过这个长度计算出【start, end】.
例如长度为4的情况start=3 end=6 center=4(中心位置永远不变)。
截屏2024-05-20 下午2.51.26.png

【start, end】形成一个窗口 当左右滑动时 窗口左右移动
当左右窗口边界位于中心点时即到达滚动边缘 不可以再继续向对应方向滚动。
截屏2024-05-20 下午2.52.33.png

对于边界也要做相应的处理 当已经处于边界外围并且移动的下一步依然在边界外面 我们不需要移动当前视图。
截屏2024-05-20 下午2.53.06.png

问题4:滚动的动画如何实现

先看下最基本的Animated动画是如何实现的

import React, { useEffect, useRef } from 'react';
import { Animated, Text, View } from 'react-native';
const FadeInView = (props) => {
  const fadeAnim = useRef(new Animated.Value(0)).current; // 1:初始化动画属性

  useEffect(() => {
    // 3:通过api开始动画属性的变化
    Animated.timing(
      fadeAnim,
      {
        toValue: 1,  // 目标透明度
        duration: 5000,  // 动画时长
        useNativeDriver: true,  // 启用原生动画驱动
      }
    ).start();
  }, [fadeAnim])

  return (
    <Animated.View                 // 使用 Animated.View
      style={{
        ...props.style,
        opacity: fadeAnim,         // 将 动画属性和动画组件的样式关联
      }}
    >
      {props.children}
    </Animated.View>
  );
}
// 使用
<FadeInView style={{width: 200, height: 50, backgroundColor: 'powderblue'}}>
  <Text style={{fontSize: 28, textAlign: 'center', margin: 10}}>Fading in</Text>
</FadeInView>

使用 Animated 库通常分为以下几个步骤:

  1. 初始化动画值,使用 Animated.Value 或 Animated.ValueXY 初始化动画的状态值
  2. 将动画值应用到组件的样式属性上
  3. 通过动画api配置并开始动画属性的变化。

注意

需要将Animated动画值声明为不可变.

const fadeAnim = new Animated.Value(0);

在这种情况下,每当组件重新渲染时,fadeAnim 将被重新实例化,从而导致:

  • 动画值每次渲染都重置,使得无法维持连贯的动画效果。
  • 动画相关的操作(如开始、停止)需要重新配置,因为 fadeAnim 每次都是新的实例。

这种做法不仅影响动画的正常执行,还可能引入性能问题,因为频繁的实例化和垃圾回收会消耗额外的资源。

具体实现

  1. 我们将滑动分为左右两个方向 分别实现。
  2. 当向左滑动时 首先判断当前end是否大于center点 只有大于中心点才能移动。
  3. 窗口的end位置依次向左移动 找到下一个视图对应的位置 并获取坐标。计算差值
  4. 然后开始计算动画属性变化 生成动画对象数组。
  5. 将【start end】边界做调整,修改中心位置的对象。
  6. 通过动画api并行的运行收集的动画对象。
const leftXY = (step = 1) => {
        const aniListXY = [];
        [...infoList.current].reverse().forEach((info, i) => {
            let s = 1;
            let o = 0;
            const item = cycleList[info.pos];
            let index = end - i; // 当前的元素位置
            if (index < 0) {
                return;
            }
            const nextIndex = Math.max(index - step, 0); // 将要移动到的下一个元素位置
            if (index >= LocationNums.length && nextIndex >= LocationNums.length) { // 处理超出边界的case
                return;
            }
            index = Math.min(end - i, LocationNums.length - 1);

            const x =
                layoutListRef.current[LocationNums[nextIndex]].centerX -
                layoutListRef.current[LocationNums[index]].centerX;
            const y =
                layoutListRef.current[LocationNums[nextIndex]].centerY -
                layoutListRef.current[LocationNums[index]].centerY;
            if (LocationNums[nextIndex] === 0) {
                s = 1.5;
                o = 1;
            }

            info.offsetX = info.offsetX + x;
            info.offsetY = info.offsetY + y;
            generateAniList(aniListXY, item, { x: info.offsetX, y: info.offsetY, s, o });
        });

        changeCenterIndex(true, step);
        setStart(start - step);
        setEnd(end - step);
        return aniListXY;
    };

参考

  1. https://docs.swmansion.com/react-native-gesture-handler/
  2. https://reactnative.dev/
  3. https://reactnative.dev/docs/0.73/handling-touches#touchables
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

No branches or pull requests

1 participant