1557 words
8 minutes
React Native 实现横向无限滚动日历详解
2025-03-13 08:46:36
2025-12-24 23:45:46

React Native 横向无限滚动日历详解#

横向无限滚动是现代移动应用中常见的交互模式,特别是在日历、图片浏览等场景中。本文将深入分析React Native Calendars库中周日历(WeekCalendar)组件的无限滚动实现,剖析其核心原理和数据流转逻辑。

核心实现原理#

WeekCalendar组件实现了一个横向无限滚动的日历视图,主要实现逻辑如下:

1. 数据结构设计#

export const NUMBER_OF_PAGES = 6;
const NUM_OF_ITEMS = NUMBER_OF_PAGES * 2 + 1; // NUMBER_OF_PAGES before + NUMBER_OF_PAGES after + current
  • 使用一个长度为13(NUMBER_OF_PAGES*2+1)的数组存储日期数据
  • 中间位置(索引6)为当前周,左右各6个周
  • 这种设计允许用户在有限DOM元素范围内体验”无限”滚动

2. 滚动机制实现#

<FlatList
  horizontal
  pagingEnabled
  showsHorizontalScrollIndicator={false}
  initialScrollIndex={NUMBER_OF_PAGES}
  data={listData}
  renderItem={renderItem}
  getItemLayout={getItemLayout}
  viewabilityConfigCallbackPairs={viewabilityConfigCallbackPairs.current}
  onEndReached={onEndReached}
  onEndReachedThreshold={1 / NUM_OF_ITEMS}
/>
  • 使用FlatList组件实现水平滚动
  • pagingEnabled确保翻页效果
  • initialScrollIndex={NUMBER_OF_PAGES}初始显示中间位置
  • getItemLayout预计算每项位置,优化性能
  • viewabilityConfigCallbackPairs监听可见项变化
  • onEndReached处理到达边缘的情况

3. 日期数组生成逻辑#

function getDatesArray(date: string, firstDay: number, numberOfDays?: number) {
  return [...Array(NUM_OF_ITEMS).keys()].map((index) => {
    if (isCustomNumberOfDays(numberOfDays)) {
      return getDateForDayRange(date, index - NUMBER_OF_PAGES, numberOfDays as number);
    }
    return getDate(date, firstDay, index - NUMBER_OF_PAGES);
  });
}
  • 通过index - NUMBER_OF_PAGES计算周的偏移量
  • 对于中间位置,偏移量为0,表示当前周
  • 对于小于中间的索引,偏移量为负,表示过去的周
  • 对于大于中间的索引,偏移量为正,表示未来的周

数据流转逻辑详解#

从滑到顶点(最早日期)的数据流转#

当用户持续右滑查看过去的日期时,数据流转过程如下:

1. 滑动检测与边缘判断#

const onViewableItemsChanged = useCallback(({viewableItems}) => {
  // 省略前面的检查...
  
  if (shouldFixRTL) {
    // RTL环境特殊处理...
  } else {
    currentIndex.current = currItems.indexOf(newDate);
    visibleWeek.current = newDate;
    setDate(newDate, UpdateSources.WEEK_SCROLL);
    
    // 检查是否到达左侧边缘(最早日期)
    if (visibleWeek.current === currItems[0]) {
      onEndReached(); // 触发数据重置
    }
  }
}, [onEndReached, shouldFixRTL]);

当检测到可见项变为数组第一项时,触发边缘重置流程。

2. 顶点重置过程#

const onEndReached = useCallback(() => {
  changedItems.current = true;  // 标记数据正在变更
  items.current = getDatesArray(visibleWeek.current, firstDay, numberOfDays);
  setListData(items.current);  // 更新FlatList数据
  currentIndex.current = NUMBER_OF_PAGES;  // 重置索引到中间
  list?.current?.scrollToIndex({index: NUMBER_OF_PAGES, animated: false});  // 无动画跳转
}, [firstDay, numberOfDays]);

重置过程包括:

  1. 标记变更状态,防止循环触发
  2. 以当前可见周为中心点重新生成日期数组
  3. 将当前索引重置到中间位置
  4. 无动画跳转回中间,用户不会察觉这个跳转

3. 上下文数据更新#

setDate(newDate, UpdateSources.WEEK_SCROLL);

每次可见周变化时,通过setDate更新上下文,触发整个日历系统的状态更新。

4. 完整顶点数据流程#

  1. 用户右滑 → 可见项变为数组第一项
  2. 触发边缘检测 → 调用onEndReached
  3. 重新生成以当前周为中心的日期数组
  4. 无动画跳转到中间位置
  5. 用户可继续右滑查看更早日期
  6. 整个过程对用户无感知,体验连续滚动

从滑到终点(最新日期)的数据流转#

当用户持续左滑查看未来日期时,数据流转过程如下:

1. 终点触发机制#

在标准实现中,终点检测主要通过两种方式:

// 方式1:通过FlatList的onEndReached属性
<FlatList
  onEndReached={onEndReached}
  onEndReachedThreshold={1 / NUM_OF_ITEMS}
/>

// 方式2:在RTL环境中,通过检测最后一项
if (visibleWeek.current === currItems[currItems.length - 1]) {
  onEndReached();
}

当用户接近列表末尾约1/13的位置,就会触发重置流程,确保在实际到达最后一项前就开始准备新数据。

2. 终点数据重置特点#

当从终点(最新日期)重置时:

  • 当前可见周是最新日期
  • 生成的新数组以这个最新日期为中心
  • 中间位置保持为当前最新周
  • 之前的位置变成过去的周
  • 之后的位置生成更未来的周日期

3. 日期生成的特殊处理#

function getDate(date: string, firstDay: number, weekIndex: number, numberOfDays?: number) {
  // ...前面的计算逻辑
  
  const today = new XDate();
  const offsetFromNow = newDate.diffDays(today);
  const isSameWeek = offsetFromNow > 0 && offsetFromNow < (numberOfDays ?? 7);
  return toMarkingFormat(isSameWeek ? today : newDate);
}

对于当前周有特殊处理,确保正确标记和显示今天的日期。

4. 完整终点数据流程#

  1. 用户左滑查看未来日期 → 接近/到达列表右侧
  2. 触发onEndReached或通过可见性检测到达终点
  3. 以当前最新周为中心生成新的日期数组
  4. 无动画跳转回中间位置
  5. 用户可继续左滑查看更多未来日期
  6. 整个过程对用户无感知

RTL支持与特殊处理#

对于RTL(从右到左)语言环境,组件有特殊处理机制:

const shouldFixRTL = useMemo(() => !constants.isRN73() && constants.isAndroidRTL, []);

// RTL环境下的特殊处理
if (shouldFixRTL) {
  const newDateOffset = -1 * (NUMBER_OF_PAGES - currItems.indexOf(newDate));
  const adjustedNewDate = currItems[NUMBER_OF_PAGES - newDateOffset];
  // ...后续逻辑
}

在RTL环境中:

  • 滚动方向与实际数据索引方向相反
  • 需要特殊计算可见项的实际位置
  • 边缘检测逻辑也会相应调整

性能优化技巧#

该实现包含几个关键的性能优化点:

  1. 预计算布局:通过getItemLayout避免动态测量
const getItemLayout = useCallback((_, index: number) => {
  return {
    length: containerWidth,
    offset: containerWidth * index,
    index
  };
}, [containerWidth]);
  1. 状态变更标记:使用changedItems.current防止循环触发
  2. 无动画跳转:在数据重置时使用animated: false避免用户察觉
  3. 有限DOM元素:只渲染13个周视图,而不是无限多的DOM元素

总结#

React Native中横向无限滚动的实现本质上是一种”障眼法”:

  1. 使用有限数量的视图元素
  2. 在用户滚动到边缘时重置数据和位置
  3. 通过无缝过渡创造无限滚动的错觉

这种实现既保证了良好的性能,又提供了顺畅的用户体验,是一种优雅的无限滚动解决方案。这一模式不仅适用于日历,也可应用于图片浏览、轮播图等多种场景。

React Native 实现横向无限滚动日历详解
https://0bipinnata0.my/posts/react-native/横向无限滚动/
Author
0bipinnata0
Published at
2025-03-13 08:46:36