PWA でも動くiOSっぽいPickerを作った！

友人たちと作ってるサービスのWebアプリで会員登録ページを作成している際，意外な問題に直面しました

それは
**PWAで動くdataPicker見つけられない！**

将来的なiOS/Androidへの横展開を目指してExpo(React Native) WebでPWAチックに作っているのですがiOS,android上で動くdatePickerは
https://docs.expo.dev/versions/latest/sdk/date-time-picker/
や
https://github.com/react-native-datetimepicker/datetimepicker?tab=readme-ov-file#expo-users-notice
のようなものはあるけれどPWAで動くdatePickerがなかなか見つけられない！

↓こういうの
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3803506/401d0126-41e4-4c62-acd0-84aef6da533e.png)

ここまでやってる人はもうiOS/Androidアプリまで作れてるよね？って話なのだろうか...
かつては react-ios-wheel-picker というパッケージがあったらしいのだが 404 not foundになっている

1日探しても見つからないので，しょうがない，作るかと言うことになりました

## 作成したもののスクショ
![simulator_screenshot_CEA0A097-F6FC-4C01-90C2-3A1B7CAD423D.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3803506/0e8babc0-9145-445a-b378-9a739898b838.png)

## とりあえずChatGPT君に投げた

優秀だからコードの作成までやってくれて問題なく動いたのでコードの内容を見てみる

### 作ってくれたコード
```CustomWheelPicker.tsx
import React, { useEffect, useRef, useState } from 'react';
import {
  View,
  Text,
  ScrollView,
  StyleSheet,
  TouchableOpacity,
  Dimensions,
  Modal,
  NativeSyntheticEvent,
  NativeScrollEvent,
} from 'react-native';

type Props = {
  dataset: string[];
  initialValue?: string;
  onConfirm: (value: string) => void;
  onCancel?: () => void;
};

const ITEM_HEIGHT = 48;

const CustomWheelPicker = ({
  dataset,
  initialValue,
  onConfirm,
  onCancel,
}: Props) => {
  const [selectedIndex, setSelectedIndex] = useState(0);
  const scrollRef = useRef<ScrollView>(null);

  useEffect(() => {
    const index = dataset.findIndex((d) => d === initialValue);
    if (index >= 0) {
      setSelectedIndex(index);
      scrollRef.current?.scrollTo({ y: index * ITEM_HEIGHT, animated: false });
    }
  }, [initialValue]);

  const handleScrollEnd = (e: NativeSyntheticEvent<NativeScrollEvent>) => {
    const y = e.nativeEvent.contentOffset.y;
    const index = Math.round(y / ITEM_HEIGHT);
    setSelectedIndex(index);
  };

  const confirmValue = () => {
    onConfirm(dataset[selectedIndex]);
  };

  return (
    <Modal transparent animationType="slide">
      <View style={styles.overlay}>
        <View style={styles.pickerContainer}>
          {/* ヘッダー */}
          <View style={styles.header}>
            <TouchableOpacity onPress={onCancel}>
              <Text style={styles.cancelText}>キャンセル</Text>
            </TouchableOpacity>
            <TouchableOpacity onPress={confirmValue}>
              <Text style={styles.doneText}>完了</Text>
            </TouchableOpacity>
          </View>

          {/* ホイール本体 */}
          <View style={styles.wheelWrapper}>
            <ScrollView
              ref={scrollRef}
              snapToInterval={ITEM_HEIGHT}
              decelerationRate="fast"
              showsVerticalScrollIndicator={false}
              onMomentumScrollEnd={handleScrollEnd}
              contentContainerStyle={{ paddingVertical: ITEM_HEIGHT * 2 }}
            >
              {dataset.map((item, index) => (
                <View key={index} style={styles.item}>
                  <Text
                    style={[
                      styles.itemText,
                      index === selectedIndex && styles.itemTextSelected,
                    ]}
                  >
                    {item}
                  </Text>
                </View>
              ))}
            </ScrollView>
            <View style={styles.highlight} />
          </View>
        </View>
      </View>
    </Modal>
  );
};

const { width } = Dimensions.get('window');

const styles = StyleSheet.create({
  overlay: {
    flex: 1,
    justifyContent: 'flex-end',
    backgroundColor: 'rgba(0,0,0,0.4)',
  },
  pickerContainer: {
    backgroundColor: '#fff',
    borderTopLeftRadius: 12,
    borderTopRightRadius: 12,
    paddingBottom: 20,
  },
  header: {
    flexDirection: 'row',
    justifyContent: 'space-between',
    paddingHorizontal: 20,
    paddingTop: 12,
    paddingBottom: 8,
  },
  cancelText: {
    fontSize: 16,
    color: '#999',
  },
  doneText: {
    fontSize: 16,
    color: '#007AFF',
    fontWeight: '600',
  },
  wheelWrapper: {
    height: ITEM_HEIGHT * 5,
    width: width,
    position: 'relative',
  },
  item: {
    height: ITEM_HEIGHT,
    justifyContent: 'center',
    alignItems: 'center',
  },
  itemText: {
    fontSize: 20,
    color: '#666',
  },
  itemTextSelected: {
    fontWeight: 'bold',
    color: '#000',
  },
  highlight: {
    position: 'absolute',
    top: ITEM_HEIGHT * 2,
    height: ITEM_HEIGHT,
    left: 0,
    right: 0,
    borderTopWidth: 1,
    borderBottomWidth: 1,
    borderColor: '#ccc',
  },
});

export default CustomWheelPicker;

```

### 内容

```tsx

type Props = {
  dataset: string[];
  initialValue?: string;
  onConfirm: (value: string) => void;
  onCancel?: () => void;
};


const CustomWheelPicker = ({
  dataset,
  initialValue,
  onConfirm,
  onCancel,
}: Props) => {
```
データセットを引数としているのでいろんな選択肢を持てる

```tsx
  const [selectedIndex, setSelectedIndex] = useState(0);
```
selectIndexでdetaset[i]のiを制御
```tsx
  const scrollRef = useRef<ScrollView>(null);
 ```
設定された初期値が画面外にあるのでそこにすぐにアクセスできるようにする
 ```tsx
  useEffect(() => {
    const index = dataset.findIndex((d) => d === initialValue);
    if (index >= 0) {
      setSelectedIndex(index);
      scrollRef.current?.scrollTo({ y: index * ITEM_HEIGHT, animated: false });
    }
  }, [initialValue]);
```
1つの項目を移動するのに何ピクセル必要か
```tsx
  const handleScrollEnd = (e: NativeSyntheticEvent<NativeScrollEvent>) => {
    const y = e.nativeEvent.contentOffset.y;
    const index = Math.round(y / ITEM_HEIGHT);
    setSelectedIndex(index);
  };
```
最後の値に行ったら止まるようにする
```tsx
  const confirmValue = () => {
    onConfirm(dataset[selectedIndex]);
  };
```
今のIndexの値を確定
```tsx
  return (
    <Modal transparent animationType="slide">
```
Modal
https://reactnative.dokyumento.jp/docs/modal
slideは下からにゅっと出てくる，
すぐに出てきて欲しいならnone

```tsx
      <View style={styles.overlay}>
        <View style={styles.pickerContainer}>
          {/* ヘッダー */}
          <View style={styles.header}>
            <TouchableOpacity onPress={onCancel}>
              <Text style={styles.cancelText}>キャンセル</Text>
            </TouchableOpacity>
            <TouchableOpacity onPress={confirmValue}>
              <Text style={styles.doneText}>完了</Text>
            </TouchableOpacity>
          </View>
```
ヘッダーにある「キャンセル」「確定」

```tsx
          {/* ホイール本体 */}
          <View style={styles.wheelWrapper}>
            <ScrollView
              ref={scrollRef}
              snapToInterval={ITEM_HEIGHT}
              decelerationRate="fast"
              showsVerticalScrollIndicator={false}
              onMomentumScrollEnd={handleScrollEnd}
              contentContainerStyle={{ paddingVertical: ITEM_HEIGHT * 2 }}
            >
```
ref={scrollRef}：初期値の位置に移動
snapToInterval={ITEM_HEIGHT}：各項目ごとで吸着する
decelerationRate="fast"：ホイールの減速率
showsVerticalScrollIndicator={false}：スクロールバー非表示
onMomentumScrollEnd={handleScrollEnd}：スクロールが止まった位置で確定
 contentContainerStyle={{ paddingVertical: ITEM_HEIGHT * 2 }}
：中央が選択した値になるように余白を作成

```tsx
              {dataset.map((item, index) => (
                <View key={index} style={styles.item}>
                  <Text
                    style={[
                      styles.itemText,
                      index === selectedIndex && styles.itemTextSelected,
                    ]}
                  >
                    {item}
                  </Text>
                </View>
              ))}
```
データセットを並べ直す

```tsx
            </ScrollView>
            <View style={styles.highlight} />
          </View>
        </View>
      </View>
    </Modal>
  );
};

const { width } = Dimensions.get('window');


export default CustomWheelPicker;

```

色々な場面で使いそうなのにないのは意外だったな

簡単だから，作れってことかな