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

# まえがき
色々モバイルアプリを見ていくと、リストの表示に横スクロールバーを使っている場合が多く見られます。
汎用性が高そうなので、実装しようと思いました。
フレームワークは[React](https://ja.reactjs.org/)、コンポーネントライブラリとして、[ReactNative](https://reactnative.dev/)と[NativeBase](https://nativebase.io/)を使用しています。
また、その際に詰まった箇所などをメモとして残します。

# 使用ツール・バージョン
React 17.0.2
ReactNative 0.68.2
NativeBase 3.4.6
Typescript 4.3.5

# 最終的にできたもの

```react: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](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/280261/4a1bc688-0272-9605-f7fe-b0f5bc96e1fe.gif)

# できるまでの過程

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

どちらを選択するかの判断は[公式のドキュメント](https://reactnative.dev/docs/scrollview)に記述があります。下記に引用&Deeplで翻訳

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

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

1. 横スクロールの設定方法
    ScrollViewコンポーネントをインポートし、horizonalプロパティをtrueで設定します。
    ```diff_jsx:TabThreeScreen.ts
    import React from 'react';
    import { View, ScrollView } from 'react-native';
    
    export default function TabThreeScreen() {
      return (
        <View>
            <ScrollView horizontal={true}>
            </ScrollView>
        </View>
      )
    }
    ```
2. ScrollViewはheightを設定できません。なので、高さを調節したい場合は、Viewコンポーネントで囲み、Viewコンポーネントに対して、高さを設定します。

    ```diff_jsx: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>
      )
    }
    ```
3. 適当にTextコンポーネントをたくさん並べたもので動きを確認
    ```diff_jsx: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>
      )
    }
    ```    
4. 見た目の動きを見る
ABCDEFGHI という文字が連なっています。また、横スクロールバーが出て、スクロールできることが見て取れます。
![ScrollView_1.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/280261/a1499e68-11d1-8ae2-c85e-e919a355ebbf.gif)


### 見た目をよくする
1. NativeBaseにHStackというコンポーネントがあります。
HStackは、アイテムを水平に並べるためのレイアウトコンポーネントです。また、HStackはRowという別名も持っています。
いくつかプロパティを持っていますが、次のものを使います。
    1. space: アイテム同士の空間のサイズを指定できます。
    2. alignItems: アイテムの揃え方を指定できます。今回は、centerにしているので、Viewコンポーネントの真ん中に揃っています。
2. Centerは、内容を中心に合わせるコンポーネントです。
3. Viewコンポーネントは中の要素のsizeに合わせるようです。なので、heightの指定を削除しました。
    ```diff_jsx: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>
      )
    }
    ```
    **画面での見た目**
    <img width="50%" alt="スクリーンショット 2022-07-27 15.57.19.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/280261/2cf83aa4-0463-2163-d127-722d47d9236a.png">

4. Centerコンポーネントのサイズをディスプレイのサイズに合わせて、レンダリングするようにしてみます。
これは、ReactNativeのDimensionsモジュールから作成できました。
    ```diff_jsx: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](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/280261/53fb0de7-c2f2-5d76-1847-0d67cd7c75a6.gif)

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

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

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

    ```diff_jsx: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>
      );
    }
    ```
2. Buttonコンポーネントをつけます。
Buttonコンポーネントは、React Nativeにもありますが、今回はNativeBaseのコンポーネントを使用します。
一旦、onPressでコンソール出力させているだけです。
    ```diff_jsx: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>
      );
    }
    ```

    **画面での見た目**
    <img width="50%" alt="スクリーンショット 2022-07-28 10.23.18.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/280261/8df6f7ca-f700-b861-b462-96ec56e05774.png">

3. addItemメソッドを変更し、Centerコンポーネントを追加するようにしました。
    ```diff_jsx: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>
      );
    }
    ```
4. 追加後、アイテムを追加ボタンをクリックしてみましたが、**アイテムが追加されませんでした**。コンソールを確認したところ、コンソールへの出力はできています。
これの原因はReactが再レンダリングをしてくれないことでした。
Reactが再レンダリングを行うタイミングは次の場合でした。
    1. コンポーネントのStateが更新された時
    2. 親コンポーネントがレンダリングされた時

    なので、itemsをReactへ状態管理してもらい、再レンダリングを行ってみようと思います。
5. itemsを状態管理する
状態管理には、useStateを使います。
また、typescriptを使っているので、useStateで型を指定する必要があります。
配列へはCenterコンポーネントを詰めるので、型はReactElementとしてます。
    ```diff_jsx: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](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/280261/4d1657c6-b483-d9b7-468d-02152b78430e.gif)

6. 5の問題は、ReactはStateが更新された時に、再レンダリングされますが、その際にfor文コードも動いてしまうことにありました。
    なので、for文の部分はStateの初期値として、指定します。
    ```diff_jsx: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](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/280261/ccba9da0-e255-9bf2-714a-00d5e5ae958e.gif)