[Flutter]flutter_staggered_grid_viewを使って「Pinterest」風のレイアウトを実現する

flutterを使って具体的なレイアウトを組む1つ目の記事です。flutter_staggered_grid_viewを使用しています。
2020.08.03

この記事は公開されてから1年以上経過しています。情報が古い可能性がありますので、ご注意ください。

こんにちは。CX事業本部の田辺です。この記事ではFlutterで写真共有サービス「Pinterest」風のグリッドレイアウトをFlutterで実装する方法を解説します。iOSで実現する方法については当ブログの過去の記事を参照してください。

環境について

pubspec.yamlの設定です。

environment:
  sdk: ">=2.7.0 <3.0.0"

dependencies:
  flutter:
    sdk: flutter

  cupertino_icons: ^0.1.3
  flutter_staggered_grid_view: ^0.3.1

flutter_staggered_grid_view

cross axisを等倍の大きさの複数に分割して空いているmain axisのスペースを基準に最適な位置に要素を配置していくグリッドレイアウトを実装することができるWidgetを提供しているパッケージです。

インストール方法

他のパッケージと同じくpubspec.yamlファイルに追記してflutter pub getコマンドを実行することで導入できます。アプリケーションコードでimportすることでパッケージ内でexportされているウィジェットを使用できます。

import 'package:flutter_staggered_grid_view/flutter_staggered_grid_view.dart';

アップデートによりインストール方法などが変わるかもしれないので実際に使用される際にはInstallingを確認することをすすめます。

主要なクラス

flutter_staggered_grid_viewパッケージで登場する主なクラスにを事前に紹介しておきます。

StaggeredGridView

スクロール可能な、可変サイズのウィジェット群を表示するクラスです。main axisは、スクロールする方向(scrollDirection)です。主要なコンストラクタは2つあります。決まった数のtileをcross axisに配置するためにStaggeredGridView.countがよく使われます。今回使用するのはこちらのコンストラクタです。また、 最大のcross axisへのextentを持つタイルで配置するためにStaggeredGridView.extentが使われます。metro grid styleな画面を実装するならこちらを使うのだと思います。

StaggeredTile

StaggeredGridViewのタイルの寸法を保持するクラスです。

StaggeredTileもユースケースに合わせたコンストラクタを主に3つ提供しています。StaggeredTile.countコンストラクタはcrossAxisCellCount列、mainAxisCellCount行のタイルを作成することを表します。静的にタイルのレイアウトを決めたい時はこちらを使うのだと思います。

2つ目のコンストラクタがStaggeredTile.extentコンストラクタです。こちらはドキュメントにある通りmain axisへの固定された範囲を保持したレイアウトを保持します。

3つ目のコンストラクタがStaggeredTIle.fitコンストラクタです。 crossAxisCellCountを指定する必要があります。mainAxisへの範囲を内容にあわせて表示します。今回のPinterest風のレイアウトを実現するならこのコンストラクタを使うのが良さそうです。

SliverStaggeredGrid

StaggeredGridViewをSliver系のウィジェットと組み合わせて使えるようにするために提供されているクラスです。Sliver系ウィジェットは併用する時にCustomScrollView.sliversにListで渡すのでその時に使用できます。

StaggeredGridView.countの基本的な使い方 

登場する基本的なクラスについて説明したので簡易的な実装をしてみます。

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

void main() => runApp(MaterialApp(
      home: StaggeredExample(),
      theme: ThemeData(
        primaryColor: Colors.black,
      ),
    ));

class StaggeredExample extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text('StaggeredExample'),
      ),
      body: Padding(
        padding: EdgeInsets.only(top: 12.0),
        child: StaggeredGridView.count(
          crossAxisCount: 4,
          staggeredTiles: const <StaggeredTile>[
            const StaggeredTile.count(3, 2),
            const StaggeredTile.count(1, 1),
            const StaggeredTile.count(1, 1),
            const StaggeredTile.count(2, 2),
            const StaggeredTile.count(2, 1),
            const StaggeredTile.count(1, 2),
            const StaggeredTile.count(1, 1),
            const StaggeredTile.count(2, 2),
            const StaggeredTile.count(1, 2),
            const StaggeredTile.count(1, 1),
            const StaggeredTile.count(3, 1),
            const StaggeredTile.count(1, 1),
            const StaggeredTile.count(4, 1),
          ],
          children: [
            const _SampleTile(Colors.green),
            const _SampleTile(Colors.lightBlue),
            const _SampleTile(Colors.amber),
            const _SampleTile(Colors.brown),
            const _SampleTile(Colors.deepOrange),
            const _SampleTile(Colors.indigo),
            const _SampleTile(Colors.red),
            const _SampleTile(Colors.pink),
            const _SampleTile(Colors.purple),
            const _SampleTile(Colors.blue),
            const _SampleTile(Colors.black),
            const _SampleTile(Colors.red),
            const _SampleTile(Colors.brown),
          ],
        ),
      ),
    );
  }
}

class _SampleTile extends StatelessWidget {
  const _SampleTile(this.backgroundColor);

  final Color backgroundColor;

  @override
  Widget build(BuildContext context) {
    return new Card(
      color: backgroundColor,
      child: new InkWell(
        onTap: () {},
        child: new Center(
          child: new Padding(
            padding: const EdgeInsets.all(4.0),
          ),
        ),
      ),
    );
  }
}

静的に寸法を指定してContainerを使ってレイアウトを組みました。

crossAxisCountが4にしているので最大4のうちどれだけcross axisに対して専有するのか、main axisに対してどれだけ専有するのかをstaggeredTilesパラメータで指定しています。指定したList<StaggeredTile>と同じ数のWidgetのListをchildrenパラメータに渡します。

これで静的にではありますがStaggeredGridViewの実装は済みました。このコードのstaggeredTileseパラメータの値やcrossAxisCountの値を変えることで自由にレイアウトを組み替えることができます。 

Pinterest風のUIを作る

実現したいのはcross axisには決まった数(cross axisに対してちょうど半分)でmain axisにはコンテンツのサイズ分の範囲を持つTile上のレイアウトです。

そこで今回使うのは

  • StaggeredGridView.countコンストラクタ
  • StaggeredTile.fitコンストラクタ

の2つです。

画像について

UIのみの実装としたいので画像はassetとしてアプリケーションで保持します。Flutterで画像を追加する場合はpubspec.yamlで設定を行います。

今回はassetフォルダを画像を保持するフォルダとして設定したいのでassetsフォルダに画像を移動させた後、pubspec.yamlファイルに以下のように記述します。

# To add assets to your application, add an assets section, like this:
  assets:
    - assets/

記述後、flutter pub getコマンドを実行します。

※アプリ内で使用させていただいた画像それぞれへのリンク先です。

実装

class Home extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      backgroundColor: Colors.black,
      appBar: AppBar(
        centerTitle: true,
        iconTheme: IconThemeData(
          color: Colors.white,
        ),
        title: Text('Pinterst風UI Sample'),
        actions: <Widget>[
          Container(
            margin: EdgeInsets.only(right: 10),
            child: Icon(Icons.message),
          )
        ],
      ),
      body: StaggeredGridView.count(
        crossAxisCount: 4,
        children: List.generate(15, (index) {
          return _Tile(index);
        }),
        staggeredTiles: List.generate(15, (index) {
          return StaggeredTile.fit(2);
        }),
      ),
    );
  }
}

class _Tile extends StatelessWidget {
  _Tile(this.index);

  final int index;

  @override
  Widget build(BuildContext context) {
    return Container(
      margin: EdgeInsets.all(10),
      child: ClipRRect(
        borderRadius: BorderRadius.all(Radius.circular(5)),
        child: Image.asset('assets/${1 + index}.jpg'),
      ),
    );
  }

実行結果は以下になります。

StaggeredGridView.countコンストラクタのcrossAxisパラメータに4を渡してcross axis方向への全体的な寸法を指定しています。List.generateコンストラクタを使って_Tileウィジェットを指定の数もつListをchildrenパラメータに渡しています。_Tileはパラメータにint型の値を受け取っていますがこれをString Interpolationで画像ファイルを指定するのに使っています。

staggeredTilesパラメータにもList.generateコンストラクタを使用しています。ここでは先述の通り要件にあわせてStaggeredTile.fitコンストラクタを、引数にはcross axisに指定した4に対してちょうど半分になるよう2を渡します。fitなのでmain axisへの範囲はコンテンツに依存します。実装の解説は以上になります。

その他クラスについて

今までに記事で扱っていないクラスがいくつか登場しているので簡単な解説とドキュメントへのリンクを併記します。Widgetの基本的な使い方を解説しているシリーズで扱った時にはリンクを書き換えたいと思っています。

ClipRRect

childに指定したウィジェットを矩形に切り取るWidgetです。borderRadiusプロパティで丸みを指定できます。ドキュメントはこちらです。

Padding

childに指定したWidgetにパディングをもたせることができるWidgetです。ドキュメントはこちらです。

InkWell

マテリアルデザインでアクション可能な部品をタップすると波紋のように広がるアニメーションがありますが、リップルエフェクトと呼びます。そのようなエフェクトを持つコントロールを実装する時に使用するWidgetです。ドキュメントはこちらです。

まとめ

初めて使用するクラスを使ってレイアウトを組む時には、試行錯誤を高速に繰り返せるHot Reloadが本当にありがたいです。Flutterは言語仕様がすごいシンプルで扱いやすいので日々の開発で得た経験を活かしてロジックを記述できていて不満を感じていません。一方思い通りのレイアウトを作るにはもう少しインプットが必要だなと感じているので良さそうなレイアウトをFlutterで作ってみることを繰り返しながら練習したいと考えています。