Handwriting手書き

【ReactNative】手書き機能を実装する方法〜アプリ実装済み〜

Handwriting
スポンサーリンク

既定の単語を手書きで書きながら覚えることを想定した機能をご紹介します。

開発環境(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',
  },
});

動作デモ

リリースする

※上記の詳細は以下のアプリに実装されています!

コメント

タイトルとURLをコピーしました