0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

友人たちと作ってるサービスの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

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

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

作成したもののスクショ

simulator_screenshot_CEA0A097-F6FC-4C01-90C2-3A1B7CAD423D.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;

内容


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


const CustomWheelPicker = ({
  dataset,
  initialValue,
  onConfirm,
  onCancel,
}: Props) => {

データセットを引数としているのでいろんな選択肢を持てる

  const [selectedIndex, setSelectedIndex] = useState(0);

selectIndexでdetaset[i]のiを制御

  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]);

1つの項目を移動するのに何ピクセル必要か

  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]);
  };

今のIndexの値を確定

  return (
    <Modal transparent animationType="slide">

Modal
https://reactnative.dokyumento.jp/docs/modal
slideは下からにゅっと出てくる,
すぐに出てきて欲しいならnone

      <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 }}
            >

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');


export default CustomWheelPicker;

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

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

追記

このままだとデータの選択ができてないので

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

<ScrollView 
 ref={scrollRef}
 snapToInterval={ITEM_HEIGHT}
 decelerationRate="fast"
 showsVerticalScrollIndicator={false}
 
 onScroll={handleScroll} 
 scrollEventThrottle={16} 
 
 onMomentumScrollEnd={handleScrollEnd}
 contentContainerStyle={{ paddingVertical: ITEM_HEIGHT * 2 }}
>

を追加して選択できるようにした 

追記の追記

メタクソに重くなったので調べてみるとScrollViewは全てを表示しているらしい
https://zenn.dev/woodstock_tech/articles/14011577c33f90
なのでflatlistに切り替えた


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

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

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

  const handleScroll = (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}>
            <FlatList
              ref={flatListRef}
              data={dataset}
              keyExtractor={(item, index) => `${item}-${index}`}
              getItemLayout={(_, index) => ({
                length: ITEM_HEIGHT,
                offset: ITEM_HEIGHT * index,
                index,
              })}
              initialScrollIndex={selectedIndex}
              snapToInterval={ITEM_HEIGHT}
              decelerationRate="fast"
              showsVerticalScrollIndicator={false}
              onScroll={handleScroll}
              scrollEventThrottle={16}
              contentContainerStyle={{ paddingVertical: ITEM_HEIGHT * 2 }}
              renderItem={({ item, index }) => (
                <View style={styles.item}>
                  <Text
                    style={[
                      styles.itemText,
                      index === selectedIndex && styles.itemTextSelected,
                    ]}
                  >
                    {item}
                  </Text>
                </View>
              )}
            />
            <View style={styles.highlight} />
          </View>

          <Text style={{ marginTop: 8, fontSize: 12, textAlign: 'center' }}>
            現在の選択: {dataset[selectedIndex]}
          </Text>
        </View>
      </View>
    </Modal>
  );
};

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


});

export default CustomWheelPicker;

これでだいぶ軽くなった

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?