1. tigeeer

    Posted

    tigeeer
Changes in title
+Flutterで手書きを実装する
Changes in tags
Changes in body
Source | HTML | Preview
@@ -0,0 +1,601 @@
+# 実装する機能
+
+今回実装する機能は、
+
+- 自由に画面をなぞると特定の範囲内で手書きで絵が描けるようにする
+- ひとつ戻る(undo)ひとつ進む(redo)ボタンの実装
+- 全部消すボタンの実装
+
+になります。類似した機能が入ったアプリだと消しゴムが入っていますが、今回のアプリではそこまで必要性を感じなかったため実装しません。
+
+# パッケージ・バージョン
+
+Flutterのバージョンは`2.2.0`です。
+
+使用するパッケージは状態管理のために`hooks_riverpod`と`statenotifier`、`freezed`を使います。
+特にそれ以外のパッケージは使用しません。
+バージョンは以下の通りです。
+
+```yaml:pubspec.yaml
+dependencies:
+ flutter_hooks: ^0.17.0
+ freezed_annotation:
+ hooks_riverpod: ^0.14.0+4
+
+dev_dependencies:
+ build_runner:
+ freezed:
+```
+
+# 手書きの実装
+
+## 土台の作成
+
+本題の実装になります。
+今回の手書きの範囲は正方形にしたいので、画面中央に配置します。
+
+```dart:draw_screen.dart
+class DrawScreen extends StatelessWidget {
+ const DrawScreen({Key? key}) : super(key: key);
+
+ @override
+ Widget build(BuildContext context) {
+ return Scaffold(
+ appBar: AppBar(
+ title: const Text('Appbar'),
+ ),
+ body: Center(
+ child: Container(
+ width: MediaQuery.of(context).size.width,
+ height: MediaQuery.of(context).size.width,
+ color: Colors.white,
+ ),
+ ),
+ );
+ }
+}
+```
+
+画面をなぞった時に感知させたいため、`GestureDetector`を使用します。
+
+```dart:draw_screen.dart
+...
+
+child: Container(
+ width: MediaQuery.of(context).size.width,
+ height: MediaQuery.of(context).size.width,
+ color: Colors.white,
+ child: GestureDetector(
+ onPanStart: (details) {},
+ onPanUpdate: (details) {},
+ onPanEnd: (details) {},
+ ),
+),
+
+...
+```
+
+## ペイントの状態を管理
+
+次に、`GestureDetector`のそれぞれの関数の引数から、なぞっているポジションが取得できるので、それを格納するための`StateNotifierProvider`を追加します。
+
+```dart:draw_controller.dart
+// ステート
+@freezed
+abstract class DrawState with _$DrawState {
+ const factory DrawState({
+ @Default(<List<Offset>>[]) List<List<Offset>> paintList,
+ @Default(<List<Offset>>[]) List<List<Offset>> undoList,
+ @Default(false) bool isDrag,
+ }) = _DrawState;
+}
+
+// コントローラー
+final drawController =
+ StateNotifierProvider.autoDispose<DrawController, DrawState>(
+ (ref) => DrawController());
+
+class DrawController extends StateNotifier<DrawState> {
+ DrawController() : super(const DrawState());
+
+ void undo() {
+ // ひとつ戻る
+ }
+
+ void redo() {
+ // ひとつ進む
+ }
+
+ void clear() {
+ // 全消し
+ }
+
+ void addPaint() {
+ // ペイント開始
+ }
+
+ void updatePaint() {
+ // ペイント中
+ }
+
+ void endPaint() {
+ // ペイント終了
+ }
+}
+```
+
+`paintList`は
+描画開始から描画終了までの線 = なぞったポジションの集まり = `List<Offset>`
+かつ、その線を何本もかけるので、その集まり = `List<List<Offset>>`
+となります。
+
+`undoList`はundoをした際に、paintListの最後の要素を格納して、redoの際にpaintListに戻すためのものなので
+、同様に`List<List<Offset>>`になります。
+
+`isDrag`は描画中かどうかの判別用ステータスで、描画中にundoやredoなどを使えないようにするためのものです。
+
+ではそれぞれの関数を実装していきます。
+
+```dart:draw_controller.dart
+ void undo() {
+ // 描画中か、undoできなかったら何もしない
+ if (state.isDrag || !canUndo) {
+ return;
+ }
+ // paintListの最後を取って、undoListに追加する
+ final _last = state.paintList.last;
+ state = state.copyWith(
+ undoList: List.of(state.undoList)..add(_last),
+ paintList: List.of(state.paintList)..removeLast(),
+ );
+ }
+
+ void redo() {
+ // 描画中か、redoできなかったら何もしない
+ if (state.isDrag || !canRedo) {
+ return;
+ }
+ // undoListの最後を取って、paintListに追加する
+ final _last = state.undoList.last;
+ state = state.copyWith(
+ undoList: List.of(state.undoList)..removeLast(),
+ paintList: List.of(state.paintList)..add(_last),
+ );
+ }
+
+ void clear() {
+ // 全ての要素を空にするだけ
+ if (!state.isDrag) {
+ state = state.copyWith(paintList: [], undoList: []);
+ }
+ }
+
+ void addPaint(Offset startPoint) {
+ if (!state.isDrag) {
+ state = state.copyWith(
+ isDrag: true, // 描画中に変更
+ paintList: List.of(state.paintList)..add([startPoint]), // 新たに開始地点を追加
+ undoList: const [], // 一つ進めるものがないはずなので空に(redoできないように)
+ );
+ }
+ }
+
+ void updatePaint(Offset nextPoint) {
+ // 最後の要素に進んだポジションを追加
+ if (state.isDrag) {
+ final _paintList = List<List<Offset>>.of(state.paintList);
+ final _offsetList = List<Offset>.of(state.paintList.last)..add(nextPoint);
+ _paintList.last = _offsetList;
+
+ state = state.copyWith(paintList: _paintList);
+ }
+ }
+
+ // 描画終了
+ void endPaint() => state = state.copyWith(isDrag: false);
+```
+
+GestureDetectorと上の関数を繋げます。
+`flutter_hooks`を使用しているため、StatelessWidgetをHookWidgetに変更し、`runApp()`に`ProviderScope()`を追加することを忘れないでください。
+
+```dart:draw_screen.dart
+...
+
+@override
+Widget build(BuildContext context) {
+ final _state = useProvider(drawController);
+ final _controller = useProvider(drawController.notifier);
+
+...
+
+child: GestureDetector(
+ onPanStart: (details) =>
+ _controller.addPaint(details.localPosition),
+ onPanUpdate: (details) {
+ _controller.updatePaint(details.localPosition);
+ },
+ onPanEnd: (_) => _controller.endPaint(),
+ child: CustomPaint(painter: Signature(_state, context)),
+),
+
+...
+```
+
+## 描画する機能の実装
+
+なぞったポジションをもとに描画する必要があるため、カスタムペインターを使います。
+
+```dart:signature.dart
+class Signature extends CustomPainter {
+ Signature(this.state, this.context);
+
+ final DrawState state;
+ final BuildContext context;
+
+ @override
+ void paint(Canvas canvas, Size size) {
+ const strokeWigth = 12.0;
+
+ final paint = Paint()
+ ..color = Colors.black
+ ..strokeCap = StrokeCap.round
+ ..strokeWidth = strokeWigth;
+
+ for (final points in state.paintList) {
+ // 一番最初にタップした地点に点を打つ
+ // そうしないとタップして離しただけの時に描画されない
+ canvas.drawRRect(
+ RRect.fromRectAndRadius(
+ Rect.fromCenter(
+ center: points[0], width: strokeWigth, height: strokeWigth),
+ const Radius.circular(strokeWigth),
+ ),
+ paint,
+ );
+ // ひとかたまりの線の描画
+ for (var i = 0; i < points.length - 1; i++) {
+ canvas.drawLine(points[i], points[i + 1], paint);
+ }
+ }
+ }
+
+ // paintListが変更されている時のみリビルド
+ @override
+ bool shouldRepaint(Signature oldDelegate) =>
+ oldDelegate.state.paintList != state.paintList;
+}
+```
+
+これで一旦は描画できるようになっていると思います。
+
+![ダウンロード.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/901218/3562af6a-5f76-7bfa-d587-1e6b36bacc45.gif)
+
+## 枠からはみ出さないよう修正
+
+しかし、このままでは、範囲外まで描画できるようになっています。
+
+![ダウンロード (1).gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/901218/06fab6f1-c30a-ec3b-f91c-15d59fc35e91.gif)
+
+要件を満たすように修正をしていきます
+修正内容としては、枠を超えたら枠の端でとどまるようにします。
+
+```dart:draw_screen.dart
+...
+
+ // ポジションの取得
+ Offset _getPosition(double length, Offset localPosition) {
+ double _dx;
+ double _dy;
+ if (localPosition.dx < 0) {
+ _dx = 0;
+ } else if (localPosition.dx > length) {
+ _dx = length;
+ } else {
+ _dx = localPosition.dx;
+ }
+ if (localPosition.dy < 0) {
+ _dy = 0;
+ } else if (localPosition.dy > length) {
+ _dy = length;
+ } else {
+ _dy = localPosition.dy;
+ }
+ return Offset(_dx, _dy);
+ }
+
+...
+```
+
+それと、`onPanUpdate`を次のように変更します
+
+```dart
+onPanUpdate: (details) {
+ _controller.updatePaint(_getPosition(
+ MediaQuery.of(context).size.width, details.localPosition));
+},
+```
+
+こうすることで、超えることがなくなったと思います。
+
+![ダウンロード (2).gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/901218/01a267af-693c-5d7d-49f2-59ec9e490f9c.gif)
+
+## undo,redo,全消しボタンの実装
+
+最後にボタンの配置をします。
+ボタンは左からundo, redo, 全消で配置します
+また、undo, redoできる時のみアクティブカラーにします。
+
+```dart:draw_screen.dart
+Row(
+ mainAxisAlignment: MainAxisAlignment.spaceAround,
+ children: [
+ ElevatedButton(
+ onPressed: _controller.undo,
+ style: ElevatedButton.styleFrom(
+ shape: const CircleBorder(),
+ primary: _controller.canUndo
+ ? Theme.of(context).accentColor
+ : Colors.grey[200],
+ onPrimary: Colors.white,
+ padding: const EdgeInsets.all(10),
+ ),
+ child: const Icon(Icons.undo, size: 40),
+ ),
+ ElevatedButton(
+ onPressed: _controller.redo,
+ style: ElevatedButton.styleFrom(
+ shape: const CircleBorder(),
+ primary: _controller.canRedo
+ ? Theme.of(context).accentColor
+ : Colors.grey[200],
+ padding: const EdgeInsets.all(10),
+ onPrimary: Colors.white,
+ ),
+ child: const Icon(Icons.redo, size: 40),
+ ),
+ ElevatedButton(
+ onPressed: _controller.clear,
+ style: ElevatedButton.styleFrom(
+ shape: const CircleBorder(),
+ primary: Colors.red,
+ padding: const EdgeInsets.all(10),
+ onPrimary: Colors.white,
+ ),
+ child: const Icon(Icons.delete, size: 40),
+ ),
+ ],
+),
+```
+
+以上になります!!!
+
+# 完成図
+
+![6ee93e4c490da76a62c6b4c54c42f42b.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/901218/f963e7e9-c660-7177-72e3-c36694f1ae41.gif)
+
+<details><summary>コード全体</summary><div>
+
+```dart:dart_screen.dart
+import 'package:flutter/material.dart';
+import 'package:flutter_hooks/flutter_hooks.dart';
+import 'package:hooks_riverpod/hooks_riverpod.dart';
+import 'package:test/draw_controller.dart';
+
+class DrawScreen extends HookWidget {
+ const DrawScreen({Key? key}) : super(key: key);
+
+ @override
+ Widget build(BuildContext context) {
+ final _state = useProvider(drawController);
+ final _controller = useProvider(drawController.notifier);
+ return Scaffold(
+ appBar: AppBar(
+ title: const Text('Appbar'),
+ ),
+ body: Column(
+ children: [
+ const Spacer(),
+ Container(
+ width: MediaQuery.of(context).size.width,
+ height: MediaQuery.of(context).size.width,
+ color: Colors.white,
+ child: GestureDetector(
+ onPanStart: (details) =>
+ _controller.addPaint(details.localPosition),
+ onPanUpdate: (details) {
+ _controller.updatePaint(_getPosition(
+ MediaQuery.of(context).size.width, details.localPosition));
+ },
+ onPanEnd: (_) => _controller.endPaint(),
+ child: CustomPaint(painter: Signature(_state, context)),
+ ),
+ ),
+ const SizedBox(height: 60),
+ Row(
+ mainAxisAlignment: MainAxisAlignment.spaceAround,
+ children: [
+ ElevatedButton(
+ onPressed: _controller.undo,
+ style: ElevatedButton.styleFrom(
+ shape: const CircleBorder(),
+ primary: _controller.canUndo
+ ? Theme.of(context).accentColor
+ : Colors.grey[200],
+ onPrimary: Colors.white,
+ padding: const EdgeInsets.all(10),
+ ),
+ child: const Icon(Icons.undo, size: 40),
+ ),
+ ElevatedButton(
+ onPressed: _controller.redo,
+ style: ElevatedButton.styleFrom(
+ shape: const CircleBorder(),
+ primary: _controller.canRedo
+ ? Theme.of(context).accentColor
+ : Colors.grey[200],
+ padding: const EdgeInsets.all(10),
+ onPrimary: Colors.white,
+ ),
+ child: const Icon(Icons.redo, size: 40),
+ ),
+ ElevatedButton(
+ onPressed: _controller.clear,
+ style: ElevatedButton.styleFrom(
+ shape: const CircleBorder(),
+ primary: Colors.red,
+ padding: const EdgeInsets.all(10),
+ onPrimary: Colors.white,
+ ),
+ child: const Icon(Icons.delete, size: 40),
+ ),
+ ],
+ ),
+ const Spacer(),
+ ],
+ ),
+ );
+ }
+
+ // ポジションの取得
+ Offset _getPosition(double length, Offset localPosition) {
+ double _dx;
+ double _dy;
+ if (localPosition.dx < 0) {
+ _dx = 0;
+ } else if (localPosition.dx > length) {
+ _dx = length;
+ } else {
+ _dx = localPosition.dx;
+ }
+ if (localPosition.dy < 0) {
+ _dy = 0;
+ } else if (localPosition.dy > length) {
+ _dy = length;
+ } else {
+ _dy = localPosition.dy;
+ }
+ return Offset(_dx, _dy);
+ }
+}
+
+class Signature extends CustomPainter {
+ Signature(this.state, this.context);
+
+ final DrawState state;
+ final BuildContext context;
+
+ @override
+ void paint(Canvas canvas, Size size) {
+ const strokeWigth = 12.0;
+
+ final paint = Paint()
+ ..color = Colors.black
+ ..strokeCap = StrokeCap.round
+ ..strokeWidth = strokeWigth;
+
+ for (final points in state.paintList) {
+ canvas.drawRRect(
+ RRect.fromRectAndRadius(
+ Rect.fromCenter(
+ center: points[0], width: strokeWigth, height: strokeWigth),
+ const Radius.circular(strokeWigth),
+ ),
+ paint,
+ );
+ for (var i = 0; i < points.length - 1; i++) {
+ canvas.drawLine(points[i], points[i + 1], paint);
+ }
+ }
+ }
+
+ @override
+ bool shouldRepaint(Signature oldDelegate) =>
+ oldDelegate.state.paintList != state.paintList;
+}
+```
+
+```dart:dart_controller.dart
+import 'package:flutter/material.dart';
+import 'package:freezed_annotation/freezed_annotation.dart';
+import 'package:hooks_riverpod/hooks_riverpod.dart';
+import 'package:state_notifier/state_notifier.dart';
+
+part 'draw_controller.freezed.dart';
+
+// ステート
+@freezed
+abstract class DrawState with _$DrawState {
+ const factory DrawState({
+ @Default(<List<Offset>>[]) List<List<Offset>> paintList,
+ @Default(<List<Offset>>[]) List<List<Offset>> undoList,
+ @Default(false) bool isDrag,
+ }) = _DrawState;
+}
+
+// コントローラー
+final drawController =
+ StateNotifierProvider.autoDispose<DrawController, DrawState>(
+ (ref) => DrawController());
+
+class DrawController extends StateNotifier<DrawState> {
+ DrawController() : super(const DrawState());
+
+ bool get canUndo => state.paintList.isNotEmpty;
+ bool get canRedo => state.undoList.isNotEmpty;
+
+ void undo() {
+ // 描画中か、undoできなかったら何もしない
+ if (state.isDrag || !canUndo) {
+ return;
+ }
+ // paintListの最後を取って、undoListに追加する
+ final _last = state.paintList.last;
+ state = state.copyWith(
+ undoList: List.of(state.undoList)..add(_last),
+ paintList: List.of(state.paintList)..removeLast(),
+ );
+ }
+
+ void redo() {
+ // 描画中か、redoできなかったら何もしない
+ if (state.isDrag || !canRedo) {
+ return;
+ }
+ // undoListの最後を取って、paintListに追加する
+ final _last = state.undoList.last;
+ state = state.copyWith(
+ undoList: List.of(state.undoList)..removeLast(),
+ paintList: List.of(state.paintList)..add(_last),
+ );
+ }
+
+ void clear() {
+ if (!state.isDrag) {
+ state = state.copyWith(paintList: [], undoList: []);
+ }
+ }
+
+ void addPaint(Offset startPoint) {
+ if (!state.isDrag) {
+ state = state.copyWith(
+ isDrag: true,
+ paintList: List.of(state.paintList)..add([startPoint]),
+ undoList: const [],
+ );
+ }
+ }
+
+ void updatePaint(Offset nextPoint) {
+ if (state.isDrag) {
+ final _paintList = List<List<Offset>>.of(state.paintList);
+ final _offsetList = List<Offset>.of(state.paintList.last)..add(nextPoint);
+ _paintList.last = _offsetList;
+
+ state = state.copyWith(paintList: _paintList);
+ }
+ }
+
+ void endPaint() => state = state.copyWith(isDrag: false);
+}
+```
+</div></details>