react-native 通讯录字母列表

背景

需求:实现一个选择快递的页面,操作类似于通讯录,设计图如下

image.png

上半部分的搜索交互及实现这里就不赘述,重点探讨字母列表的实现。

需要实现的点:

  1. 主列表分组,头部不吸顶
  2. 字母列表可以滑动或点击,变更当前选中的字母,且主列表跟随滑动定位
  3. 主列表滚动时,字母列表当前选中的字母对应变化

搜索了目前已有的开源组件,要么最后一次更新的时间也比较久远,要么不适用于我们的场景,要么 star 数量不多 😂。因此打算重新造个轮子,即使将来有什么问题,也能自行维护。

AlphabetList

将我们的组件命名为 AlphabetList,它将接收一个 dataSource :

<AlphabetList dataSource={dataSource} />

dataSource 的结构如下:

interface IListItem {
  name: string;
  value: string | number;
}
interface ISectionItem {
	sectionKey: string;
	data: {
		name: string;
		value: string
	}[]
}
interface DataSource: SectionItem[]

例如:

[
  {
    sectionKey: '常',
    data: [
      {
        name: '顺丰物流',
        value: '1',
      },
      {
        name: '京东快递',
        value: '2',
      },
    ],
  },
  {
    sectionKey: 'A',
    data: [
      {
        name: '安能物流',
        value: '3',
      },
      {
        name: '澳大利亚邮政',
        value: '4',
      },
    ],
  }
]

AlphabetList 的结构如下:

<View>
  {/* 主列表 */}
  <MainList />
  {/* 右侧字母列表 */}
  <SideBar />
</View>

MainList

主列表分组 Main,那么使用 SectionList 是一个不错的选择(当然也可以用 FlatList 来实现)

当滚动 MainList 时,通过 onListViewableItemsChanged 的回调可以得到当前窗口可视的分组,从而改变当前选中的字母

const MainList = ({
    dataSource,
    onMainListViewableItemsChanged,
  }) => {
  return (
    <SectionList
      renderItem={({ item }) => (<ListItem item={item} />)}
      renderSectionHeader={({ section }) => <SectionHeader section={section} />}
      keyExtractor={(item) => `${item.value}`}
      sections={dataSource}
      stickySectionHeadersEnabled={false}
      onViewableItemsChanged={onMainListViewableItemsChanged}
    />
  );
};

export default MainList;

在 AlphabetList 中:

const AlphabetList = ({ dataSource }: IProps) => {
  // 右侧字母表列表
  const letterlist = dataSource.map((item) => {
    return item.sectionKey;
  });
  const [currentSectionKey, setCurrentSectionKey] = useState(letterlist);
  const onMainListViewableItemsChanged = (info: {
    viewableItems: ViewToken[];
  }) => {
    // 将选中的字母设置为当前第一个可见 section 的 sectionKey
    setCurrentSectionKey(info.viewableItems[0].section.sectionKey);
  };

  return (
    <View style={styles.container}>
      {/* 主列表 start */}
      <MainList
        dataSource={dataSource}
        onMainListTouchStart={() => setCurrentResponder('mainList')}
        onMainListViewableItemsChanged={onMainListViewableItemsChanged}
      />
      {/* 主列表 end */}
      {/* 右侧字母列表 start */}
      <SideBar />
      {/* 右侧字母列表 end */}
    </View>
  );
};

export default AlphabetList;

侧边栏的实现比起来则要复杂一些,这里使用一个绝对定位的 FlatList,实际上简单使用 View 去实现也是可以的。

const SideBar = ({
  letterlist,
  // 回调
  handleSideBarItemRespond,
  currentSectionKey
}: IProps) => {
  return (
    <FlatList
      style={styles.wrap}
      scrollEnabled={false}
      data={letterlist}
      renderItem={({ item, index }) => (
        <Item
          item={item}
          handleSideBarItemRespond={handleSideBarItemRespond}
          letterlist={letterlist}
        />
      )}
      keyExtractor={item => item}
    />
  );
};

一开始我的想法是通过监听 Item 的 onPress | onPressIn ,从一项滑动到另一项的时候,依次触发事件,实验后发现,在释放手势之前,仅会有一个响应的元素。

这里就得提一下 RN 的手势系统了。

Responder

在 React Native 中,响应手势的基本单位是responder,并且仅有一个 responder 拥有控制权。在 View 上有这些属性:

  1. onStartShouldSetResponder:点击时是否变成一个 responder
  2. onMoveShouldSetResponder:滑动时是否变成一个 responder
  3. onResponderGrant: 点击时的回调
  4. onResponderMove:移动时的回调
  5. onResponderTerminationRequest:当别的元素希望成为 responder 时,是否要释放当前的 responder?
  6. onResponderRelease:当 reponder 释放时的回调
  7. 等等

通过上述这些 API 我们就能很容易的实现一些手势。初次之外,RN 还提供了更高级的抽象 PanResponder,它可以给我们提供更多手势信息,这里不展开解释。

不过当我尝试在每个 Item 上设置手势响应,并且设置 onResponderTerminationRequest 为 () => true,手势依然没有在切换 Item 时释放原 responder。

因此只能通过计算高度来获取滑动时当前的字母

// evt.nativeEvent.pageY: 触摸事件的 pageY
// sideBarTop: SideBar 的 pageY
// letterHeight:每个字母的高度
const index = Math.floor((evt.nativeEvent.pageY - sideBarTop) / letterHeight);

要获取元素距离屏幕的高度,可以在 onLayout 的时候,调用 measure 来获取:

const SideBar = ({
  letterlist,
  handleSideBarItemRespond,
  currentSectionKey,
  letterHeight,
}: IProps) => {
  const [sideBarTop, setSideBarTop] = useState(0);
  const sideBarRef = useRef<View>(null);
  const measure = () => {
    if (sideBarRef.current) {
      sideBarRef.current.measure((x, y, width, height, pageX, pageY) => {
        setSideBarTop(pageY);
      });
    }
  };
  return (
    <View onLayout={measure} ref={sideBarRef}>
      <FlatList<string>
        // ...
      />
    </View>
  );
};

然后就是 Item 的实现:

const Item = ({
  item,
  handleSideBarItemRespond,
  isCurrentSectionKey,
  letterlist,
  letterHeight,
  sideBarTop,
}: {
  item: string;
  isFirst: boolean;
  handleSideBarItemRespond: (item: string) => void;
  letterlist: string[];
  letterHeight: number;
  sideBarTop?: number;
}) => {
  const panResponder = PanResponder.create({
    onStartShouldSetPanResponderCapture: () => true,
    onStartShouldSetPanResponder: () => true,
    // 尝试通过 onResponderTerminationRequest 来终止手势,发现不生效,只能通过计算偏移量来获取当前选中
    onPanResponderTerminationRequest: () => true,
    onPanResponderGrant: () => {
      handleSideBarItemRespond(item);
    },
    onPanResponderMove: (evt, gs) => {
      if (!sideBarTop) {
        return;
      }
      const index = Math.floor(
        (evt.nativeEvent.pageY - sideBarTop) / letterHeight,
      );
      if (index >= 0 && index < letterlist.length) {
        // 触发侧边字母选中的回调
        handleSideBarItemRespond(letterlist[index]);
      }
    },
  });

  return (
    <View {...panResponder.panHandlers}>
      <Text>
        {item}
      </Text>
    </View>
  );
};

到此为止,侧边栏的逻辑已经完成。现在处理侧边栏事件的回调逻辑, 主要通过 scrollToLocation 来实现列表滚动到对应的位置

const AlphabetList = ({ dataSource, companyList, onCompanySelect }: IProps) => {
  // ...
  const mainListRef = useRef<SectionList<IListItem>>(null);
  const handleSideBarItemRespond = (item: string) => {
    if (!mainListRef.current) {
      return;
    }
  	// 记录当前选中的字母
    setCurrentSectionKey(item);
    mainListRef.current.scrollToLocation({
      sectionIndex: dataSource.findIndex((data) => data.sectionKey === item),
      itemIndex: 0,
      animated: false,
    });
  };

  return (
    <View style={styles.container}>
      {/* 主列表 start */}
      <MainList
        // ...
        ref={mainListRef}
      />
      // ...
    </View>
  );
};

这里需要通过 React.forwardRef 获取到 MainList 的 ref

const MainList: React.ForwardRefRenderFunction<
  SectionList<IListItem>,
  IProps
> = (
  {
    dataSource,
    // ...
  },
  ref,
) => {
 	// ...
  return (
    <SectionList
      // ...
      ref={ref}
    />
  );
};
export default React.forwardRef(MainList)

到这一步,已经基本完成了一个通讯录字母列表。

其他细节

在完成上述功能后,由于点击侧边栏会滚动主列表,此时也会触发 MainList 的 onViewableItemsChanged,因此需要一个标识来区分是什么动作触发的:

const AlphabetList = ({ dataSource, companyList, onCompanySelect }: IProps) => {
  // 标识位:用于区分当前的操作是由什么触发的
  const [currentResponder, setCurrentResponder] = useState('');
  const mainListRef = useRef<SectionList<IListItem>>(null);
  // 右侧字母表列表
  const letterlist = dataSource.map((item) => {
    return item.sectionKey;
  });

  const [currentSectionKey, setCurrentSectionKey] = useState(letterlist[0]);

  const handleSideBarItemRespond = (item: string) => {
    if (!mainListRef.current) {
      return;
    }
    setCurrentResponder('sideBar');
  	// ...
  };

  const onMainListViewableItemsChanged = (info: {
    viewableItems: ViewToken[];
  }) => {
    if (
      // 侧边栏触发的滚动则不触发
      currentResponder === 'sideBar' ||
      !info.viewableItems[0].section.sectionKey
    ) {
      return;
    }
    setCurrentSectionKey(info.viewableItems[0].section.sectionKey);
  };

  return (
    <View style={styles.container}>
      {/* 主列表 start */}
      <MainList
	       // ...
        onMainListTouchStart={() => setCurrentResponder('mainList')}
        // ...
      />
      {/* 主列表 end */}
      // ...
    </View>
  );
};

踩坑

功能是基本上完成了,然而由于这里的数据量比较多,大概有 600 条数据,且由于 SectionList 也是基于 VirtualList,因此导致了以下长列表固有的几个问题:

  1. 快速滚动 MainList,当 SectionList 来不及渲染时,会导致屏幕空白
  2. 当使用 scrollToLocation 时,如果定位到来不及渲染的区域,将会报错,提示需要设置 getItemLayout 属性,这个属性是用于计算主列表每一项高度以及偏移量的

在尝试了一些优化的手段后,如 React.memo, initialNumToRender,getItemLayout 等属性依然无法有效解决上述问题。。

RN 的 list 真的是让人一言难尽。。不知道是否原生组件或者 flutter 对长列表的支持会更好一点呢 😂