既定の単語を手書きで書きながら覚えることを想定した機能をご紹介します。
開発環境(Expo Bare)
- “typescript”: “^5.2.2”
- “expo”: “~49.0.13”,
- “react”: “18.2.0”,
- “react-native”: “0.72.6”,
- “expo-router”: “^2.0.0”,
- “react-native-paper”: “^5.10.6”,
- “react-native-svg“: “^13.14.0”,
- “react-native-reanimated“: “~3.3.0”,
セットアップする
react-native-svg
yarn add react-native-svg // プロジェクトをリビルドする # For iOS npx pod-install npx react-native run-ios # For Android npx react-native run-android
undo/redo()
コンポーネントを追加する
TegakiPad
Svg
を使って手書き機能を実装します。
// TegakiPad.tsx
import React, {memo, useCallback, useEffect, useState} from 'react'; import {View, ColorValue, StyleSheet, Animated} from 'react-native'; import {useTheme} from 'react-native-paper'; import Svg, {Path} from 'react-native-svg'; import {isEqual} from 'lodash'; import {PathType, UndoStrokeType, SigningPathType} from 'common/types'; import {layout} from 'common/utils'; import {useReactiveVar} from '@apollo/client'; import {rvStudyVar} from 'apollo/reactivars'; import StrokeSettings from './StrokeSettings'; import StrokeButtons from './StrokeButtons'; import {jcommonMapper} from 'apollo/mappers/common'; type Props = { isFirst: boolean; isLast: boolean; style?: {}; onPrev: () => void; onNext: (hasPaths: boolean) => void; }; const TegakiPad: React.FC<Props> = ({ isFirst, isLast, style, onPrev, onNext, }) => { const {colors, dark} = useTheme(); const rvStudy = useReactiveVar(rvStudyVar); const [paths, setPaths] = useState<SigningPathType>([]); const [color, setColor] = useState(rvStudy.tegaki.stroke.color); const [stroke, setStroke] = useState(rvStudy.tegaki.stroke.size); const [isReady, setIsReady] = useState(false); const [undos, setUndos] = useState<UndoStrokeType>({ past: [], present: {} as PathType, future: [], }); const canUndo = undos.past.length > 0; const canRedo = undos.future.length > 0; const setNewPath = (x: number, y: number) => { setPaths(prev => { const result = [...prev, {path: [`M${x} ${y}`], color, stroke}]; return result; }); }; const updatePath = (x: number, y: number) => { setPaths(prev => { const currentPath = paths[paths.length - 1]; currentPath && currentPath.path.push(`L${x} ${y}`); const result = currentPath ? [...prev.slice(0, -1), currentPath] : prev; recordHistory(result); return result; }); }; const clearHistory = () => { setUndos({past: [], present: {} as PathType, future: []}); }; const recordHistory = useCallback( (paths2: SigningPathType) => { let newPresent = paths2[paths2.length - 1]; if (!newPresent) newPresent = paths2[0]; if (isEqual(newPresent, undos.present)) return; setUndos((prev: UndoStrokeType) => { return { past: [...prev.past, prev.present], present: newPresent, future: [], }; }); }, [undos.present], ); const onUndo = () => { if (!paths.length) return; setUndos((prev: UndoStrokeType) => { const previous = prev.past[prev.past.length - 1]; const newPast = prev.past.slice(0, prev.past.length - 1); const newUndo = { past: newPast, present: previous, future: [prev.present, ...prev.future], }; return newUndo; }); setPaths(prev => paths.slice(0, prev.length - 1)); }; const onRedo = () => { if (!undos.future[0]) return; setUndos((prev: UndoStrokeType) => { const next = prev.future[0]; const newPast = [...prev.past, prev.present]; const newFuture = prev.future.slice(1); const newUndo = { past: newPast, present: next, future: newFuture, }; setPaths([...paths, next]); return newUndo; }); }; const _onNext = () => { onNext(!!paths.length); }; const onReset = useCallback(() => { clearHistory(); setPaths([]); }, []); useEffect(() => { jcommonMapper.isReadySvg(isReady); }, [isReady]); return ( <> <Animated.View style={[ style, styles.container, { backgroundColor: dark ? colors.onSurface : colors.onSecondary, }, ]}> <StrokeButtons isFirst={isFirst} isLast={isLast} color={color} canUndo={canUndo} canRedo={canRedo} onUndo={onUndo} onRedo={onRedo} onReset={onReset} onPrev={onPrev} onNext={_onNext} /> <View onStartShouldSetResponder={() => true} onMoveShouldSetResponder={() => true} onResponderStart={e => { setNewPath(e.nativeEvent.locationX, e.nativeEvent.locationY); }} onResponderMove={e => { updatePath(e.nativeEvent.locationX, e.nativeEvent.locationY); }} style={[styles.canvas, {backgroundColor: colors.background}]}> <Svg onLayout={e => { setIsReady(e.nativeEvent.layout.x === 0); }}> {paths.map(({path, color: c, stroke: s}, i) => { if (path === undefined) return; return ( <Path key={i} d={`${path.join(' ')}`} fill="none" strokeWidth={`${s}px`} stroke={c as ColorValue} /> ); })} </Svg> </View> </Animated.View> <StrokeSettings strokeWidth={stroke} currentColor={color} onChangeColor={setColor} onChangeStroke={setStroke} /> </> ); }; const styles = StyleSheet.create({ container: {flexGrow: 1}, canvas: {flexGrow: 1, width: layout.width}, }); export default memo(TegakiPad);
StrokeButtons
Prev/Nextボタン、Undo/Redoボタン、クリアボタンを実装します。
// StrokeButtons.tsx
import React, {memo} from 'react'; import {View, StyleSheet} from 'react-native'; import {IconButton, Surface} from 'react-native-paper'; import {StrokeButtonProps} from 'common/types'; import {layout} from 'common/utils'; const StrokeButtons = (props: StrokeButtonProps) => { const { isFirst, isLast, canUndo, canRedo, onUndo, onRedo, onReset, onPrev, onNext, } = props; const _onPrev = () => { onPrev(); onReset(); }; const _onNext = () => { onNext(); onReset(); }; return ( <Surface> <View style={[styles.button]}> <IconButton mode={'contained'} icon={'arrow-left-thick'} size={24} disabled={isFirst} accessibilityLabel="previous" onPress={_onPrev} /> <_VerticalStick /> <IconButton icon="undo" size={24} disabled={!canUndo} accessibilityLabel="undo" onPress={onUndo} /> <IconButton icon="redo" size={24} disabled={!canRedo} accessibilityLabel="redo" onPress={onRedo} /> <IconButton icon="format-clear" size={24} disabled={!canRedo && !canUndo} accessibilityLabel="reset" onPress={onReset} /> <_VerticalStick /> <IconButton mode={'contained'} icon={'arrow-right-thick'} size={24} disabled={isLast} accessibilityLabel="next" onPress={_onNext} /> </View> </Surface> ); }; const _VerticalStick = () => ( <View style={{borderRightWidth: 1, borderColor: 'grey'}} /> ); const styles = StyleSheet.create({ button: { flexDirection: 'row', width: layout.width, paddingHorizontal: 20, justifyContent: 'space-between', }, }); export default memo(StrokeButtons);
StrokeSettings
手書きペンの太さとカラーを変更するメニューを実装します。(react-native-reanimated 適用)
// StrokeSettings.tsx
import React, {FC, memo, useState} from 'react'; import {Platform, StyleSheet, TouchableOpacity, View} from 'react-native'; import Animated, {useAnimatedStyle, withTiming} from 'react-native-reanimated'; import { ColorSelectorProps, StrokeColorType, StrokeSizeType, StrokeViewProps, } from 'common/types'; import {STROKE_COLORS, STROKE_SIZES, layout} from 'common/utils'; import {useTheme} from 'react-native-paper'; import {rvStudyVar} from 'apollo/reactivars'; const StrokeView: FC<StrokeViewProps> = ({color, size}) => { return ( <View accessibilityLabel="StrokeView" style={{ backgroundColor: color, width: size, height: size, borderRadius: size, }} /> ); }; const StrokeSettings: FC<ColorSelectorProps> = ({ onChangeColor, onChangeStroke, currentColor, strokeWidth, }) => { const {colors, dark} = useTheme(); const [openColor, setOpenColor] = useState(false); const [openStroke, setOpenStroke] = useState(false); const COLOR_CONTAINER_WIDTH = openColor ? layout.width - 130 : 60; const STROKE_CONTAINER_WIDTH = openStroke ? layout.width - 130 : 60; const colorAnimatedStyles = useAnimatedStyle(() => { return { left: 10, width: withTiming(COLOR_CONTAINER_WIDTH), }; }); const strokeAnimatedStyles = useAnimatedStyle(() => { return { right: 10, width: withTiming(STROKE_CONTAINER_WIDTH), }; }); const onColorSelector = (c: StrokeColorType) => { onChangeColor(c); setOpenColor(false); }; const onStrokeSelector = (s: StrokeSizeType) => { onChangeStroke(s); setOpenStroke(false); }; const onToggleColor = () => { setOpenColor(old => !old); setOpenStroke(false); }; const onToggleStrokeSize = () => { setOpenStroke(old => !old); setOpenColor(false); }; const bkgColor = React.useMemo( () => ({ borderColor: dark ? colors.onSurfaceVariant : colors.elevation.level5, backgroundColor: dark ? colors.surface : colors.elevation.level2, }), [ colors.elevation.level2, colors.elevation.level5, colors.onSurfaceVariant, colors.surface, dark, ], ); return ( <> <Animated.View style={[styles.container, colorAnimatedStyles, bkgColor]}> <> {!openColor && ( <TouchableOpacity accessibilityLabel="toggle color" onPress={onToggleColor} style={[{backgroundColor: currentColor}, styles.colorButton]} /> )} {openColor && STROKE_COLORS.map((c, i) => { return ( <TouchableOpacity key={i} accessibilityLabel="select color" onPress={() => onColorSelector(c as StrokeColorType)} style={[{backgroundColor: c}, styles.colorButton]} /> ); })} </> </Animated.View> <Animated.View style={[styles.container, strokeAnimatedStyles, bkgColor]}> <> {!openStroke && ( <TouchableOpacity accessibilityLabel="toggle stroke size" onPress={onToggleStrokeSize} style={[styles.colorButton]}> <StrokeView color={currentColor} size={strokeWidth} /> </TouchableOpacity> )} {openStroke && STROKE_SIZES.map(s => { return ( <TouchableOpacity key={s} accessibilityLabel="select stroke size" onPress={() => onStrokeSelector(s as StrokeSizeType)} style={[styles.colorButton]}> <StrokeView color={currentColor} size={s} /> </TouchableOpacity> ); })} </> </Animated.View> </> ); }; export default memo(StrokeSettings); const styles = StyleSheet.create({ container: { position: 'absolute', bottom: Platform.OS === 'ios' ? 100 : 90, // Displaying BannerAd flexDirection: 'row', padding: 10, justifyContent: 'space-around', borderRadius: 15, alignItems: 'center', borderWidth: StyleSheet.hairlineWidth, }, colorButton: { width: 30, height: 30, borderRadius: 30, alignItems: 'center', justifyContent: 'center', }, });
動作デモ
リリースする
※上記の詳細は以下のアプリに実装されています!
コメント