既定の単語を手書きで書きながら覚えることを想定した機能をご紹介します。
開発環境(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',
},
});
動作デモ
リリースする
※上記の詳細は以下のアプリに実装されています!



コメント