友人たちと作ってるサービスの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がなかなか見つけられない!
ここまでやってる人はもうiOS/Androidアプリまで作れてるよね?って話なのだろうか...
かつては react-ios-wheel-picker というパッケージがあったらしいのだが 404 not foundになっている
1日探しても見つからないので,しょうがない,作るかと言うことになりました
作成したもののスクショ
とりあえずChatGPT君に投げた
優秀だからコードの作成までやってくれて問題なく動いたのでコードの内容を見てみる
作ってくれたコード
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;
これでだいぶ軽くなった

