Flutter でアプリ開発(4. 音楽プレーヤーもどき:画面レイアウト)

前回 svpl.hatenablog.jp

基本はわかってきたので、もう少し実践的なことをやっていきます。試しに音楽プレーヤーもどきを作ってみながら学んでいこうと思います。「もどき」とつくのは、実際には音楽を再生しないからです。UI の作り方や画面遷移、データの扱いなどがメインです。

完成した画面

プログラミングの話の前に、完成した画面を先にやります。

まずはデザインを考えます。デザインツールに InVision Studio を使いました。このようなソフトを使うと、UI のプロトタイプが簡単に作れます。

以下はデザイン案です。

プロトタイプを元にアプリを作成しました。以下は完成した実際の画面です。

画面こそ完成しましたが、プレイリストをスクロース以外は何も操作ができません。今回は UI のレイアウトのみです。

以上は完成した画面でしたが、この先は作り方に入っていきます。

データを用意

まずは画面に表示するデータを用意します。今回は Billboard Japan Hot 100 Year End のランキングを使用しました。

const musicChart = [
  {'title': 'ドライフラワー', 'artist': '優里'},
  {'title': 'Dynamite', 'artist': 'BTS'},
  {'title': '夜に駆ける', 'artist': 'YOASOBI'},
  {'title': '炎', 'artist': 'LiSA'},
  {'title': '怪物', 'artist': 'YOASOBI'},
  {'title': 'Butter', 'artist': 'BTS'},
  {'title': 'うっせぇわ', 'artist': 'Ado'},
  // 以下略
];

// 再生中の曲
const musicPlayingIndex = 10;

普通の音楽アプリのプレイリストならデータが変更されるのが当たり前ですが、今回はとりあえずハードコーディングで用意しました。また、再生中の曲も固定しています。

UI 実装

Flutter では、ウィジェット入れ子構造にして定義します。今回は以下のような構造になっています。*で囲んだものは自作ウィジェットです。

MaterialApp
└── *PlaylistBody*
    └── Scaffold
        ├── AppBar
        │   ├── Text(上部バーに表示されるタイトル)
        │   ├── IconButton(3本ラインボタン)
        │   └── IconButton(3点ドットボタン)
        └── Column
            ├── Expanded
            │   └── ListView
            │       ├── *Song*
            │       │   └── Container
            │       │       └── Row
            │       │           ├── Expanded
            │       │           │   └── Text(曲名)
            │       │           └── Expanded
            │       │               └── Text(アーティスト名)
            │       └── Divider
            └── *NowPlaying*
                └── Container
                    └── Row
                        ├── Expanded
                        │   └── Column
                        │       ├── Text(再生中の曲名)
                        │       └── Text(再生中のアーティス名)
                        └── Icon(一時停止ボタン)

なかなかわかりにくいですが、ビジュアルデバッグを入れると構造が視覚的にわかります。DevTool を起動して Show Guidelines をクリックすると表示されます。

main 関数

自作した PlaylistBody ウィジェットを作成するだけです。

void main() {
  runApp(
      const MaterialApp(
          home: PlaylistBody()
      )
  );
}

PlaylistBody ウィジェット

class PlaylistBody extends StatelessWidget {
  const PlaylistBody({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return Scaffold(
        appBar: AppBar(
            title: const Text('Billboard Japan Hot 100 Year End'),
            leading: const IconButton(
              icon: Icon(Icons.menu),
              tooltip: 'メインメニュー',
              onPressed: null,
            ),
            actions: const [
              IconButton(
                icon: Icon(Icons.more_vert),
                tooltip: 'サブメニュー',
                onPressed: null,
              )
            ]),
        body:
        Column(
          children: [
            Expanded(
                child: ListView.separated(
                  itemCount: musicChart.length,
                  itemBuilder: (BuildContext context, int index) => Song(
                      musicChart[index]['title'],
                      musicChart[index]['artist'], index == musicPlayingIndex),
                  separatorBuilder:
                      (BuildContext context, int index) => const Divider(),
              )
            ),
            const NowPlaying(),
        ],
        )
    );
  }
}

新しく登場したのは ListView です。itemCount に表示させる件数を指定し、itembuilder にウィジェットを作成する関数を指定します。itemBuilder にはリストのインデックスが渡されるので、それを使ってウィジェットを作ります。
separatorBuilder には、項目の間に表示するウィジェットを指定します。Divider を指定して、区切り線と適度なスペースを表示しました。

ListView には、

  • ListView
  • ListView.builder
  • ListView.separated

の 3 種類がありますが、項目を更新できるのは builder だけです。今回の例はプレイリストなので、本当は builder を使うべきでした。

NowPlaying ウィジェット

再生中の音楽の情報と、一時停止の操作ができます。ですが、今回は見た目だけで何もできません。

class NowPlaying extends StatelessWidget {
  const NowPlaying({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    final TextStyle defaultTextStyle = DefaultTextStyle.of(context).style;
    return Container(
        height: 90,
        color: Colors.grey[300],
        padding: const EdgeInsets.symmetric(horizontal: 20),
        child: Row(
          children: [
            Expanded(
              child: Column(
                mainAxisAlignment: MainAxisAlignment.center,
                crossAxisAlignment: CrossAxisAlignment.start,
                children: [
                  Text('勿忘',
                      style: defaultTextStyle.apply(
                        fontSizeFactor: 1.5,
                        fontWeightDelta: 1,
                      )),
                  Text(
                    'Awesome City Club',
                    style: defaultTextStyle.apply(
                      fontSizeFactor: 1.1,
                      color: Colors.black54,
                    ),
                  ),
                ],
              ),
            ),
            const Icon(Icons.pause_circle_outline, size: 60),
          ],
        )
    );
  }
}

文字サイズの指定では、fontSizeFactor に数値を直接指定するのではなく、DefaultTextStyle.of(context).style.apply を使っています。こうすると、現在のデフォルト設定からの相対的な文字サイズを指定でき、Android の文字サイズ設定なども反映されます。

Song ウィジェット

ListView で使う、曲の情報を表示するウィジェットです。

class Song extends StatelessWidget {
  const Song(this.title, this.artist, this.playing, {Key? key})
      : super(key: key);
  final String? title;
  final String? artist;
  final bool playing;

  @override
  Widget build(BuildContext context) {
    final TextStyle defaultTextStyle = DefaultTextStyle.of(context).style;
    final Widget playIcon;

    if (playing) {
      playIcon = const Icon(Icons.play_arrow_outlined, size: 30);
    } else {
      playIcon = const SizedBox(width: 30);
    }

    return Container(
      height: 50,
      padding: const EdgeInsets.only(right: 20, left: 3),
      child: Row(children: [
        playIcon,
        Expanded(
          flex: 3,
          child: Text(
            title != null ? '$title' : '',
            style: defaultTextStyle.apply(fontSizeFactor: 1.3),
          ),
        ),
        Expanded(
          flex: 2,
          child: Text(
            artist != null ? '$artist' : '',
            style: defaultTextStyle.apply(color: Colors.black54),
            textAlign: TextAlign.right,
          ),
        )
      ]),
    );
  }
}

再生中は左端に再生マークを、そうでないときは SizedBox で空白を表示します。
Expanded に Flex を指定すると、その割合でウィジェットの大きさが割り当てられます。ここでは、タイトルとアーティスト名の表示領域が 3:2 の割合になります。

まとめ

思った以上に簡潔な記述で UI が構築できました。かっこが多いので長く感じますけれども。レイアウトに使える様々なウィジェットやアイコンが最初から入っているのは良いですね。

次回は、再生する曲を選んだり、メニューを表示させたいと思います。