📃

React Native で SectionIndex を実装する

8 min read

SectionIndex を表示したい

リスト表示で頭出しを行う SectionIndex 機能が標準機能では見当たらなかったためライブラリを探しましたがリスト機能とセットで提供されているケースが多かったため、独立したコンポーネントとしてあったほうが使い勝手がいいんじゃないかなと思い作成してみました。

動作イメージ

https://snack.expo.dev/@tadaedo/sectionindex-example1

実装

SectionIndex.js
import React, { useState } from 'react';
import { StyleSheet, View, Text, PanResponder } from 'react-native';

const ellipsis = Symbol("・");

const SectionIndex = ({
    style = {},
    data = [],
    fontSize = 19,
    getLabel = (data) => data,
    onPressIndex = (data, index) => {},
}) => {
    const [barHeight, setBarHeight] = useState(0);
    const [indexData, setIndexData] = useState([]);
    const onLayout = (e) => {
        // タップ領域の高さを取得
        setBarHeight(e.nativeEvent.layout.height)
        // 表示できる文字数を計算
        const visibleCharCount = parseInt(e.nativeEvent.layout.height / fontSize);
        // データが表示可能な件数以下であればそのまま表示
        if (data.length <= visibleCharCount) {
            setIndexData(data);
            return;
        }
        // ellipsis文字を差し込むため、表示できる文字を1/2にする
        const ellipsisVisibleCharCount = parseInt(visibleCharCount / 2);
        // 表示可能な件数になるまでリストを間引きしていく
        let ellipsisCount = 2;
        let ellipsisList = data.filter((_, i) => i % ellipsisCount === 0);
        while (ellipsisList.length != 0 && ellipsisList.length > ellipsisVisibleCharCount) {
            ellipsisCount++;
            ellipsisList = data.filter((_, i) => i % ellipsisCount === 0);
        }
        // 最後のデータは固定
        ellipsisList[ellipsisList.length - 1] = data[data.length - 1];
        // ellipsis文字を差し込み表示データを作成。最後のellipsisは不要なので-1を指定
        setIndexData(ellipsisList.map((v) => [v, ellipsis]).flat().slice(0, -1));
    };

    // タップイベント
    const panResponder = PanResponder.create({
        onMoveShouldSetPanResponder: () => true,
        onPanResponderMove: (e) => {
            // Viewのタップ位置からデータのindexを割り出す
            const y = e.nativeEvent.locationY;
            let index = parseInt(y / barHeight * (data.length - 1));
            // 範囲外の場合index丸め処理
            index = index < 0 ? 0 : data.length <= index ? data.length - 1 : index;
            onPressIndex(data[index], index);
        },
    });

    return (
        <View style={[{ opacity: indexData.length }, styles.bar, style]}>
            {indexData.map((v, i) => (
                <Text key={i} style={[styles.text, { fontSize, lineHeight: fontSize }]}>
                    {v === ellipsis ? ellipsis.description : getLabel(v)}
                </Text>
            ))}
            <View
                onLayout={onLayout}
                style={styles.touchArea}
                collapsable={false}
                {...panResponder.panHandlers} />
        </View>
    );
};

const styles = StyleSheet.create({
    bar: {
        position: 'absolute',
        right: 12,
        height: "90%",
        overflow: 'hidden',
        justifyContent: 'space-between',
        alignItems: 'center',
        backgroundColor: '#aaaaaa55',
        borderRadius: 12,
        paddingVertical: 8,
        paddingHorizontal: 4,
    },
    touchArea: {
        position: "absolute",
        width: "100%",
        height: "100%",
        marginVertical: 8,
    },
    text: {
        color: '#111',
    },
});

export default SectionIndex;

使い方

  • data ・・・ 表示するデータの配列
  • getLabel ・・・ 表示文字取得処理。dataが文字の配列であれば不要
  • onPressIndex ・・・ タップイベント
App.js
import React, { useState } from 'react';
import { SafeAreaView, StyleSheet, Text } from 'react-native';
import SectionIndex from './SectionIndex';

const DATA = 'ABCDEFGHIJKLMNOPQRSTUVWXYZあかさたなはまやらわ'.split('');

export default function App() {
  const [selected, setSelected] = useState(DATA[0]);
  return (
    <SafeAreaView style={styles.container}>
      <Text style={{ fontSize: 70, fontWeight: 'bold' }}>{selected}</Text>
      <SectionIndex
        style={{ height: "70%" }}
        data={DATA}
        onPressIndex={(data) => setSelected(data)} />
    </SafeAreaView>
  );
}

const styles = StyleSheet.create({
  container: {
    flex: 1,
    backgroundColor: '#fff',
    justifyContent: 'center',
    alignItems: 'center',
  },
});

SectionList と組合わて使用する

今回作成した SectionIndex をリストの頭出し機能として SectionList と組み合わせて使用してみたいと思います。
その際、頭出しなど行をスキップさせる場合 SectionList の getItemLayout を設定する必要があります。

しかし、getItemLayout は高さが異なるセクション行とアイテム行が混在している場合計算がなかなかに面倒くさいので、複雑な計算を変わりに行ってくれる react-native-section-list-get-item-layout を追加でインストールします。

https://github.com/jsoendermann/rn-section-list-get-item-layout
$ npm i react-native-section-list-get-item-layout

これで準備完了です。

SectionList 実装

App.js
import React, { useRef } from 'react';
import { SafeAreaView, SectionList, StatusBar, StyleSheet, Text, View } from 'react-native';
import sectionListGetItemLayout from 'react-native-section-list-get-item-layout';
import SectionIndex from './SectionIndex';

// 各セクションに10件のアイテムがあるデータを作成
const DATA = 'ABCDEFGHIJKLMNOPQRSTUVWXYZあかさたなはまやらわ'.split('').map((v) => ({
  sectionLabel: v,
  data: new Array(10).fill(0).map((_, count) => (`item ${v} - ${count}`))
}));
  
// react-native-section-list-get-item-layout を使用して getItemLayout を計算
const getItemLayout = sectionListGetItemLayout({
  getItemHeight: () => 50,
  getSectionHeaderHeight: () => 30,
});

// アイテム行
const renderItem = ({ item }) => (
  <View style={styles.line}><Text>{item}</Text></View>
);

// セクション行
const renderSectionHeader = ({ section: { sectionLabel } }) => (
  <View style={styles.section}><Text>{sectionLabel}</Text></View>
);

export default function App() {
  const listRef = useRef();
  return (
    <SafeAreaView style={styles.container}>
      <SectionList
        ref={listRef}
        style={styles.list}
        sections={DATA}
        keyExtractor={(item, index) => item + index}
        renderItem={renderItem}
        renderSectionHeader={renderSectionHeader}
        getItemLayout={getItemLayout}
        maxToRenderPerBatch={100}
        initialNumToRender={100}
        removeClippedSubviews={true}
        stickySectionHeadersEnabled={true}
      />
      <SectionIndex
        data={DATA}
        getLabel={(data) => data.sectionLabel}
        onPressIndex={(_, index) => {
          // セクション先頭へ頭出し
          listRef.current.scrollToLocation({
            animated: false,
            sectionIndex: index,
            itemIndex: 0,
          })
        }} />
      <StatusBar />
    </SafeAreaView>
  );
}

const styles = StyleSheet.create({
  container: {
    flex: 1,
    backgroundColor: '#fff',
    justifyContent: 'center',
    alignItems: 'center',
  },
  list: {
    width: "100%",
    height: "100%",
  },
  section: {
    height: 30,
    justifyContent: 'center',
    paddingStart: 16,
    fontWeight: 'bold',
    backgroundColor: '#888',
  },
  line: {
    height: 49,
    justifyContent: 'center',
    paddingStart: 16,
    borderBottomWidth: 1,
    borderBottomColor: 'grey',
  }
});

動作イメージ

https://snack.expo.dev/@tadaedo/sectionindex-example2

Snack の Web プレビューではスクロールの頭出しが動作しないようでした。

課題

早く動かしすぎるとチラツキが発生したりするため、SectionList 側の設定も検討していく必要がありそうです。

Discussion

ログインするとコメントできます