モバイルアプリ開発:ScrollViewを使って横スクロールバーを実装+諸々あったこと

1011
NO IMAGE

まえがき

色々モバイルアプリを見ていくと、リストの表示に横スクロールバーを使っている場合が多く見られます。
汎用性が高そうなので、実装しようと思いました。
フレームワークはReact、コンポーネントライブラリとして、ReactNativeNativeBaseを使用しています。
また、その際に詰まった箇所などをメモとして残します。

使用ツール・バージョン

React 17.0.2
ReactNative 0.68.2
NativeBase 3.4.6
Typescript 4.3.5

最終的にできたもの

//TabThreeScreen.ts

import React, { useState, ReactElement } from 'react';
import { View, ScrollView, Dimensions } from 'react-native';
import { HStack, Center, Button } from "native-base";

export default function TabThreeScreen() {
  const { width } = Dimensions.get('window')

  const [items, setItems]: [
    ReactElement[],
    React.Dispatch<React.SetStateAction<ReactElement[]>>
  ] = useState<ReactElement[]>(
    [...Array(2)].map((v, i) => {
      return <Center key={i} bg="primary.400" m="2" w={width * 0.4} h="32" rounded="md" _text={{ color: 'white' }} shadow="1">Item</Center>
    })
  )

  const addItem = () => {
    setItems([...items, <Center key={ items.length } bg="secondary.400" m="2" w={width * 0.4} h="32" rounded="md" _text={{ color: 'white' }} shadow="1">Item</Center>])
  }

  return (
    <View>
        <ScrollView horizontal={true}>
          <HStack>
            {items}
          </HStack>
        </ScrollView>
        <Button onPress={ addItem }>アイテムを追加</Button>
    </View>
  )
}

ScrollView_4.gif

できるまでの過程

横スクロール実装時の選択肢

今回は、ScrollViewを選択していますが、React Nativeを使って、横スクロールを実装しようとした時、選択肢として、二つのコンポーネントが提供されています。

  1. ScrollView
  2. FlatList

どちらを選択するかの判断は公式のドキュメントに記述があります。下記に引用&Deeplで翻訳

ScrollView vs FlatList - どちらを使うべきですか?
ScrollView は反応するすべての子コンポーネントを一度にレンダリングしますが、これにはパフォーマンス上の欠点があります。
表示したい項目のリストが非常に長く、おそらく数画面分のコンテンツがあるとします。表示されないかもしれないものをすべて一度にJSコンポーネントやネイティブビューで作成すると、レンダリングが遅くなり、メモリ使用量も増えてしまいます。
そこで登場するのがFlatListです。FlatListは、表示されようとするアイテムを遅延してレンダリングし、画面外に大きくスクロールするアイテムを削除して、メモリと処理時間を節約します。
FlatList は、アイテム間のセパレータ、複数列の表示、無限スクロールの読み込み、その他多くの機能をそのままサポートする便利な機能です。

ScrollViewのとりあえずの動きを確認

  1. 横スクロールの設定方法
    ScrollViewコンポーネントをインポートし、horizonalプロパティをtrueで設定します。
// TabThreeScreen.ts

    import React from 'react';
    import { View, ScrollView } from 'react-native';

    export default function TabThreeScreen() {
      return (
        <View>
            <ScrollView horizontal={true}>
            </ScrollView>
        </View>
      )
    }
  1. ScrollViewはheightを設定できません。なので、高さを調節したい場合は、Viewコンポーネントで囲み、Viewコンポーネントに対して、高さを設定します。
// TabThreeScreen.ts

    import React from 'react';
    import { View, ScrollView } from 'react-native';

    export default function TabThreeScreen() {
      return (
    -    <View>
    +    <View style={{height: 80}} >
           <ScrollView horizontal={true}>
           </ScrollView>
         </View>
      )
    }
  1. 適当にTextコンポーネントをたくさん並べたもので動きを確認
// TabThreeScreen.ts

    import React from 'react';
    import { View, ScrollView } from 'react-native';

    export default function TabThreeScreen() {
      return (
        <View style={{height: 80}} >
          <ScrollView horizontal={true}>
    +         <Text>ABCDEFGHI</Text>
    +         <Text>ABCDEFGHI</Text>
    +         <Text>ABCDEFGHI</Text>
          </ScrollView>
        </View>
      )
    }
  1. 見た目の動きを見る
    ABCDEFGHI という文字が連なっています。また、横スクロールバーが出て、スクロールできることが見て取れます。

ScrollView_1.gif

見た目をよくする

  1. NativeBaseにHStackというコンポーネントがあります。

HStackは、アイテムを水平に並べるためのレイアウトコンポーネントです。また、HStackはRowという別名も持っています。
いくつかプロパティを持っていますが、次のものを使います。

  1. space: アイテム同士の空間のサイズを指定できます。
  2. alignItems: アイテムの揃え方を指定できます。今回は、centerにしているので、Viewコンポーネントの真ん中に揃っています。
  1. Centerは、内容を中心に合わせるコンポーネントです。

  2. Viewコンポーネントは中の要素のsizeに合わせるようです。なので、heightの指定を削除しました。

// TabThreeScreen.ts

    import React from 'react';
    import { View, ScrollView } from 'react-native';
    + import { HStack, Center } from "native-base";

    export default function TabThreeScreen() {
      return (
    -    <View style={{height: 80}} >
    +    <View>
          <ScrollView horizontal={true}>
    +       <HStack space="2" alignItems="center">
    -          <Text>ABCDEFGHI</Text>
    -          <Text>ABCDEFGHI</Text>
    -          <Text>ABCDEFGHI</Text>
    +          <Center bg="primary.400" size="16" rounded="md" _text={{ color: 'white' }} shadow="1">Box</Center>
    +          <Center bg="primary.400" size="16" rounded="md" _text={{ color: 'white' }} shadow="1">Box</Center>
    +          <Center bg="primary.400" size="16" rounded="md" _text={{ color: 'white' }} shadow="1">Box</Center>
    +        </HStack>
          </ScrollView>
         </View>
      )
    }

画面での見た目

スクリーンショット 2022-07-27 15.57.19.png

  1. Centerコンポーネントのサイズをディスプレイのサイズに合わせて、レンダリングするようにしてみます。
    これは、ReactNativeのDimensionsモジュールから作成できました。
// TabThreeScreen.ts
    import React from 'react';
    - import { View, ScrollView } from 'react-native';
    + import { View, ScrollView, Dimensions } from 'react-native';
    import { HStack, Center } from "native-base";

    export default function TabThreeScreen() {
       // ディスプレイのサイズ情報を取得
    +  const { width } = Dimensions.get('window');

      return (
        <View>
            <ScrollView horizontal={true}>
    -         <HStack space="2" alignItems="center">
    +         <HStack>
    -          <Center bg="primary.400" size="16" rounded="md" _text={{ color: 'white' }} shadow="1">Box</Center>
    -          <Center bg="primary.400" size="16" rounded="md" _text={{ color: 'white' }} shadow="1">Box</Center>
    -          <Center bg="primary.400" size="16" rounded="md" _text={{ color: 'white' }} shadow="1">Box</Center>
                {/* アイテムの幅をwidthの90%に設定 */}
    +            <Center bg="primary.400" m="2" w={width * 0.9} h="32" rounded="md" _text={{ color: 'white' }} shadow="1">Box</Center>
    +            <Center bg="primary.400" m="2" w={width * 0.9} h="32" rounded="md" _text={{ color: 'white' }} shadow="1">Box</Center>
    +            <Center bg="primary.400" m="2" w={width * 0.9} h="32" rounded="md" _text={{ color: 'white' }} shadow="1">Box</Center>
              </HStack>
            </ScrollView>
        </View>
      );
    }

画面の動き
ScrollView_2.gif

アイテムを追加できるようにしてみる

ボタンを押すと、アイテムが追加できるようにしてみます。

  1. 冗長になっている箇所をループで表現してみます。
    for文でitemsにCenterをpushして、それを冗長になっていた箇所と置き換えただけです。ポイントとして、Centerコンポーネントにkeyプロパティを渡しています。
    これがないと、Warning: Each child in a list should have a unique "key" prop. というワーニングが出ます。
// TabThreeScreen.ts

    import React from 'react';
    import { View, ScrollView, Dimensions } from 'react-native';
    import { HStack, Center } from "native-base";

    export default function TabThreeScreen() {
      const { width } = Dimensions.get('window');
      const items = []

    +  for (let i = 0; i < 5; i++) {
    +    items.push(
    +      <Center key={i} bg="primary.400" m="2" w={width * 0.4} h="32" rounded="md" _text={{ color: 'white' }} shadow="1">Item</Center>
    +    );
    +  }

      return (
        <View>
            <ScrollView horizontal={true}>
              <HStack>
    -            <Center bg="primary.400" m="2" w={width * 0.9} h="32" rounded="md" _text={{ color: 'white' }} shadow="1">Box</Center>
    -            <Center bg="primary.400" m="2" w={width * 0.9} h="32" rounded="md" _text={{ color: 'white' }} shadow="1">Box</Center>
    -            <Center bg="primary.400" m="2" w={width * 0.9} h="32" rounded="md" _text={{ color: 'white' }} shadow="1">Box</Center>
    +            {items}
              </HStack>
            </ScrollView>
        </View>
      );
    }
  1. Buttonコンポーネントをつけます。
    Buttonコンポーネントは、React Nativeにもありますが、今回はNativeBaseのコンポーネントを使用します。
    一旦、onPressでコンソール出力させているだけです。
// TabThreeScreen.ts

    import React from 'react';
    import { View, ScrollView, Dimensions } from 'react-native';
    - import { HStack, Center } from "native-base";
    + import { HStack, Center, Button } from "native-base";

    export default function TabThreeScreen() {
      const { width } = Dimensions.get('window');
      const items = []

      for (let i = 0; i < 5; i++) {
        items.push(
          <Center key={i} bg="primary.400" m="2" w={width * 0.4} h="32" rounded="md" _text={{ color: 'white' }} shadow="1">Item</Center>
        );
      }

   +    const addItem = () => {
   +     console.log("hello world")
   +   }

      return (
        <View>
            <ScrollView horizontal={true}>
              <HStack>
                {items}
              </HStack>
            </ScrollView>
   +       <Button onPress={ addItem }>アイテムを追加</Button>
        </View>
      );
    }

画面での見た目

スクリーンショット 2022-07-28 10.23.18.png

  1. addItemメソッドを変更し、Centerコンポーネントを追加するようにしました。
// TabThreeScreen.ts

    import React from 'react';
    import { View, ScrollView, Dimensions } from 'react-native';
    import { HStack, Center, Button } from "native-base";

    export default function TabThreeScreen() {
      const { width } = Dimensions.get('window');
      const items = []

      // 追加が見やすいように2個に変更
    -  for (let i = 0; i < 5; i++) {
    +  for (let i = 0; i < 2; i++) {
        items.push(
          <Center key={i} bg="primary.400" m="2" w={width * 0.4} h="32" rounded="md" _text={{ color: 'white' }} shadow="1">Item</Center>
        );
      }

      const addItem = () => {
    -    console.log("hello world")
    +    items.push(
    +      <Center key={ items.length } bg="secondary.400" m="2" w={width * 0.4} h="32" rounded="md" _text={{ color: 'white' }} shadow="1">Item</Center>
    +    );
    +    console.log("add Item!!!")
      }

      return (
        <View>
            <ScrollView horizontal={true}>
              <HStack>
                {items}
              </HStack>
            </ScrollView>
            <Button onPress={ addItem }>アイテムを追加</Button>
        </View>
      );
    }
  1. 追加後、アイテムを追加ボタンをクリックしてみましたが、アイテムが追加されませんでした。コンソールを確認したところ、コンソールへの出力はできています。

これの原因はReactが再レンダリングをしてくれないことでした。
Reactが再レンダリングを行うタイミングは次の場合でした。

  1. コンポーネントのStateが更新された時

  2. 親コンポーネントがレンダリングされた時

    なので、itemsをReactへ状態管理してもらい、再レンダリングを行ってみようと思います。

  3. itemsを状態管理する

状態管理には、useStateを使います。
また、typescriptを使っているので、useStateで型を指定する必要があります。
配列へはCenterコンポーネントを詰めるので、型はReactElementとしてます。

// TabThreeScreen.ts

    - import React from 'react';
    + import React, { useState, ReactElement } from 'react';
    import { View, ScrollView, Dimensions } from 'react-native';
    import { HStack, Center, Button } from "native-base";

    export default function TabThreeScreen() {
      const { width } = Dimensions.get('window');

    +  const [items, setItems]: [
    +    ReactElement[],
    +    React.Dispatch<React.SetStateAction<ReactElement[]>>
    +  ] = useState<ReactElement[]>([]);

      for (let i = 0; i < 2; i++) {
        items.push(
          <Center key={i} bg="primary.400" m="2" w={width * 0.4} h="32" rounded="md" _text={{ color: 'white' }} shadow="1">Item</Center>
        );
      }

      const addItem = () => {
    -    items.push(
    -      <Center key={ items.length } bg="secondary.400" m="2" w={width * 0.4} h="32" rounded="md" _text={{ color: 'white' }} shadow="1">Item</Center>
    -    );
    -    console.log("add Item!!!")
    +    setItems([...items, <Center key={ items.length } bg="secondary.400" m="2" w={width * 0.4} h="32" rounded="md" _text={{ color: 'white' }} shadow="1">Item</Center>])
      }

      return (
        <View>
            <ScrollView horizontal={true}>
              <HStack>
                {items}
              </HStack>
            </ScrollView>
            <Button onPress={ addItem }>アイテムを追加</Button>
        </View>
      );
    }

この状態で「アイテムを追加」ボタンをタップしてます。
赤いCenterコンポーネントが一つ追加されていれば、正しいですが、青いCenterが余計についてきてしまいました。
ScrollView_3.gif

  1. 5の問題は、ReactはStateが更新された時に、再レンダリングされますが、その際にfor文コードも動いてしまうことにありました。
    なので、for文の部分はStateの初期値として、指定します。
// TabThreeScreen.ts
    import React, { useState ,ReactElement } from 'react';
    import { View, ScrollView, Dimensions } from 'react-native';
    import { HStack, Center, Button } from "native-base";

    export default function TabThreeScreen() {
      const { width } = Dimensions.get('window');

        const [items, setItems]: [
        ReactElement[],
        React.Dispatch<React.SetStateAction<ReactElement[]>>
      ] = useState<ReactElement[]>(
    +    [...Array(2)].map((v, i) => {
    +      return <Center key={i} bg="primary.400" m="2" w={width * 0.4} h="32" rounded="md" _text={{ color: 'white' }} shadow="1">Item</Center>
    +    })
      )

    -  for (let i = 0; i < 2; i++) {
    -    items.push(
    -      <Center key={i} bg="primary.400" m="2" w={width * 0.4} h="32" rounded="md" _text={{ color: 'white' }} shadow="1">Item</Center>
    -    );
    -  }

      const addItem = () => {
        setItems([...items, <Center key={ items.length } bg="secondary.400" m="2" w={width * 0.4} h="32" rounded="md" _text={{ color: 'white' }} shadow="1">Item</Center>])
        console.log(items);
      }

      return (
        <View>
            <ScrollView horizontal={true}>
              <HStack>
                {items}
              </HStack>
            </ScrollView>
            <Button onPress={ addItem }>アイテムを追加</Button>
        </View>
      );

これで最初に記載した画面の状態になりました。
ScrollView_4.gif