相変わらず、育児と仕事の両立に苦戦している遠藤です。
さて、育児をしていると、子供の前でスマホを触ってしまうシーンが多々あります。
ソシャゲで遊んだり YouTube を眺めているわけではなく、育児の記録をつけたり、写真、動画を撮るために、半ば仕方なく触るのですが、子供(0歳11ヶ月)もスマホを触りたがります。
本当は、あまり見せたり触らせたくないのですが、興味を持ってしまうのであれば、いっそ子供でも安心して遊べるようにと、お絵描きアプリを作ってみました。
コードは弊社の Github にアップしています。
https://github.com/milldea/blog-flutter-paint-sample
clone すればそのまま動かせますので、興味がある方は是非動かしてみてください。
機能追加の PR もお待ちしています!
環境
Flutter のインストール自体は、公式をご確認ください。
インストール後の状態は以下の通りとなっています。
$ flutter doctor
Doctor summary (to see all details, run flutter doctor -v):
[✓] Flutter (Channel stable, 3.3.4, on macOS 12.6 21G115 darwin-arm, locale ja-JP)
[✓] Android toolchain - develop for Android devices (Android SDK version 33.0.0-rc2)
[✓] Xcode - develop for iOS and macOS (Xcode 14.0.1)
[✓] Chrome - develop for the web
[✓] Android Studio (version 2021.2)
[✓] VS Code (version 1.71.2)
[✓] Connected device (2 available)
[✓] HTTP Host Availability
• No issues found!
出来上がったもの
少し画質が荒いですが、以下のようなものが完成しました。
画面上部に色を変えるパレットが出ていて、あとは画面をなぞってお絵描きをします。
細かい機能として、 undo/redo や画像保存機能を設けました。
機能と解説
コードの中身を一部、簡単に解説していきます。
お絵描き機能
該当コード
https://github.com/milldea/blog-flutter-paint-sample/blob/main/lib/main.dart#L426
これが、メイン機能になります。
CustomPainter を使って canvas に線を描画しています。
後で少し説明しますが、線だけでなく、直前まで描画済みの image を保存しておき、その上に線を描画するという方法を取っています。
// 実際に描画するキャンバス
class PaintCanvas extends CustomPainter{
final List<LinePoints> lines;
final List<Offset> nowPoints;
final Color nowColor;
final ui.Image? image;
PaintCanvas(this.lines, this.nowPoints, this.nowColor, this.image);
@override
void paint(Canvas canvas, Size size) {
Paint p = Paint()
..isAntiAlias = true
..color = Colors.redAccent
..strokeCap = StrokeCap.round
..strokeWidth = 5.0;
if (image != null) {
canvas.drawImage(image!, const Offset(0, 0), p);
}
for (int i = 1; i < nowPoints.length; i++){
Offset p1 = nowPoints[i - 1];
Offset p2 = nowPoints[i];
p.color = nowColor;
canvas.drawLine(p1, p2, p);
}
}
@override
bool shouldRepaint(CustomPainter oldDelegate) {
return true;
}
}
ペンの色を変える
該当コード
https://github.com/milldea/blog-flutter-paint-sample/blob/main/lib/main.dart#L467
描画するペンの色を変えるボタンは、 ListView として画面上部に並べています。
// 色を変えるボタンClass
class ColorPallet extends StatelessWidget {
final Color color;
final Function changeColor;
const ColorPallet({Key? key, required this.color, required this.changeColor, required this.isSelect}) : super(key: key);
final bool isSelect;
void onPressed(){
changeColor(color);
}
@override
Widget build(BuildContext context) {
return RawMaterialButton(
onPressed: onPressed,
constraints: const BoxConstraints(minWidth: 85.0,minHeight: 80.0),
child: Container(
margin: const EdgeInsets.only(top: 5.0,bottom: 5.0),
width: 80.0,
height: 80.0,
decoration: BoxDecoration(
color: color,
borderRadius: const BorderRadius.all(Radius.circular(40.0)),
border: Border.all(color: Colors.white,width: isSelect?4.0:0.0)
),
)) ;
}
}
特に難しいところはないですが、変更できる色の指定を flutter 標準から持ってくることで、勝手に良い感じのグラデーションを表現できています。
List<MaterialAccentColor> colors = Colors.accents;
Floating Action Button をアニメーションさせて拡張する
該当コード
https://github.com/milldea/blog-flutter-paint-sample/blob/main/lib/main.dart#L371
子供が触るので、画面にできるだけメニューボタンを置きたくありませんでした。
ので、一つだけ FloatingActionButton を配置し、ボタンを押すとメニューが出てくるようにしました。
この実装は、公式がドキュメントを公開してくれていたので、そのまま採用しました。
https://docs.flutter.dev/cookbook/effects/expandable-fab
公式には色々書いてあって、結局どこを使えば良いのよ?っというのがパッと分からなかったので、実装では expandable_fab.dart を別ファイルにして、呼び出す形にしています。
https://github.com/milldea/blog-flutter-paint-sample/blob/main/lib/expandable_fab.dart
さすが公式だけあって、呼び出し方も簡単で、距離とボタンを指定するだけで使えるようになっています。
floatingActionButton: ExpandableFab(
distance: 150.0,
children: [
ActionButton(
onPressed: () {},
icon: const Icon(Icons.delete),
enabled: isWriteData(),
),
ActionButton(
onPressed: () {},
icon: const Icon(Icons.save_alt),
enabled: isWriteData(),
),
ActionButton(
onPressed: _undo,
icon: const Icon(Icons.undo),
enabled: canUndo(),
),
ActionButton(
onPressed: _redo,
icon: const Icon(Icons.redo),
enabled: canRedo(),
),
ActionButton(
onPressed: _showPallet,
icon: Icon(showPallet ? Icons.color_lens_sharp : Icons.color_lens_outlined),
enabled: true,
),
],
),
Undo & Redo
該当コード
https://github.com/milldea/blog-flutter-paint-sample/blob/main/lib/main.dart#L188-L219
進む & 戻る機能ですね。
0歳の子供は使わないだろ!っとツッコミが入りそうですが、もちろん、趣味で作りました。
描画では、一筆書き分の座標の配列と、その時の色をセットにして、Listとして持たせています。
// 一筆書き分の座標を持つClass
class LinePoints{
final List<Offset> points;
final Color lineColor;
LinePoints(this.points, this.lineColor);
}
// state で更新する List
List<LinePoints> lines = <LinePoints>[];
Undo は、 lines から最後の要素を取り除けば実装できます。
removeLast();
という便利な関数があります。
ただ、それだけでは、 Redo が実装できません。
なので、 Undo したものを一時保存しておく UndoList を用意しておき、 Undo されたものを追加していきます。
Redo されたら、今度は UndoList から取り出して、 lines に戻していきます。
スクリーンショットを保存
該当コード
https://github.com/milldea/blog-flutter-paint-sample/blob/main/lib/main.dart#L244-L307
https://github.com/milldea/blog-flutter-paint-sample/blob/main/ios/Runner/Info.plist#L5-L8
子供の書いた絵は、やっぱり記念に保存しておきたいですよね。
ということで、保存機能を実装しました。
Android はアプリ専用のフォルダを作成し、 MediaStore に追加します。
Android の実装では、こちらの方の実装を参考にしました。
https://www.evertop.pl/en/mediastore-in-flutter/
iOS は標準のフォルダアプリから見えるところに保存しています。
そして、以下は実際に、10ヶ月の娘が描いたイラスト(?)です。
まぁ、描いたというより、 触った
というのが正しいですが、楽しそうでしたね。
とても可愛かったです。
描画処理の軽減
該当コード
https://github.com/milldea/blog-flutter-paint-sample/blob/main/lib/main.dart#L108-L133
最後のポイントとして、描画処理を軽減しています。
触った座標を配列で保持しておき、毎フレーム最初から描画を繰り返すことになるので、線を書けば書くほど、どんどん重たくなっていました。
そこで、今回は
- 画面を触る
- 座標を追加する
- 最初から全て描画する
- 画面を触る
- 座標を追加する
- 最初から全て描画する
- ・・・
という流れで実装していたものを以下の流れに変更しました。
- 画面を触る
- 画像と追加分の座標を描画する
- image として保持
- 画面を触る
- 画像と追加分の座標を描画する
- image として保持
- ・・・
一筆書き分の線が書き終わったタイミングで、前回作成した画像の上に、線を描画して画像として保存。
という処理を加えました。
実際の描画も、画像の上に現在一筆書き中の線のみを描画するだけにしました。
毎回画像生成が挟まるので、処理がやや煩雑になりましたが、処理落ちするようなことはなくなりました。
// 線を Image にして保存
Future<void> setOldImage() async{
final recorder = ui.PictureRecorder();
final canvas = Canvas(recorder);
final p = Paint()
..isAntiAlias = true
..strokeCap = StrokeCap.round
..strokeWidth = 5.0;
if (image != null) {
canvas.drawImage(image!, const Offset(0, 0), p);
}
for (int i = 1; i < nowPoints.length; i++){
Offset p1 = nowPoints[i - 1];
Offset p2 = nowPoints[i];
p.color = nowColor;
canvas.drawLine(p1, p2, p);
}
final picture = recorder.endRecording();
int w = MediaQuery.of(context).size.width.toInt();
int h = MediaQuery.of(context).size.height.toInt();
ui.Image tmp = await picture.toImage(w, h);
setState(() {
image = tmp;
});
}
感想
というわけで、コツコツと 3日ほどかけて、アプリを作ってみたわけですが、子供は 3分くらい楽しそうに触って、去っていきました...。
もう遊んでくれません。
なんなら、開発期間含めて、私の方が楽しんでしまいました。
次は、子供の成長に合わせて、もっと楽しんでもらえるアプリを作ってみたいなと思います。