You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

Animated ScrollView中scrollHandler与scrollTo的协同使用方案

解决React Native Animated ScrollView中scrollHandler与scrollTo的协同问题

我来帮你搞定这个头疼的问题!核心矛盾就是:调用scrollTo(或者通过修改scrollY触发的滚动)会触发onScroll事件,导致scrollHandler里的状态更新重复执行;但禁用scrollHandler又会丢失手动滑动时的状态同步。咱们的思路就是给滚动加个“身份标记”,区分开手动滚动和编程式滚动,让scrollHandler只在手动滚动时干活。

解决方案步骤

  1. 添加一个共享值isProgrammaticScroll,用来标记当前是否是代码触发的滚动
  2. 在调用编程式滚动(比如你的incrementScroll函数)时,先把标记设为true,滚动完成后再重置为false
  3. 在scrollHandler里判断这个标记,只有非编程式滚动时才更新selectedIndex

修改后的完整代码

import React, { useState } from 'react';
import { Text, View, StyleSheet, ScrollView, Platform, SafeAreaView, TouchableOpacity, } from 'react-native';
import Constants from 'expo-constants';
import Animated, { Extrapolate, interpolate, runOnJS, useAnimatedRef, useAnimatedScrollHandler, useAnimatedStyle, useDerivedValue, useSharedValue, scrollTo, withTiming, } from 'react-native-reanimated';
import { Ionicons } from '@expo/vector-icons';

const data = [
  'Jan',
  'Feb',
  'Mar',
  'Apr',
  'May',
  'Jun',
  'Jul',
  'Aug',
  'Sep',
  'Oct',
  'Nov',
  'Dec',
];

const ITEM_HEIGHT = 25;
const ITEM_WIDTH = 200;
const VISIBLE_ITEMS = 3;
const FONT_SIZE = 16;

const SpinnerItem = (props) => {
  const { idx, label, scrollY } = props;

  const focusedStyle = useAnimatedStyle(() => {
    const opacity = interpolate(
      scrollY.value,
      [(idx - 2) * ITEM_HEIGHT, (idx - 1) * ITEM_HEIGHT, idx * ITEM_HEIGHT],
      [0.3, 1, 0.3],
      Extrapolate.CLAMP
    );
    const rotate = interpolate(
      scrollY.value,
      [(idx - 2) * ITEM_HEIGHT, (idx - 1) * ITEM_HEIGHT, idx * ITEM_HEIGHT],
      [-60, 0, 60],
      Extrapolate.CLAMP
    );
    return {
      opacity,
      transform: [{ rotateX: rotate + 'deg' }],
    };
  }, [idx, scrollY]);

  const fontStyle = useAnimatedStyle(() => {
    const fontStyle = interpolate(
      scrollY.value,
      [(idx - 2) * ITEM_HEIGHT, (idx - 1) * ITEM_HEIGHT, idx * ITEM_HEIGHT],
      [FONT_SIZE, FONT_SIZE + 8, FONT_SIZE],
      Extrapolate.CLAMP
    );
    return {
      fontSize: fontStyle,
    };
  }, [idx, scrollY]);

  return (
    <Animated.View
      style={[
        {
          alignItems: 'center',
          justifyContent: 'center',
          width: ITEM_WIDTH,
          height: ITEM_HEIGHT,
        },
        focusedStyle,
      ]}
    >
      <Animated.Text
        allowFontScaling={false}
        adjustsFontSizeToFit={false}
        style={[styles.label, fontStyle]}
      >
        {label}
      </Animated.Text>
    </Animated.View>
  );
};

export default function App() {
  const [selectedIndex, setSelectedIndex] = useState(0);
  const scrollY = useSharedValue(0);
  const aref = useAnimatedRef();
  // 新增:标记是否为编程式滚动
  const isProgrammaticScroll = useSharedValue(false);

  const scrollHandler = useAnimatedScrollHandler({
    onScroll: (event) => {
      scrollY.value = event.contentOffset.y;
      // 只有非编程式滚动时,才更新选中索引
      if (!isProgrammaticScroll.value) {
        runOnJS(setSelectedIndex)(Math.round(scrollY.value / ITEM_HEIGHT));
      }
    },
    onScrollEnd: () => {
      // 滚动结束后重置标记,确保手动滚动后状态正常
      isProgrammaticScroll.value = false;
    },
  });

  useDerivedValue(() => {
    scrollTo(aref, 0, scrollY.value, true);
  });

  const incrementScroll = () => {
    'worklet';
    // 标记当前是编程式滚动
    isProgrammaticScroll.value = true;
    scrollY.value = scrollY.value + ITEM_HEIGHT;
    if (scrollY.value >= (data.length - 1) * ITEM_HEIGHT) scrollY.value = 0;
    // 编程式滚动时直接更新选中索引,不用等scrollHandler触发
    runOnJS(setSelectedIndex)(Math.round(scrollY.value / ITEM_HEIGHT));
    // 滚动动画结束后重置标记
    withTiming(scrollY.value, {}, () => {
      isProgrammaticScroll.value = false;
    });
  };

  return (
    <SafeAreaView style={{ flex: 1, alignItems: 'center', justifyContent: 'center' }}>
      <Text>{'Selected Index: ' + selectedIndex}</Text>
      <View style={styles.container}>
        <TouchableOpacity
          onPress={() => {
            incrementScroll();
          }}
        >
          <Ionicons name="ios-arrow-down-sharp" size={24} color="black" />
        </TouchableOpacity>
        <Animated.ScrollView
          showsVerticalScrollIndicator={false}
          bounces={false}
          ref={aref}
          decelerationRate={Platform.OS === 'ios' ? 0 : 0.98}
          renderToHardwareTextureAndroid
          contentContainerStyle={{
            alignItems: 'center',
            justifyContent: 'center',
          }}
          centerContent
          pinchGestureEnabled={false}
          snapToInterval={ITEM_HEIGHT}
          snapToAlignment="start"
          onScroll={scrollHandler}
          scrollEventThrottle={16}
        >
          {['spacer', ...data, 'spacer'].map((item, idx) => (
            <SpinnerItem key={idx} idx={idx} scrollY={scrollY} label={item === 'spacer' ? '' : item} />
          ))}
        </Animated.ScrollView>
      </View>
    </SafeAreaView>
  );
}

const styles = StyleSheet.create({
  container: {
    flexDirection: 'row',
    alignItems: 'center',
    justifyContent: 'center',
    alignSelf: 'center',
    backgroundColor: '#ecf0f1',
    height: ITEM_HEIGHT * 3,
    width: ITEM_WIDTH,
    overflow: 'hidden',
  },
  label: {
    textAlignVertical: 'center',
    textAlign: 'center',
    color: 'red',
    fontSize: FONT_SIZE,
    fontWeight: '700',
  },
});

关键细节说明

  • 标记区分滚动类型:isProgrammaticScroll这个共享值在编程滚动时设为true,让scrollHandler跳过状态更新逻辑;手动滚动时保持false,正常同步状态。
  • 直接更新状态:编程式滚动时,我们直接调用setSelectedIndex,不用依赖scrollHandler,避免重复更新。
  • 滚动结束重置标记:不管是编程滚动还是手动滚动,在onScrollEnd里重置标记,确保后续操作不会被错误标记。

这样就能完美实现:点击Next按钮触发的滚动不会触发scrollHandler里的状态更新,手动滑动时又能正常同步选中索引啦!

内容的提问来源于stack exchange,提问作者Hani Q

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.04.30 09:57:32