📖

React Native ScrollView でページング・バナー表示

2022/01/23に公開

標準の ScrollView の横スクロール・ページング機能を使い、ページング&バナーっぽい表示を行いたいと思います。

土台になるページング処理を実装

標準の ScrollView を使っていきます。

https://reactnative.dev/docs/scrollview

horizontalscrollEnabled を使用することでページングに対応できます。(Android は縦ページング非対応)

const PagingView = ({ children, ...props }) => {
    return (
        <ScrollView
	    {...props}
            horizontal={true}
	    pagingEnabled={true}>
	    {children}
	</ScrollView>
     );
};

この際に scrollEnabled の欄にページングの条件が記載されています。

the scroll view stops on multiples of the scroll view's size when scrolling. This can be used for horizontal pagination.
(機械翻訳) スクロールビューをスクロールする際に、スクロールビューのサイズの倍数で停止します。これは、水平方向のページネーションに使用することができます。

そのため、 {children} は View 一つ一つが ScrollView の横幅と同じになっていないと変なところでページングされてしまいます。

呼び出し元の方でサイズが確定していればいいのですが、確定していない場合毎回サイスをチェックして子要素に設定するのは手間なので PageingView 側でページサイズを強制する処理を行いたいと思います。

const PagingView = ({ children, ...props }) => {
    const [viewSize, setViewSize] = useState();
    return (
        <ScrollView
	    {...props}
            onLayout={({ nativeEvent: { layout: { width, height } } }) => {
	        if (!viewSize) {
		    setViewSize({ width, height });
		}
            }}
            horizontal={true}
	    pagingEnabled={true}>
	    {viewSize && children.map((view, index) => (
	        <View key={index} style={viewSize}>
		     {view}
		</View>
	    ))}
	</ScrollView>
     );
};

onLayout で ScrollView のサイズを取得後に children を ScrollView と同じサイズの View で囲いページングを整えます。

ここでバナー表示のため、もうひと手間入れます。

画面全体をページングする場合は現時点の実装でいいのですがバナー表示させたい場合、PagingView に幅・高さを指定して使う想定ですが、ScrollView のドキュメントでは

In order to bound the height of a ScrollView, either set the height of the view directly (discouraged) or make sure all parent views have bounded height.
(機械翻訳) ScrollViewの高さを制限するには、ビューの高さを直接設定するか(推奨しません)、すべての親ビューの高さが制限されていることを確認します。

とあるため、高さを制限できるように ScrollView を View で囲みます。

const PagingView = ({ style, children, ...props }) => {
    const [viewSize, setViewSize] = useState();

    return (
        <View
            style={style}
            onLayout={({ nativeEvent: { layout: { width, height } } }) => {
                if (!viewSize) {
                    setViewSize({ width, height });
                }
            }}>
            <ScrollView
                {...props}
                horizontal={true}
                pagingEnabled={true}>
                {viewSize && children.map((view, index) => (
                    <View key={index} style={viewSize}>
                        {view}
                    </View>
                ))}
            </ScrollView>
        </View>
    );
};

ここまでの動作イメージ

https://snack.expo.dev/@tadaedo/pagingview1

呼び出し元からページング操作を行えるようにする

プログラム上でスクロールの位置を調整を行いたい場合、 scrollTo を使用しますが、位置を座標で指定する必要があるためページ番号を指定できる scrollToPage を実装します。

React の forwardRefuseImperativeHandle を使用していきますが、useImperativeHandle のドキュメントには以下の注意書きがあります。

ref を使った手続き的なコードはほとんどの場合に避けるべきです。
https://ja.reactjs.org/docs/hooks-reference.html#useimperativehandle

今回は scrollTo のパラメータ違いを作るためなので使用していきたいと思います。

-const PagingView = ({ style, children, ...props }) => {
+const PagingView = forwardRef(({ style, children, ...props }, ref) => {
    const [viewSize, setViewSize] = useState();

+   const scrollRef = useRef();
+   const maxPageSize = React.Children.count(children) - 1;
+   useImperativeHandle(ref, () => ({
+       scrollToPage: (page) => {
+           if (page < 0) {
+               page = maxPageSize;
+           } else if (maxPageSize < page) {
+               page = 0;
+           }
+           scrollRef.current.scrollTo({ x: page * viewSize.width, animated: true });
+       },
+   }));

    return (
        <View
            style={style}
            onLayout={({ nativeEvent: { layout: { width, height } } }) => {
                if (!viewSize) {
                    setViewSize({ width, height });
                }
            }}>
            <ScrollView
                {...props}
+               ref={scrollRef}
                horizontal={true}
                pagingEnabled={true}>
                {viewSize && children.map((view, index) => (
                    <View key={index} style={viewSize}>
                        {view}
                    </View>
                ))}
            </ScrollView>
        </View>
    );
-};
+});

コンポーネントを forwardRef で囲い、パラメータに ref を追加します。受け取った ref に対して useImperativeHandle 経由で呼び出し元に公開する処理を設定します。

scrollToPage では渡されたページ番号と、事前に取得している View のサイズを元に座標を計算して scrollTo を呼び出します。

React.Children.countchildren を渡すと子要素のサイズを取得できるため、最大のページ数をチェックして範囲外のページ番号が指定されたら循環するようにしています。

後は呼び出し元の方で PagingView の ref を取得し、ボタン操作等のタイミングで scrollToPage を呼び出すとページを変更することができるようになりました。(ページ番号は0始まり)

App.js
// 使用例
const App = () => {
  const pagingRef = useRef();

  return (
      <PagingView ref={pagingRef}>
          {new Array(3).fill(0).map((_, index) => ( // ループ処理で3ページほど表示
            <View key={index} style={styles.page}>
              <Text style={styles.text}>Page{index + 1}</Text>
              <Button title='NEXT' onPress={() => pagingRef.current.scrollToPage(index + 1)} />
              <Button title='PREV' onPress={() => pagingRef.current.scrollToPage(index - 1)} />
            </View>
          ))}
      </PagingView>
  );
};

ここまでの動作イメージ2

https://snack.expo.dev/@tadaedo/pagingview2

補足ですが、ScrollView の scrollEnabledfalse を設定すると、タップ操作でのスクロールが行えなくなるため、ボタン操作だけで改ページ操作させたい時に便利です。showsHorizontalScrollIndicator を設定しておくとスクロールバーを非表示にできます。

App.js
// タップ操作で改ページさせたくない場合
-     <PagingView ref={pagingRef}>
+     <PagingView
+         ref={pagingRef}
+         scrollEnabled={false}
+         showsHorizontalScrollIndicator={false}>

バナー表示として使用する

バナーは自動で改ページするようにしたいのですが、そのためには「現在表示しているページ番号」が必要なため処理を追加します。

const PagingView = forwardRef(({ style, children, ...props }, ref) => {
    const [viewSize, setViewSize] = useState();

    const scrollRef = useRef();
+   const currentPageRef = useRef(0);
    const maxPageSize = React.Children.count(children) - 1;
    useImperativeHandle(ref, () => ({
        scrollToPage: (page) => {
	    ・・・
        },
+       getCurrentPage: () => currentPageRef.current,
    }));

    return (
        <View
	    ...
	    >
            <ScrollView
                {...props}
                ref={scrollRef}
                horizontal={true}
-               pagingEnabled={true}>
+               pagingEnabled={true}
+               scrollEventThrottle={64}
+               onScroll={({ nativeEvent: { contentOffset: { x }} }) => {
+                   currentPageRef.current = parseInt(x / viewSize.width);
+               }}>
                ・・・
            </ScrollView>
        </View>
    );
});

onScroll でスクロール位置を取得して、ページの幅で割って現在のページ番号を取得します。

scrollEventThrottleonScroll の呼び出し間隔を指定する iOS のみで有効なパラメータです。必須の設定ではありませんが、デフォルトの呼び出し間隔では回数が多すぎるので、最後の1回が取得できれば良いので少し大きめの数字を設定して呼び出し間隔を間引くようにしています。

最後に呼び出し元で、setInterval を使い一定間隔で scrollToPage を呼び出します。

App.js
const App = () => {
  const pagingRef = useRef();

+ useEffect(() => {
+   const id = setInterval(() => {
+     const currentPage = pagingRef.current.getCurrentPage();
+     pagingRef.current.scrollToPage(currentPage + 1);
+   }, 3000);
+
+   return () => {
+     clearInterval(id);
+   };
+ }, []);

  return (
      <PagingView ref={pagingRef}>
          {new Array(3).fill(0).map((_, index) => ( // ループ処理で3ページほど表示
            <View key={index} style={styles.page}>
              <Text style={styles.text}>Page{index + 1}</Text>
-             <Button title='NEXT' onPress={() => pagingRef.current.scrollToPage(index + 1)} />
-             <Button title='PREV' onPress={() => pagingRef.current.scrollToPage(index - 1)} />
            </View>
          ))}
      </PagingView>
  );
};

動作イメージ

https://snack.expo.dev/@tadaedo/pagingview3

バナー風の表示が行えました。
お疲れさまでした。

Discussion