シンプルな壁紙対応時計を開発する

こんにちはnasustです。

Flutter入門者向けにシンプルな壁紙対応時計の開発を解説します。 入門向けなので簡単な実装となっています。

なお「 初回作成時のサンプルのカウントアッププログラムを読み解く 」で重複する内容は解説しません。

解説に使用するソースコードは以下で公開されています。

Flutterプロジェクトを作成

Flutterのアプリを作成するにはプロジェクトを作成します。 プロジェクトの作成方法はいくつかあります。

コマンドライン

以下のコマンドを実行するとプロジェクトが作成されます。

flutter create -i swift -a kotlin --org com.nasust flutter_clock

-i と -a

iOSとAndroidのプログラム言語を設定します。

-iはObjectCまたはSwiftが指定出来ます。

-aはJavaまたはkotlinが指定出来ます。

—org

KotlinのパッケージとiOSのbundle identifierを設定します。 com.nasustは自分の組織のものに書き換えてください。

私の場合はnasust.comというドメインを取得しているので、それを利用しています。

指定しないとcom.exampleになってしまうので必ず指定するようにしましょう。

VS Code

VS Codeのコマンドパレットを開いてFlutter: New Projectを実行します。

flutter create vscode

シンプルに作成出来ますが、コマンドラインの-i,-a,--orgが設定出来ないです。

Android Studio

Start a new Flutter projectを選択します。

{{< img src=“fluttercreateandroid_studio.png” scale=0.8 >}}

Android Studioのプロジェクト作成はウィザード形式で 内容を簡単に設定できるので一番お勧めです。

main.dart

lib/main.dartを編集します。

hello worldが表示されるサンプルコードになっています。 これを以下のように編集します。

import 'package:flutter/material.dart';
import 'package:image_picker/image_picker.dart';

import 'widgets/clock_widget.dart';
import 'widgets/wallpaper_widget.dart';

void main() => runApp(MyApp());

class MyApp extends StatelessWidget {
  // This widget is the root of your application.
  
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter Clock',
      theme: ThemeData.dark(),
      home: _ClockHomePage(),
    );
  }
}

class _ClockHomePage extends StatelessWidget {
  final _key = new GlobalKey<WallpaperWidgetState>();

  _pickImage() async {
    var imageFile = await ImagePicker.pickImage(source: ImageSource.gallery);
    if (imageFile != null) _key.currentState.imageFile = imageFile;
  }

  
  Widget build(BuildContext context) {
    return Scaffold(
      body: GestureDetector(
        behavior: HitTestBehavior.opaque,
        child: Stack(
          children: <Widget>[
            Container(child: WallpaperWidget(key: _key)),
            Container(child: Center(child: ClockWidget())),
          ],
        ),
        onTap: () => _pickImage(),
      ),
    );
  }
}

class MyApp extends StatelessWidget

ルートのWidgetです。ダークテーマを適用しています。 最初のページは_ClockHomePageを指定しています。

class _ClockHomePage extends StatelessWidget

StatelessWidgetを継承している_ClockHomePageです。 壁紙と時計の処理は子のWidgetにしているのでStatelessWidgetにしました。

_ClockHomePageStatefulWidgetにして壁紙と時計を一個のWidgetで実装することもできますが、時間の更新、壁紙の変更などで画面全体のWidgetツリーを更新する事になってしまうので負荷が掛かります。

Widgetツリーとは、FlutterのWidgetはツリー状の親子関係で管理されています。 StatefulWidgetの状態が更新されると子のWidgetが再ビルドの対象になります。

負荷を掛けないようにするには状態の変更で、Widgetツリーの再ビルドの対象を最小限にした方が良いです。

StatefulWidgetのパフォーマンスについては 「StatefulWidget class - widgets library - Dart API」 のPerformance considerationsで解説されております。

widget tree

DevToolsでWidgetツリーの構造が確認できます。

body: GestureDetector

画面をタッチしたら壁紙を選択する画面を表示したいので、 GestureDetectorでタッチイベントを拾っています。

see also: GestureDetector class - widgets library - Dart API

child: Stack

壁紙と時計を重ねたいのでStackを使用します。StackはレイアウトのWidgetです。

Stackには壁紙を表示するWallpaperWidgetと時計を表示するClockWidgetを配置しています。 それぞれのWidgetは、ContainerCenterでレイアウトを調整しています。

see also: Stack class - widgets library - Dart API
see also: Container class - widgets library - Dart API
see also: Center class - widgets library - Dart API

Screenshot 1578720424

DevToolsでDebug Paintを実行するとレイアウトが確認できます。 このようにFlutterはレイアウトのWidgetで簡単に配置できます。

壁紙の設定処理

onTap: () => _pickImage()で壁紙の画像を選択する画面を表示して、 選択された画像を壁紙に設定しています。

_pickImage() async {
    var imageFile = await ImagePicker.pickImage(source: ImageSource.gallery);
    if (imageFile != null) _key.currentState.imageFile = imageFile;
}

ImagePicker.pickImage(source: ImageSource.gallery)は 画像を選択する画面が表示されます。画像が選択されると画像ファイルが返されます。

このメソッドはpub.devで公開されているプラグインを使用しています。

このプラグイン使用するには、以下のようにpubspec.yamlを編集します。

dependencies:
  image_picker: ^0.6.3

VS CodeまたはAndroid Studioで開発している場合は、記述して保存すると自動的にダウンロードが始まって使用可能になります。

asyncとawaitについて

_pickImage() asyncawait ImagePicker.pickImageと記述されているのは、 これらの処理を非同期で行う為です。

ImagePicker.pickImageで画像が返されるタイミングは選択した後になります。その戻り値はFuture<File>になっています。 Futureは「画像が選択される」ようなイベントをthenメソッドに指定したコールバックで処理できます。

see also: Future class - dart:async library - Dart API

壁紙を設定する処理を Futureのthenメソッドでイベントをコールバックする場合のコードは以下の通りになります。

ImagePicker.pickImage(source: ImageSource.gallery).then((imageFile){
    if (imageFile != null) _key.currentState.imageFile = imageFile;
});

Future<File>のthenメソッドで指定しているコールバックは画像を選択されたタイミングで呼ばれます。

このようにコールバックであるのは非同期で処理する為です。 Flutterはシングルスレッドで動作しています。 もし逐次処理で画像が選択されるまで待つならばアプリ全体が停止します。

この「画像が選択された」のようなイベントをコールバックで処理する仕組みはFlutterがイベントループで動作している為です。

FlutterのようなGUIのアプリはユーザの操作など沢山のイベントが発生します。 無限ループでイベントをキューに貯めて、対応したコールバックで処理をします。 Android,iOS,Window,MacなどのGUIは、この形式で動作しています。 後、GUIじゃないですがNode.jsもイベントで動作しています。

偶にGUIが固まって、その状態で色々操作してから固まったのが解除されると一気に操作されるのは、 キューに操作のイベントが溜まっているからですね。

イベントループについては、「 The Event Loop and Dart (翻訳) - Qiita 」に詳しく解説されています。

そしてasync、awaitについてですが、Futureのthenメソッドとコールバックの処理を簡潔に書ける書式です。 コールバックの問題点は、コールバックの中にコールバックを書くと分かり難くなる問題があります。 1つは2つならともかく、多重のコールバックになるとソースが見辛くなりバグの元になりやすいです。

それを解決したのがasync、awaitです。

var imageFile = await ImagePicker.pickImage(source: ImageSource.gallery) は、コールバックをFutureのthenメソッドを隠蔽して一行で逐次処理っぽく書けるので分かりやすいです。

final _key = new GlobalKey() と _key.currentState.imageFile = imageFile

_keyの型はGlobalKey<WallpaperWidgetState>()です。

この_keyWallpaperWidgetのコンストラクターで名前付き引数keyに指定すると、 _key.currentStateWallpaperWidgetの状態管理するStateクラスを継承したWallpaperWidgetStateにアクセスできます。

この方法でWidgetに壁紙を設定しています。

GlobalKeyとはWidgetを識別します。このようにGlobalKeyで関連付けると、 簡単にWidgetとStateを取得できます。

see also: GlobalKey class - widgets library - Dart API

clock_widget.dart

libs/widgets/clock_widget.dartは以下の通りです。 時計を表示します。

import 'dart:async';

import 'package:flutter/material.dart';
import 'package:flutter/widgets.dart';
import 'package:intl/intl.dart';

class ClockWidget extends StatefulWidget {
  ClockWidget({Key key}) : super(key: key);

  
  ClockWidgetState createState() => ClockWidgetState();
}

class ClockWidgetState extends State<ClockWidget> {
  var _nowTime = DateTime.now();
  final _dateFormat = new DateFormat.Hms();

  
  void initState() {
    super.initState();
    _initTimer();
  }

  void _initTimer() {
    Timer.periodic(Duration(milliseconds: 33),
        (Timer timer) => setState(() => _nowTime = DateTime.now()));
  }

  
  Widget build(BuildContext context) {
    var text = _dateFormat.format(_nowTime);
    var fontSize = MediaQuery.of(context).size.width * 0.15;
    return Stack(
      children: <Widget>[
        Text(
          text,
          style: TextStyle(
              fontSize: fontSize,
              foreground: Paint()
                ..style = PaintingStyle.stroke
                ..strokeWidth = 4
                ..color = Colors.black),
        ),
        Text(
          text,
          style: TextStyle(
            fontSize: fontSize,
          ),
        )
      ],
    );
  }
}

class ClockWidget extends StatefulWidget

このWidgetは時計という状態を持っているので、StatefulWidgetを継承して実装しています。 StatefulWidgetはUIの一部が動的に変化する場合に便利です。 createStateClockWidgetStateを作成して返しています。

see also: StatefulWidget class - widgets library - Dart API

class ClockWidgetState extends State

時計のテキストを作成するStateを継承しています。

see also: State class - widgets library - Dart API

var _nowTime = DateTime.now();

DateTime型の現在の時間のプライベート変数です。

see also: DateTime class - dart:core library - Dart API

final _dateFormat = new DateFormat.Hms();

DateFormat型のプライベート変数です。 DateFormatはDateTimeをフォーマットに従って文字列に変換できます。

see also: DateFormat class - intl library - Dart API

DateFormatはpub.devで公開されているDart Packageです。

使用するにはpubspec.yamlを以下の様に記述します。

dependencies:
  intl: ^0.16.0

intlは国際化対応に便利なパッケージです。

void initState()

このメソッドはオーバーライドして実装しています。 initState()はWidget作成時に呼ばれます。

_initTimerメソッドを実行しています。

void _initTimer()

ここでは時計の為の時間の_nowTimeを 30フレームで現在時刻に更新するタイマー登録しています。

void _initTimer() {
    Timer.periodic(Duration(milliseconds: 33),
        (Timer timer) => setState(() => _nowTime = DateTime.now()));
}

タイマーはTimerクラスで登録できます。

see also: Timer class - dart:async library - Dart API

タイマーで実行する関数は、setStateメソッド内で実行しています。 setStateメソッドは引数のコールバックを指定した関数は同期的に実行して、状態変化をFlutter UIのフレームワークに通知します。指定したコールバックで状態変化を実装します。

see also: setState method - State class - widgets library - Dart API

Widget build(BuildContext context)

時間のテキストのUIを作成しています。

var fontSize = MediaQuery.of(context).size.width * 0.15 は画面サイズに合わせてフォントサイズを計算しています。

see also: MediaQuery class - widgets library - Dart API

画面に文字を表示するにはTextのWidgetを使用します。

see also: Text class - widgets library - Dart API

Stack(
  children: <Widget>[
    Text(
      text,
      style: TextStyle(
          fontSize: fontSize,
          foreground: Paint()
            ..style = PaintingStyle.stroke
            ..strokeWidth = 4
            ..color = Colors.black),
    ),
    Text(
      text,
      style: TextStyle(
        fontSize: fontSize,
      ),
    ),
  ]
)

Stackで同じ文字を重ねて表示しているのは、アウトラインに黒の枠を表示する為です。 1個目のforeground引数のPaintで黒の枠を描画しています。

see also: Paint class - dart:ui library - Dart API

1個目のTextは枠以外が透明になりますので、2個目のTextで文字の塗りつぶしをしています。 重ねることで白塗りの黒枠の文字が表示されます。

現状、Flutterで文字に枠を表示される方法は、この様にStackPaintを使用するか、文字に影を付けるShadowを使用する方法があります。

see also: Shadow class - dart:ui library - Dart API
see also: Missing Text outline / stroke / border feature · Issue #24108 · flutter/flutter

wallpaper_widget.dart

libs/widgets/wallpaper_widget.dartは以下の通りです。 壁紙を表示します。

import 'dart:io';

import 'package:flutter/widgets.dart';

class WallpaperWidget extends StatefulWidget {
  WallpaperWidget({Key key}) : super(key: key);

  
  State<WallpaperWidget> createState() {
    return WallpaperWidgetState();
  }
}

class WallpaperWidgetState extends State<WallpaperWidget> {
  Image _image;

  set imageFile(File imageFile) {
    setState(() {
      _image = Image.file(imageFile,fit: BoxFit.cover);
    });
  }

  
  Widget build(BuildContext context) {
    return Container(
      constraints: BoxConstraints.expand(),
      child: _image,
    );
  }
}

WallpaperWidget extends StatefulWidget

壁紙の状態を持っていますので、StatefulWidgetを継承しています。 createState()WallpaperWidgetStateを作成しています。

set imageFile(File imageFile)

画像を設定するプロパティです。 プライベート変数であるImage _imageに画像を設定しています。 Image.file(imageFile,fit: BoxFit.cover)fit: BoxFit.coverは fitさせる方法を指定しています。

see also: Image class - widgets library - Dart API
see also: BoxFit enum - painting library - Dart API

Widget build(BuildContext context)

画像を内包したContainerを返しています。 constraints: BoxConstraints.expand()は、_imageをContainerの大きさに合わせて拡大縮小させます。

see also: constraints property - Container class - widgets library - Dart API
see also: BoxConstraints class - rendering library - Dart API

まとめ

Flutter解説用に壁紙対応時計アプリを開発しました。今回の解説で注目すべき点は以下の通りです。

  • pub.devでプラグインを簡単に導入できる
  • StatelessWidgetStatefulWidgetを組み合わせて負荷を最小限にする
  • async,awaitで非同期の処理を行える
  • GlobalKeyWidgetStateを簡単に取得できる

これらの機能を利用することで、iOSやAndroidと比べるとシンプルに開発できました。 フレームワーク、宣言型UIとdartのスクリプト言語という特徴でiOSやAndroidのアプリ開発より少ない工程で実装できるのがFlutterの良いところです。

iOS、Android、Web、APIサーバーなどのフロントエンド・バックエンドを開発するソフトウェアエンジニアです。 UI/UXが好きです。かっこいいUIやWebデザインを眺めるのが趣味です。 このブログはソフトウェア開発関係の内容を記事にしています。
web service:
GitHubQiitaTwitterはてなブログ
handle name:
nasust
real name:
hideki mori
job:
ソフトウェアエンジニア
develop:
target: ios, android, web page, single page application, api server, system service, cli tool, linux embedded device

lang: c/c++, go, swift, objective-c, java, kotlin, typescript, dart, javascript, ruby, python, php

tool: vscode, xcode, android studio, photoshop, vim, docker