📷

[React Native] Instagramのリール風のUIを作りたい

2024/10/07に公開

スワイプしてピタッと止まるようなInstagramのリール風のUIの作り方がわからなかったので記事にしました。

コードをGitHubで公開しているので、この記事では必要な部分だけを切り出して説明します。

https://github.com/zackerms/expo-instagram-reel-playground

10秒でわかる要約

  • Instagramのリールのようにスワイプすると1ページごとめくれるUIを作りたい
  • FlatListにpagingEnabled={true}を指定すると、ピタッと止まるようになる
  • 要素とFlatListの大きさが異なるときは、snapToInterval, disableIntervalMomentum={true} を指定することでピタッと止まるようになる
出来上がったものがコチラ

自己紹介 & 宣伝

現在、大学の友人と komichi というお出かけプランを提案してくれるアプリを開発しています。

↓ Instagramもやっているので、ぜひフォローをお願いします 🙏
https://www.instagram.com/komichiapp/profilecard/?igsh=MXh0azB0Z3hvMWtpeg==

セットアップ

プロジェクトの作成

https://docs.expo.dev/more/create-expo/

npx create-expo-app@latest expo-instagram-reel --template blank-typescript --yes

起動

yarn android

本題

FlatList

このUIの基本となるコンポーネントです。

大量のデータを表示するときに表示される部分だけを描画することで、メモリ効率を良くしてくれます。

https://reactnative.dev/docs/flatlist

FlatListを利用するためには、2つのプロパティを指定する必要があります。

  • data: 画面に表示したいデータ
  • renderComponent : コンポーネントの描画を行う関数
import { FlatList, Text } from 'react-native';

const Component = () => {
  const data = [
    { id: '1', title: 'Item 1' },
    { id: '2', title: 'Item 2' },
    // ...
  ];

  const renderItem = ({ item }) => (
    <Text>{item.title}</Text>
  );

  return (
    <FlatList
      data={data}
      renderItem={renderItem}
      keyExtractor={item => item.id}
    />
  );
};

画面にページがピッタリと収まるようにする

FlatListは単純にリスト形式を実現するUIなので、要素の高さを適切に指定しないとリールのような見た目になりません。

参考:https://reactnative.dev/docs/flatlist

FlatList

そこで、まずは要素の大きさをページにピッタリ合うようにします。

画面の大きさを取得するために、onLayoutコールバック関数を利用します。

const [screenHeight, setScreenHeight] = useState(0);

const handleOnLayout = (event: LayoutChangeEvent) => {
  const { height } = event.nativeEvent.layout;
  setScreenHeight(height);
}

return <View onLayout={handleOnLayout}>
    ...
</View>

取得された画面の高さにピッタリ合うように、要素の高さを指定します。

return (<View onLayout={handleOnLayout}>
  <FlatList
	  renderItem={({ item, index }) => (
			 <View
		        key={index}
		        style={{ width: "100%", height: screenHeight }}
		    >
		      <Image source={{ uri: item?.imageUrl }} />
		    </View>
    )}
  />
</View>)

スワイプしたときに、ピタッと止まるようにする

高さを指定することで、見た目上は画面にピッタリ合うようになります。

しかし、スワイプしたときにページごとにピタッと止まるようにするには、以下のようにpagingEnabled={true} を指定すしてあげる必要があります。

<FlatList
   pagingEnabled={true}
pagingEnabled={false} pagingEnabled={true}

要素をページよりも小さくしたい

Instagramの発見タブを開くと、リストの大きさと要素の大きさが違うことがわかると思います。

このように、要素の高さがリストよりも小さい場合は pagingEnabled={true} だけでは、ピタッと止まる動作を実現することはできません。

When true the scroll view stops on multiples of the scroll view's size when scrolling. This can be used for horizontal pagination. The default value is false.

コード内のドキュメントを見ると、pagingEnabled={true} は、ScrollViewの高さの倍数の位置でスクロールが止まるように機能するようです。

つまり、要素の高さがScrollViewの高さにピッタリあっていない場合は、ピタッと止まらなくなってしまいます。

このような場合、FlatListにスクロールしたときに、画面にどれだけ動かすかを指定してあげます。 その方法が snapToIntervalです。

<FlatList
    snapToInterval={contentHeight}

これで、どんな要素の大きさでもピタッと止まるUIを実現できるようになりました 🎉

大きくスワイプしたときにもピタッと止まるようにする

かと思いきや、実はそうではないんです。 先ほどの実装だと軽くスワイプしたときはピタッと止まることができます。 しかし、大きくスワイプしたときは、普通のスクロールをしてしまいます。

わざわざ、章を分けたのは、大きくスワイプしたときにもピタッと止まる方法を探すのにめちゃめちゃ時間がかかったからです 😅

そして、その解決策はシンプルでdisableIntervalMomentum というプロパティを付け加えるだけです。

<FlatList
    snapToInterval={contentHeight}
    disableIntervalMomentum={true}
disableIntervalMomentum={false} disableIntervalMomentum={true}

調べるのに数時間かかったのに、それを解決する方法はたった1行のコード。 開発者あるあるな気がします。

Appendix

画面の左右タップでページング

実現するもの

画面の左側をタップすると前のページ、右側をタップすると次のページに行くような動作を実現します。

前後のページに移動するためには「現在のページ」を知る必要があります。

onScrollイベントにコールバック関数を登録し、スクロール幅から現在のページを算出します。

const [currentPageIndex, setCurrentPageIndex] = useState(0);

const onScroll = (e: NativeSyntheticEvent<NativeScrollEvent>) => {
    const { contentOffset } = e.nativeEvent;
    const currentPage = Math.floor(contentOffset.y / screenHeight);
    if (currentPage >= 0 && currentPage <= data.length - 1) {
        setCurrentPageIndex(currentPage);
    }
};

return <FlatList
    onScroll={onScroll}
/>

次に、実際に描画されるページの上に透明なボタンを左右に配置します。

function TapPager({
    onPrev, 
    onNext, 
    children
}: {
    onPrev: () => void; 
    onNext: () => void; 
    children?: ReactNode;
}) {
    return <View style={styles.tapPagerContainer}>
        {children}
        <View style={styles.tapPager}>
            <Pressable style={styles.tapPagerButton} onPress={onPrev} />
            <Pressable style={styles.tapPagerButton} onPress={onNext} />
        </View>
    </View>
}

const styles = StyleSheet.create({
    tapPagerContainer: {
        width: '100%',
        height: '100%',
        position: 'relative',
    },
    tapPager: {
        display: 'flex',
        flexDirection: 'row',
        position: "absolute",
        top: 0,
        right: 0,
        bottom: 0,
        left: 0,
    },
    tapPagerButton: {
        height: "100%",
        flex: 1,
    },
});

スクロールの処理を記述する前に、FlatListにどのくらいの大きさをスクロールするかを教えてあげる必要があります。。

getItemLayout でそれぞれのページの高さと、そのページまで行くのにどのくらいスクロールしなければいけないかを指定しています。

<FlatList
    getItemLayout={(_, index) => ({
        index,
        length: contentHeight,
        offset: contentHeight * index,
    })}

最後に、ページングを行うボタンがタップされたときの処理を記述します。

FlatListの参照をuseRefを利用して作成し、ページボタンを押されたタイミングでこの参照を利用してスクロールを行います。

 const onTapPrev = () => {
    const isFirstPage = currentPageIndex === 0;
    if (isFirstPage) {
        return;
    }

    // 前のページに移動
    flatListRef.current?.scrollToIndex({
        index: currentPageIndex - 1,
        animated: true,
    });
}

const onTapNext = () => {
    const isLastPage = currentPageIndex === data.length - 1;
    if (isLastPage) {
        return;
    }

    // 次のページへ移動
    flatListRef.current?.scrollToIndex({
        index: currentPageIndex + 1, animated: true,
    });
};

 return <FlatList
    ref={flatListRef}
    onScroll={onScroll}
    getItemLayout={(_, index) => ({
        index,
        length: contentHeight,
        offset: contentHeight * index,
    })}
    renderItem={({ item, index }) => (<View
        key={index}
        style={{ width: "100%", height: screenHeight }}
    >
        <TapPager
            onPrev={onTapPrev}
            onNext={onTapNext}
        >
            <ReelItem item={item} />
        </TapPager>
    </View>)}
/>

戻るボタンを押したときに前のページに戻る

Androidの戻るボタンを押したときに、前のページに戻る挙動を実現します。

BackHandlerを利用することで実現できます。

useEffect(() => {
  const backToPrevPage = () => {
      if (currentPageIndex === 0) {
          return false;
      }

      flatListRef.current?.scrollToIndex({
          index: currentPageIndex - 1,
          animated: true,
      });

      return true;
  };

  const backHandler = BackHandler.addEventListener(
      "hardwareBackPress",
      backToPrevPage
  );

  return () => backHandler.remove();
}, [currentPageIndex]);

この記事が皆さんのお役に立てると嬉しいです!

Discussion