リバース・エンジニアリング

Flultterとテックブログと時々iOS

61.【ナンバーズアプリ】ゲームロジックを組み立てる

ゲームロジックを組み立てる

今日はナンバーズアプリのゲームロジックを組み立てます。

【目次】

前回の記事はこちらになります。興味があったら読んでね。

blog.tamappe.com

このゲームで作らないといけない機能は大まかに3つほどです。

  1. 上のラベルに表示されている数のボタンをタップすると数字が繰り上がるという正解のロジック
  2. タイマーカウント
  3. 盤面のボタンの配置をランダムにする

正解のロジック

今回は一番上の「正解のロジック」の作っていきます。
そのために必要なのか「次の数字」が表示されているUIのデザインを整えることです。

f:id:qed805:20200507232203p:plain:w300
ゲーム画面のデザイン

ここの左上の部分を作っていこうと思います。

といっても特段難しいことではなく、
レイアウトは前回で Columnを使って縦に widget を並べました。


正解の数字  タイマー

    盤面


という並びになったらOKです。
細かい余白は Paddingなり margin を使って調整します。

縦の並びは Column で行いましたので
正解の数字とタイマーは Row で並べることにします。

Row(
                mainAxisAlignment: MainAxisAlignment.spaceBetween,
                crossAxisAlignment: CrossAxisAlignment.end,
                children: <Widget>[
                  正解の数字,
                  タイマー
                ],
              ),

みたいな感じです。

実際はこのように並べました。

Row(
                mainAxisAlignment: MainAxisAlignment.spaceBetween,
                crossAxisAlignment: CrossAxisAlignment.end,
                children: <Widget>[
                  Padding(
                    padding: EdgeInsets.only(left: 20.0),
                    child: ClipRRect(
                      borderRadius: BorderRadius.circular(5),
                      child: Container(
                          width: 140.0,
                          height: 50.0,
                          color: Colors.white,
                          child: Center(
                            child: Text(
                              '$currentNumber',
                              textAlign: TextAlign.center,
                              style: TextStyle(
                                  fontWeight: FontWeight.bold, color: Colors.black, fontSize: 30),
                            ),
                          )),
                    ),
                  ),
                  Container(
                    padding: EdgeInsets.only(right: 20),
                    child: Center(
                        child: Text(
                      'Timer: 3.57',
                      style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
                    )),
                  )
                ],
              ),

正解の数字を currentNumber というプロパティで定義します。

game_play_page.dart

class GamePlayPage extends StatefulWidget {
  @override
  _GamePlayPageState createState() => _GamePlayPageState();
}

class _GamePlayPageState extends State<GamePlayPage> {
  int currentNumber = 1;

  void _onPressedNumberButton() {}

  @override
  Widget build(BuildContext context) {
    return Scaffold(
    );
  }
}

と定義しました。

ゲームロジック

あとは currentNumber を使って正解ロジックを組み立ててればいいだけです。
正解したらcurrentNumber の数字を更新すればOKです。
正解しているかどうかはボタンをタップした時にそのボタンが持っている数字と一致しているかどうかを確認します。

if (index == currentNumber)

こんな感じです。

前回、ボタンをタップしたときの関数プロパティ onPressed を定義しました。
これを使って GridView.count の中でロジックを書きます。

GridView.count(
                    mainAxisSpacing: 8,
                    crossAxisSpacing: 8,
                    physics: const NeverScrollableScrollPhysics(),
                    crossAxisCount: 5,
                    children: List.generate(25, (index) {
                      return NumberButton(index + 1, () {
                        if (index + 1 == currentNumber) {
                          _updateCurrentNumber();
                        }
                      });
                    })

index + 1 == currentNumber の時に updateCurrentNumber メソッドを叩きます。
updateCurrentNumber メソッドでは

  void _updateCurrentNumber() {
    if (currentNumber >= 25) {
      Navigator.push(
        context,
        new MaterialPageRoute<Null>(
          settings: RouteSettings(name: Constants.clearRoute),
          builder: (BuildContext context) => ClearPage(),
        ),
      );
    }
    setState(() {
      currentNumber += 1;
    });
  }

というふうに処理しました。
今はまだ Provider とか使っていませんので StatefulWidget の setState を使ってUIを更新しています。
本当は Provider で StatelessWidget にして comsumer で currentNumber を更新するのが今どきみたいです。
Provider については余力があったときに勉強してみようと思います。

これで盤面のボタンをタップして正解の数字をヒットしたら currentNumber が更新されて次の数字に更新されます。

この今回は部分的にコードの処理を説明しただけなので分かりにくいと思います。
そこで最後にこの部分の全体のソースコードを載せることにします。

game_play_page.dart

class GamePlayPage extends StatefulWidget {
  @override
  _GamePlayPageState createState() => _GamePlayPageState();
}

class _GamePlayPageState extends State<GamePlayPage> {
  int currentNumber = 1;

  void _onPressedNumberButton() {}

  void _updateCurrentNumber() {
    if (currentNumber >= 25) {
      Navigator.push(
        context,
        new MaterialPageRoute<Null>(
          settings: RouteSettings(name: Constants.clearRoute),
          builder: (BuildContext context) => ClearPage(),
        ),
      );
    }
    setState(() {
      currentNumber += 1;
    });
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: Center(
        child: Padding(
          padding: const EdgeInsets.symmetric(horizontal: 20.0),
          child: Column(
            mainAxisAlignment: MainAxisAlignment.center,
            children: <Widget>[
              Row(
                mainAxisAlignment: MainAxisAlignment.spaceBetween,
                crossAxisAlignment: CrossAxisAlignment.end,
                children: <Widget>[
                  Padding(
                    padding: EdgeInsets.only(left: 20.0),
                    child: ClipRRect(
                      borderRadius: BorderRadius.circular(5),
                      child: Container(
                          width: 140.0,
                          height: 50.0,
                          color: Colors.white,
                          child: Center(
                            child: Text(
                              '$currentNumber',
                              textAlign: TextAlign.center,
                              style: TextStyle(
                                  fontWeight: FontWeight.bold, color: Colors.black, fontSize: 30),
                            ),
                          )),
                    ),
                  ),
                  Container(
                    padding: EdgeInsets.only(right: 20),
                    child: Center(
                        child: Text(
                      'Timer: 3.57',
                      style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
                    )),
                  )
                ],
              ),
              SizedBox(
                height: 48.0,
              ),
              SizedBox(
                height: 400.0,
                child: GridView.count(
                    mainAxisSpacing: 8,
                    crossAxisSpacing: 8,
                    physics: const NeverScrollableScrollPhysics(),
                    crossAxisCount: 5,
                    children: List.generate(25, (index) {
                      return NumberButton(index + 1, () {
                        if (index + 1 == currentNumber) {
                          _updateCurrentNumber();
                        }
                      });
                    })
                ),
              )
            ],
          ),
        ),
      ),
    );
  }
}

class NumberButton extends StatelessWidget {
  final int number;
  final Function onPressed;

  NumberButton(this.number, this.onPressed);

  @override
  Widget build(BuildContext context) {
    return Container(
      width: 60,
      height: 60,
      decoration: BoxDecoration(
        border: Border.all(color: Colors.white, width: 2.0),
        borderRadius: BorderRadius.circular(10),
        color: Constants.orangeColor,
      ),
      child: FlatButton(
          child: Text(
            '$number',
            style: TextStyle(
              fontSize: 20.0,
              fontWeight: FontWeight.bold,
            ),
          ),
          onPressed: onPressed),
    );
  }
}

このようになりました。

これで正解したときのロジックが完成になります。

今日はここまでにします。

それではバイバイ!

60.【ナンバーズアプリ】メインのゲーム画面のデザインGridViewで作る

【目次】

メインのゲーム画面のデザイン GridView で作る

それでは今回からアプリのメイン機能であるゲーム画面を作って行きます(というか作りました)。
前回までのあらすじを読みたい方はこちらを読んでね!

blog.tamappe.com

メインの画面のデザインをおさらいします。

f:id:qed805:20200507232203p:plain
ゲーム画面

上下に部品を置いています。

上が現在の数字とタイマーで、下にゲーム用のボタンが1から25まで並んでいます。
実際にアプリで遊べる頃にはこの1から25の番号ボタンはランダムで出る想定です。でないと簡単すぎますw。

番号のボタンを GridView で作る

Flutter ではそれぞれの部品を細かく作ってから乗せるよりも先にざっくばらんにレイアウトを作ったほうがスムーズに開発できます。
まずはちなみにこのデザインを iOS で作るなら、多分 UICollectionView で DataSource と Delegate を使ってゴリゴリ書いていく気がします。
Flutter で UICollectionView に近い widget は GridView です。
これを使ってデザインを作っていきます。

盤面 GridView のソースコード

それでは実際にレイアウトを簡単に作ったものと GridView で下のデザインを作ったときのソースコードを載せていきます。

game_play_page.dart

import 'package:flutter/material.dart';
import 'package:twentyfive/utils/constants.dart';

class GamePlayPage extends StatefulWidget {
  @override
  _GamePlayPageState createState() => _GamePlayPageState();
}

class _GamePlayPageState extends State<GamePlayPage> {
  void _tappedNumberButton() {}

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: Center(
        child: Padding(
          padding: const EdgeInsets.symmetric(horizontal: 20.0),
          child: Column(
            mainAxisAlignment: MainAxisAlignment.center,
            children: <Widget>[
              Row(
                mainAxisAlignment: MainAxisAlignment.spaceBetween,
                children: <Widget>[Text('a'), Text('b')],
              ),
              SizedBox(
                height: 48.0,
              ),
              SizedBox(
                height: 400.0,
                child: GridView.count(
                    mainAxisSpacing: 8,
                    crossAxisSpacing: 8,
                    physics: const NeverScrollableScrollPhysics(),
                    crossAxisCount: 5,
                    children: List.generate(25, (index) {
                      return NumberButton(index + 1, _tappedNumberButton);
                    })),
              )
            ],
          ),
        ),
      ),
    );
  }
}

今回は GridView.count を使って横幅に関係なく横に5つボタンを並べるようにしました。
ボタン一つのサイズを決める場合は GridView.extent を使えばいいのかなと。

GridView の中身はスクロールさせたくなかったので

physics: const NeverScrollableScrollPhysics()

と設定しています。

GridView の中にはあとで説明する NumberButton の widget を乗せています。

                   children: List.generate(25, (index) {
                      return NumberButton(index + 1, _tappedNumberButton);
                    })),

ボタンを縦 5 x 横 5 並べるために 25 と設定しています。

ボタン NumberButton のソースコード

次に NumberButton のソースコードを載せます。

新しいファイルは作成せずに GamePlayPage の下に定義しました。

game_play_page.dart

class NumberButton extends StatelessWidget {
  final int number;
  final Function onPressed;

  NumberButton(this.number, this.onPressed);

  @override
  Widget build(BuildContext context) {
    return Container(
      width: 60,
      height: 60,
      decoration: BoxDecoration(
        border: Border.all(color: Colors.white, width: 2.0),
        borderRadius: BorderRadius.circular(10),
        color: Constants.orangeColor,
      ),
      child: FlatButton(
          child: Text(
            '$number',
            style: TextStyle(
              fontSize: 20.0,
              fontWeight: FontWeight.bold,
            ),
          ),
          onPressed: onPressed),
    );
  }
}

番号としての index とボタンをタップした時の関数 onPressed をプロパティとして定義しました。
ボタンは FlatButton を使って作成しています。

ここで iOS エンジニアに注目していただきたいのがソースコードの行数です。
iOS アプリ開発ではボタンに枠線と丸みを設定するのは色々調整が必要ですが、 Flutter ではほんの数行で定義することができるのです。

角丸・枠線の部分

     decoration: BoxDecoration(
        /// 枠線の色とサイズ
        border: Border.all(color: Colors.white, width: 2.0),
        /// 角丸の丸み
        borderRadius: BorderRadius.circular(10),
        /// ボタンの色
        color: Constants.orangeColor,
      ),

非常にシンプルで可読性がいいですね。定義を塊で見れるのも非常に good!

角丸・枠線の付け方自体は過去に記事にしています。

blog.tamappe.com

カウント画面からゲームプレイ画面への画面遷移

最後にプレイ画面への遷移についてはまだ定義していませんので補足しておきます。

画面遷移の定義は MaterialApp で宣言します。

main.dart

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

class MyApp extends StatelessWidget {
  // This widget is the root of your application.
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      initialRoute: Constants.startRoute,
      routes: <String, WidgetBuilder>{
        Constants.startRoute: (BuildContext context) => StartPage(),
        /// プレイ画面への遷移
        Constants.playRoute: (BuildContext context) => GamePlayPage(),
      },
      title: 'Numbers',
      theme: new ThemeData(
        fontFamily: 'Arial',
        brightness: Brightness.dark,
        primaryColor: Colors.lightBlue[800],
        accentColor: Colors.cyan[600],
      ),
    );
  }
}

routes のところに "/play" のパスを追加しています。

とあるボタンをタップしてプレイ画面へ遷移させたい場合はボタンをタップした時の処理で

Navigator.pushReplacement(
          context,
          PageRouteBuilder(
            pageBuilder: (context, animation1, animation2) => GamePlayPage(),
          ),
        );

を入れてあげればOKです。
アニメーションはかけたくなかったので書いていません。

stackoverflow.com

これだけで画面遷移せずにゲームプレイ画面に飛ぶようになります。

これでビルドするとゲームプレイ画面が下のように表示されます。

f:id:qed805:20200527223952p:plain:w300
ボタンの盤面

まだまだブラッシュアップしていく必要がありますが、下の方のデザインが完成しました。

作ったあとの感想

改めて見返すと GridView はめちゃくちゃ便利すぎます。
Swift だと UICollectionView でゴリゴリ書いていくところたった数行で縦x横に item を並べるUIが出来上がりました。
このように Flutter は UI の開発のコスパが優れているんですよね。
しかも触っていく度に新しい発見があります。

今日はここまでにします。

それではバイバイ!

59.【ナンバーズアプリ】Flutter でカスタムフォントを使う方法

目次

Flutter でカスタムフォントを使う方法

今回は Flutter でカスタムフォントを使う方法について紹介します。

Flutter は何もフォントを指定しない場合、デフォルトのフォントが文字に反映されます。 デフォルトで使われるフォントは「Roboto」というフォントです。

fonts.google.com

対して、iOS で使われている一般的なフォントは iOS8 までは Helvetica Neue (ハルベチカ)でしたが、iOS9 からの標準フォントは San Francisco です。

https://developer.apple.com/fonts/

またさらに日本語のフォントは、ヒラギノWebフォントと言われています。

これらがよく iOS アプリで指定されるフォントだったりします(^_^;)。

www.screen-hiragino.jp

なので、Flutter でアプリ開発する時はフォントを指定しないと Roboto が反映されてしまい、見た目がデザインと違うことがあります。

フォントを指定して使う方法

そこで Flutter で Roboto 以外のフォントを使う方法を紹介します。

main.dart

import 'package:flutter/material.dart';

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

class MyApp extends StatelessWidget {
  // This widget is the root of your application.
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'タイトル',
      theme: new ThemeData(
        fontFamily: 'Hiragino Kaku Gothic ProN',
      ),
    );
  }
}

ThemeData の fontFamily にフォントの名前を指定します。

github.com

Flutter で使えるフォントであればこれで反映できます。Google Font にそれぞれ載っています。

fonts.google.com

カスタムフォントをダウンロードして使う方法

それでは Google Font に載っていないカスタムフォントを使う方法についてです。 こちらは Flutter 公式ドキュメントにも実装の仕方が紹介されています。

flutter.dev

ということでこの通りにやっていきましょう。 今回は「Arial」というフォントを使いたいのでダウンロードページからフォントをDLします。

jp.ffonts.net

ダウンロードして zip を解答すると拡張子が ttf のファイルが見つかります。 これがフォントのファイルになります。

Flutter プロジェクトのルートに fonts パッケージを作成します。

f:id:qed805:20200517173958p:plain:w300
fontsディレクトリー

この fonts ディレクトリにダウンロードしたフォントをインポートします。

f:id:qed805:20200517174054p:plain:w300
フォントをインポート

次に pubspec.yaml ファイルを編集します。

  # example:
  fonts:
     - family: Arial
       fonts:
         - asset: fonts/Arial.ttf

このように変更しました。 このあとに update してプロジェクトファイル全体に反映させます。

実際に使う際は - family で指定した文字列を使うのが正しいのかなと思います。

あとは main.dart などで ThemeData に設定すれば全体に反映されます。

main.dart

import 'package:flutter/material.dart';

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

class MyApp extends StatelessWidget {
  // This widget is the root of your application.
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      theme: new ThemeData(
        fontFamily: 'Arial',
      ),
    );
  }
}

他にも 任意に Text の TextStyle で fontFamily に指定すれば反映できると思います。 これでダウンロードしなければ使えないフォントの使い方が分かりました。

それではバイバイ。

58. Flutter で Cannot provide both a color and a decoration エラーが出てしまったら?

Flutter で Cannot provide both a color and a decoration エラー?

Flutter で書いている時に Cannot provide both a color and a decoration のエラーが発生しました。

エラー内容

Cannot provide both a color and a decoration
To provide both, use "decoration: BoxDecoration(color: color)".
'package:flutter/src/widgets/container.dart':
Failed assertion: line 320 pos 15: 'color == null || decoration == null'

このエラーが出たら、Container のところを見てみましょう。

具体例

Container(
              width: 100,
              height: 100,
              color: Colors.blueAccent,
              decoration: BoxDecoration(
                border: Border.all(
                    color: Colors.greenAccent
                ),
                borderRadius: BorderRadius.circular(5),
              ),
              child: Text('color and decoration エラー'),
            )

例えばあるボタンに枠線と背景色をつけようと思った時につい書いてしまいそうなコードですね。
今回は Text ですけど。

Container は枠線やサイズを調整できてとても便利ですが、どうやら枠線と背景色を同時につけることができないみたいです。
背景色か枠線どちらかを削除するとエラーが消えます。

Container(
              width: 100,
              height: 100,
              decoration: BoxDecoration(
                border: Border.all(
                    color: Colors.greenAccent
                ),
                borderRadius: BorderRadius.circular(5),
              ),
              child: Text('color and decoration エラー'),
            )

どうやってwidgetに枠線+背景色をつけたら良いでしょうか。

ただ、それだとどうやって枠線+背景色をつけたら良いでしょうか。

答えはBoxDecoration の中で color 指定すれば大丈夫です。

Container(
              width: 100,
              height: 100,
              decoration: BoxDecoration(
                border: Border.all(
                    color: Colors.greenAccent
                ),
                color: Colors.blueAccent,
                borderRadius: BorderRadius.circular(5),
              ),
              child: Text('color and decoration エラー'),
            )

これでエラーが発生しなくなります。

StackOverFlow に同じ質問がありました。

stackoverflow.com

それではバイバイ。

57.【ナンバーズアプリ】Flutterでサークルプログレスバーにアニメーション処理を追加して動かしてみる

【目次】

Flutter でサークルプログレスバーにアニメーション処理を追加して動かしてみる

本日は前回作成したサークルプログレスバーにアニメーションを入れる作業をしていきます。

前回分の内容

blog.tamappe.com

出来上がりは前回ツイッターで呟いたようなアニメーションになります。

ソースコード

円のクラス CircleProgress

アニメーションを入れるクラスを CircleProgress にします。

circle_progress.dart

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

class CircleProgress extends CustomPainter {

  double currentProgress;
  CircleProgress(this.currentProgress);

  @override
  void paint(Canvas canvas, Size size) {
    Paint outerCircle = Paint()
        ..strokeWidth = 5
        ..style = PaintingStyle.stroke;
    Paint completeArc = Paint()
      ..strokeWidth = 5
      ..color = Colors.white
      ..style = PaintingStyle.stroke
      ..strokeCap = StrokeCap.round;

    Offset center = Offset(size.width /2, size.height / 2);
    double radius = min(size.width / 2, size.height / 2) - 7;

    if (currentProgress >= 200) {
        outerCircle.color = Colors.yellowAccent;
    } else if (currentProgress >= 100) {
        outerCircle.color = Colors.white;
    }

    canvas.drawCircle(center, radius, outerCircle);
    if (currentProgress >= 200) {
        currentProgress = currentProgress - 200;
        completeArc.color = Colors.greenAccent;
    } else if (currentProgress >= 100) {
        currentProgress = currentProgress - 100;
        completeArc.color = Colors.yellowAccent;
    }
    double angle = 2 * pi * (currentProgress / 100);
    canvas.drawArc(Rect.fromCircle(center: center, radius: radius), -pi/ 2, angle, false, completeArc);
  }

  @override
  bool shouldRepaint(CustomPainter oldDelegate) {
    return true;
  }
}

currentProgress には秒が入る想定です。

カウント画面 StartCountPage

3, 2, 1 を表示する画面の widget を作成します。

start_count_page.dart

import 'package:flutter/material.dart';
import 'package:twentyfive/pages/play_page.dart';
import 'package:twentyfive/utils/constants.dart';
import 'package:twentyfive/widgets/circle_progress.dart';

class StartCountPage extends StatefulWidget {
  @override
  _StartCountPageState createState() => _StartCountPageState();
}

class _StartCountPageState extends State<StartCountPage> with SingleTickerProviderStateMixin {
  AnimationController progressController;
  Animation animation;

  double millSeconds = 3;

  @override
  void initState() {
    super.initState();
    progressController = AnimationController(vsync: this, duration: Duration(milliseconds: 3000));
    animation = Tween<double>(begin: 0, end: 300).animate(progressController);
    animation.addListener(() {
      /// アニメーション中の処理を書く
      setState(() {
        double localSecond = 300 - animation.value;
        millSeconds = 1 + localSecond / 100;
      });
    });
    animation.addStatusListener((status) {
      /// status を引数にした処理が書ける
      if (status == AnimationStatus.completed)
        Navigator.push(
          context,
          new MaterialPageRoute<Null>(
            settings: const RouteSettings(name: Constants.playRoute),
            builder: (BuildContext context) => PlayPage(),
          ),
        );
    });
    progressController.forward();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: Center(
        child: CustomPaint(
          foregroundPainter: CircleProgress(animation.value),
          child: Container(
            width: 200,
            height: 200,
            child: Center(child: Text('${millSeconds.toInt()}')),
          ),
        ),
      ),
    );
  }
}

スタート画面

start_page.dart

import 'package:flutter/material.dart';
import 'package:twentyfive/pages/start_count_page.dart';
import 'package:twentyfive/utils/constants.dart';

class StartPage extends StatefulWidget {
  @override
  _StartPageState createState() => _StartPageState();
}

class _StartPageState extends State<StartPage> {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: Container(
        decoration: new BoxDecoration(color: Colors.black),
        child: Center(
          child: FlatButton(
            child: Text(
              'Start',
              style: TextStyle(color: Colors.white),
            ),
            onPressed: () {
              Navigator.push(
                context,
                new MaterialPageRoute<Null>(
                  settings: const RouteSettings(name: Constants.startCountRoute),
                  builder: (BuildContext context) => StartCountPage(),
                  fullscreenDialog: true, // ダイアログで表示するかどうか
                ),
              );
            },
          ),
        ),
      ),
    );
  }
}

main.dart

最後に main.dart です。

main.dart

import 'package:flutter/material.dart';
import 'package:twentyfive/pages/play_page.dart';
import 'package:twentyfive/pages/start_page.dart';
import 'package:twentyfive/utils/constants.dart';

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

class MyApp extends StatelessWidget {
  // This widget is the root of your application.
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      initialRoute: '/start',
      routes: <String, WidgetBuilder>{
        Constants.startRoute: (BuildContext context) => StartPage(),
      },
      title: 'Numbers',
      theme: new ThemeData(
        brightness: Brightness.dark,
        primaryColor: Colors.lightBlue[800],
        accentColor: Colors.cyan[600],
      ),
    );
  }
}

アニメーション処理の解説

便宜上、さきにソースコードを載せておきました。

本日はカウント画面がメインになりますので start_count_page.dart の実装を説明していきます。

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: Center(
        child: CustomPaint(
          foregroundPainter: CircleProgress(animation.value),
          child: Container(
            width: 200,
            height: 200,
            child: Center(child: Text('${millSeconds.toInt()}')),
          ),
        ),
      ),
    );
  }

なんと、 CustomPainter を継承したサブクラスを載せる widgetCustomPaint というものがあります。

CustomPaint(
          foregroundPainter: CircleProgress(animation.value),
          child: Container(
            width: 200,
            height: 200,
            child: Center(child: Text('${millSeconds.toInt()}')),
          )

CustomPaint には foregroundPainter なるプロパティがあり、 これに CustomPainter を継承したサブクラスを乗せると描写できるみたいです。

次に前回は AnimationController を紹介しましたが、これの他にアニメーション自体を細かく調整する Animation があります。

  Animation animation;

今回は Tween という、開始値と終了値を指定できるものを使ってアニメーションの細かい処理を書きました。

    animation = Tween<double>(begin: 0, end: 300).animate(progressController);
    animation.addListener(() {
      /// アニメーション中の処理を書く
      setState(() {
        double localSecond = 300 - animation.value;
        millSeconds = 1 + localSecond / 100;
      });
    });
    animation.addStatusListener((status) {
      /// status を引数にした処理が書ける
      if (status == AnimationStatus.completed)
        Navigator.push(
          context,
          new MaterialPageRoute<Null>(
            settings: const RouteSettings(name: Constants.playRoute),
            builder: (BuildContext context) => PlayPage(),
          ),
        );
    });

こんな感じに書いています。 _animation の animate に AnimationController をセットすることで AnimationController と Animation が連携できるようです。

  • addListener: アニメーション中に値が変更されたら呼ばれるリスナー
  • addStatusListener: アニメーションのステータスが変更されたら呼ばれるリスナー (引数はステータス)

などがあります。 addListener ではカウントの秒の値が変更される度にmillSecondsに通知を贈りたいので setState を呼んでいます。 addStatusListener ではアニメーションが終了したことを検知してプレイ画面に遷移させる処理を書いています。

あとは値が変更されるたびに CircleProgress と Text に変更を与えたいので、

CircleProgress(animation.value)
Text('${millSeconds.toInt()}')

という風に変数を入れてます。

これでメーターがクルクル回ったようなアニメーションが出来上がります。

今回はソースコードばかりになってしまいました。 アニメーションの基礎は前回やりましたので後は実践あるのみ、な感じですね!

これでスタート画面からゲーム画面までの大まかな実装が完了しました。

次回以降はゲームのロジックに入ります。

それではバイバイ。

56.【ナンバーズアプリ】Flutter のアニメーション AnimationController の基礎を振り返る

目次

今回は Flutter のアニメーションについて書いてますが学習途中なので所々間違いがあるかもしれませんのでご了承ください。

Flutter でアニメーションを実装できる AnimationController を説明してみる

前回は円を描写しましたがアニメーションがまだできていません。

blog.tamappe.com

Flutterでのアニメーションの入れ方を調査しなければならなかったので、本日は Flutter のアニメションについて学習します。

AnimationController とは

Animation については Flutter 公式ページの方が理解が進みます。

flutter.dev

今回はその一番の基礎である AnimationController について説明してみます。

一番分かりやすい動画はこちらになります。

youtu.be

AnimationController の基礎

AnimationController の基礎は次の2つで構成されます。

  • mixin の適用
  • AnimationController を宣言する

それぞれ順を追って説明します。

mixin の適用

mixin のイメージは iOS で言えば、delegate に近いです。

Animation は状態が変わるので基本は StatefulWidget を使って

class AnimationWidgetState extends StatefulWidget {
  @override
  _AnimationWidgetStateState createState() => _AnimationWidgetStateState();
}

class _AnimationWidgetStateState extends State<AnimationWidgetState> with SingleTickerProviderStateMixin {

  @override
  Widget build(BuildContext context) {
    return Container();
  }
}

SingleTickerProviderStateMixin を mixin させます。

これで AnimationController を使う準備ができました。

AnimationController を宣言する

それではさきほど作成した AnimationWidgetStateに AnimationController を宣言します。
AnimationController はライフサイクルを持っているので initStatedispose で生成や廃棄をしないといけないみたいな感じです。

class AnimationWidgetState extends StatefulWidget {
  @override
  _AnimationWidgetStateState createState() => _AnimationWidgetStateState();
}

class _AnimationWidgetStateState extends State<AnimationWidgetState>
    with SingleTickerProviderStateMixin {
  /// 宣言する
  AnimationController _controller;

  @override
  void initState() {
    super.initState();
    /// 初期化する
    _controller = AnimationController(
        vsync: this, 
        duration: Duration(seconds: 60)
    );
  }

  @override
  Widget build(BuildContext context) {
    return Container();
  }

  @override
  void dispose() {
    super.dispose();
    /// 廃棄する
    _controller.dispose();
  }
}

これが AnimationController を使う場合の基本形になります。
慣れない時はこれを3回書いて暗記してしまいましょう。

initState メソッドを見てみます。

    _controller = AnimationController(
        vsync: this, 
        duration: Duration(seconds: 60)
    );

ここですね。 duration はお馴染みのアニメーション時間ですが、 vsync (ビィーシンクと発音するみたいです)は初見ですね。
vsync は今はまだ分かりませんが慣例としてthisをセットすればいいみたいです。
duration が割とハマったりします。

/// マイクロ秒
Duration(microseconds: 60)

/// ミリ秒
Duration(milliseconds: 60)

/// 秒
Duration(seconds: 60)

/// 分
Duration(minutes: 60)

こんな感じです。

f:id:qed805:20200516180020p:plain:w300
Duration のプロパティ

これらを宣言し終わったら Animation の基礎は完成します。
XML とかクロージャーなどを使うと思っていましたので簡単そうに見えますね。

Listener を追加する

AnimationController で基礎が出来上がったら Listener を追加していきます。

initState の中で addListener します。

  @override
  void initState() {
    super.initState();
    /// 初期化する
    _controller = AnimationController(
        vsync: this,
        duration: Duration(seconds: 60)
    );
    _controller.addListener(_update);
  }

  void _update() {
    setState(() {
    });
  }

あとは _update() の中で行いたい処理を書いていきます。

forward() でアニメーションを開始させる

上記でアニメーション用の処理を書きました。
それでは実際にアニメーションを開始させたいです。

アニメーションを開始したい場合は簡単に Listener を追加した後に

    _controller.forward();

という風に forward() を実行するだけでOKです。
つまり、Widget 表示時にアニメーションを開始したい場合は initState のところで

  @override
  void initState() {
    super.initState();
    /// 初期化する
    _controller = AnimationController(
        vsync: this,
        duration: Duration(seconds: 60)
    );
    _controller.addListener(_update);
    _controller.forward();
  }

とすれば表示と同時にアニメーションが始まります。

紹介した動画の中では int 型の i を宣言して i の値を更新していっていました。

動画の中で紹介されていたコード

class AnimationWidgetState extends StatefulWidget {
  @override
  _AnimationWidgetStateState createState() => _AnimationWidgetStateState();
}

class _AnimationWidgetStateState extends State<AnimationWidgetState>
    with SingleTickerProviderStateMixin {
  /// 宣言する
  AnimationController _controller;

  int i = 0;

  @override
  void initState() {
    super.initState();
    /// 初期化する
    _controller = AnimationController(
        vsync: this,
        duration: Duration(seconds: 60)
    );
    _controller.addListener(_update);
    _controller.forward();
  }

  void _update() {
    setState(() {
      i = (_controller.value * 2993030303).round();
    });
  }

  @override
  Widget build(BuildContext context) {
    return Text('$i m/s');
  }

  @override
  void dispose() {
    super.dispose();
    /// 廃棄する
    _controller.dispose();
  }
}

これを実行すると i の値が高速で変更されていく画面が表示されます。

ちなみに AnimationController の duration は別に設定しなくても動作したりしました。

    _controller = AnimationController(
        vsync: this,
    );

これだけで行けるみたいです。
繰り返し処理したい場合には

    _controller = AnimationController(
        vsync: this,
    )..repeat();

このように ..repeat と書けばアニメーションがリピートされます。

これらが分かればとりあえずは Flutter での簡単なアニメーションが実現できるようになりますね。 使ってみれば以外に簡単な気がしますのでこれからはできるだけアニメーションを使っていきたいと思います。

それではバイバイ。

55. CustomPainter を使ってサークルプログレスを作成する

CustomPainter を使ってサークルプログレスを作成する

今回は前回に学習した CustomPainter を使って実際にサークル状のプログレスの雛形を作成していきたいと思います。

blog.tamappe.com

作ったファイルは circle_progress.dart になります。

circle_progress.dart

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

class CircleProgress extends CustomPainter {
  double currentProgress;

  CircleProgress(this.currentProgress);

  @override
  void paint(Canvas canvas, Size size) {
    Paint outerCircle = Paint()
      ..strokeWidth = 5
      ..color = Colors.black
      ..style = PaintingStyle.stroke;
    Paint completeArc = Paint()
      ..strokeWidth = 5
      ..color = Colors.yellowAccent
      ..style = PaintingStyle.stroke
      ..strokeCap = StrokeCap.round;

    Offset center = Offset(size.width / 2, size.height / 2);
    double radius = min(size.width / 2, size.height / 2) - 7;

    canvas.drawCircle(center, radius, outerCircle);

    double angle = 2 * pi * (currentProgress / 100);
    canvas.drawArc(
        Rect.fromCircle(center: center, radius: radius), -pi / 2, angle, false, completeArc);
  }

  @override
  bool shouldRepaint(CustomPainter oldDelegate) {
    return true;
  }
}

プロパティ currentProgress には進捗の値を渡す想定です。
例えば、currentProgress に 50 を渡すと 50% が黄色の線で塗りつぶされます。

CircleProgress(50)

f:id:qed805:20200512003937p:plain:w300
50%の進捗を表すCircleProgress

あとはこれに currentProgress に動的な値を渡すアニメーションを実装すれば、グルグル回るプログレスバーが完成します。
次回は実際にこれにアニメーション処理を付与するところをお話しようと思います。
ということでアニメーションの勉強は次回になります。

それでは、バイバイ。