実装する機能
今回実装する機能は、
- 自由に画面をなぞると特定の範囲内で手書きで絵が描けるようにする
- ひとつ戻る(undo)ひとつ進む(redo)ボタンの実装
- 全部消すボタンの実装
になります。類似した機能が入ったアプリだと消しゴムが入っていますが、今回のアプリではそこまで必要性を感じなかったため実装しません。
パッケージ・バージョン
Flutterのバージョンは2.2.0
です。
使用するパッケージは状態管理のためにhooks_riverpod
とstatenotifier
、freezed
を使います。
特にそれ以外のパッケージは使用しません。
バージョンは以下の通りです。
dependencies:
flutter_hooks: ^0.17.0
freezed_annotation:
hooks_riverpod: ^0.14.0+4
dev_dependencies:
build_runner:
freezed:
手書きの実装
土台の作成
本題の実装になります。
今回の手書きの範囲は正方形にしたいので、画面中央に配置します。
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
を使用します。
...
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
を追加します。
// ステート
@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などを使えないようにするためのものです。
ではそれぞれの関数を実装していきます。
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()
を追加することを忘れないでください。
...
@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)),
),
...
描画する機能の実装
なぞったポジションをもとに描画する必要があるため、カスタムペインターを使います。
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;
}
これで一旦は描画できるようになっていると思います。
枠からはみ出さないよう修正
しかし、このままでは、範囲外まで描画できるようになっています。
要件を満たすように修正をしていきます
修正内容としては、枠を超えたら枠の端でとどまるようにします。
...
// ポジションの取得
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
を次のように変更します
onPanUpdate: (details) {
_controller.updatePaint(_getPosition(
MediaQuery.of(context).size.width, details.localPosition));
},
こうすることで、超えることがなくなったと思います。
undo,redo,全消しボタンの実装
最後にボタンの配置をします。
ボタンは左からundo, redo, 全消で配置します
また、undo, redoできる時のみアクティブカラーにします。
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),
),
],
),
以上になります!!!
完成図
コード全体
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;
}
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);
}