実践Flutter

Flutterでスマホを中心にアプリ制作していきます。

画像の出力、ネット越しの読み込み、敷き詰め

はじめに

今回はFlutterの画像インポート~表示をやっていきます。

表示自体はとても簡単な仕組みになっていまして、画像ファイルを予め用意して、そのパスを指定して読み込むか、ネットワーク上から読み込むかを指定するだけです。

表示も簡単ですが、リサイズなども簡単にできてしまいます。自由なサイズで位置を指定することも自在にできますので、サンプルの最後は画像の敷き詰めの例を作ってみました。ゲームを作らないとここまで敷き詰めることはないと思いますが、4枚敷き詰め、などは色々と用途があると思います。





画像表示サンプル

まずは画像表示するサンプルコードを実行したときのスクリーンショットをしてしていきます。

画像表示とは直接関係ありませんが、3通りの表示を示すため、以前の記事で示したPageViewによって横スワイプで画面を切り替えるものをベースに使っています。

スクリーンショット

起動すると下記の画面になります。画像が一枚表示されます。画像はPixabayから取得させていただいています:

f:id:linkedsort:20211201223719p:plain:w350


その隣のページに遷移すると、リストビューのなかにカードが並び、画像とラベルがそれぞれ表示される画面になります。ここではネットワーク越しに画像を取得していています。最初に画面が描画される一瞬は、まだロードが完了していなかったりします:

f:id:linkedsort:20211201223752p:plain:w350


ここは何枚も画像を載せていますので、リストビューを下にスクロールするといろいろな絵を表示します。生春巻きが見えてきていますね:

f:id:linkedsort:20211201223819p:plain:w350


そして3ページ目は画像タイルの敷き詰めデモ、ということでここではレトロゲーム風のマップタイル画像を作って敷き詰めてみました:

f:id:linkedsort:20211201223849p:plain:w350

画面サイズぴったりに16チップ並べるということをしています。こういうことが簡単にできてしまうのは素晴らしいですね。任意のサイズ、場所、縮尺で画像を表示することができます。しかもなかなかの高速描画です。


サンプルコード

サンプルコードを見ていきます。本記事では画像の部分に集中します。ページ切替の部分は以前の記事をご参照下さい。


flutter.gakumon.jp

まず最初に読み込む画像を用意します。プロジェクトに任意の名前のフォルダを作って、そこに画像ファイルを用意します:


f:id:linkedsort:20211202204517p:plain

ここでは「images」という名前のフォルダを作って、その中に画像ファイルを入れています。「chip01~04.png」は16x16ドットのマップチップ、「image01.jpg」はpixabayからダウンロードした画像です。試してみる場合はお好きな画像を用意してみて下さい。

次に、pubspec.yamlの「flutter:」の節のところに「assets:」という項目を作り、その中に「- イメージをおいたフォルダ」を記載します。


f:id:linkedsort:20211202204741p:plain

ここでYAMLの文法には注意してください。assetsの前にはスペース2つ、- images/の前にはスペース4つという形になっています。
デフォルトのpubspec.yamlでは「uses-material-design: true」の下にずらずらとコメント行が並んでいますが、上記スクリーンショットではこれらを削除しています。

準備ができたところで「lib/main.dart」を見ていきましょう:

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

void main() {
  runApp(const MyApp());
}

class Controller extends GetxController {
  var selected = 0.obs;
}

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

  @override
  Widget build(BuildContext context) {
    final PageController pager = PageController();
    var state = Get.put(Controller());

    return MaterialApp(
        debugShowCheckedModeBanner: false,
        home: Scaffold(
          appBar: AppBar(
            title: const Text("Images"),
          ),
          body: PageView(
            controller: pager,
            children: const <Widget>[
              Home(),
              Network(),
              Chip(),
            ],
            onPageChanged: (int i) {
              state.selected.value = i;
            },
          ),
          bottomNavigationBar: Obx(() => BottomNavigationBar(
            items: const [
              BottomNavigationBarItem(
                  icon: Icon(Icons.home), label: 'Home'),
              BottomNavigationBarItem(
                  icon: Icon(Icons.network_wifi), label: 'Net'),
              BottomNavigationBarItem(
                  icon: Icon(Icons.map_outlined), label: 'Map'),
            ],
            currentIndex: state.selected.value,
            onTap: (int i) {
              state.selected.value = i;
              pager.jumpToPage(i);
            },
          )),
        ));
  }
}

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

  @override
  Widget build(BuildContext context) {
    return Container(
      //(1) アセットからの画像出力
      child: Image.asset("images/image01.jpg"),
      alignment: Alignment.center,
    );
  }
}

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

  @override
  Widget build(BuildContext context) {
    var items = [
      makeItem("鳥", "https://cdn.pixabay.com/photo/2021/10/18/07/25/bird-6720306_1280.jpg"),
      makeItem("犬", "https://cdn.pixabay.com/photo/2021/11/23/09/47/animal-6818310_1280.jpg"),
      makeItem("車", "https://cdn.pixabay.com/photo/2021/11/20/05/15/car-6810885_1280.jpg"),
      makeItem("狐", "https://cdn.pixabay.com/photo/2021/11/15/05/52/red-fox-6796430_1280.jpg"),
      makeItem("生春巻き", "https://cdn.pixabay.com/photo/2021/11/01/15/52/spring-roll-6760871_1280.jpg"),
      makeItem("橋", "https://cdn.pixabay.com/photo/2021/10/05/14/32/ocean-6682870_1280.jpg"),
    ];

    return Container(
        margin: const EdgeInsets.all(10),
        child: ListView.builder(
          itemCount: items.length,
          itemBuilder: (context, i) => items[i],
        )
    );
  }

  Widget makeItem(String title, String url) {
    return Card(
      color: Colors.white,
      elevation: 4.0,
      margin: const EdgeInsets.all(4),
      shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
      child: Column(
        children: [
          Padding(
            padding: const EdgeInsets.all(16),
            //(2) ネットからの画像読み込み・出力
            child: Image.network(url),
          ),
          Text(title),
          const Padding(padding:EdgeInsets.all(10)),
        ],
      ),
    );
  }
}

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

  @override
  Widget build(BuildContext context) {
    var map = [
      [1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1],
      [1,2,2,2,2,2,2,2,2,2,2,2,2,2,2,1],
      [1,2,2,2,1,1,1,1,1,1,2,2,2,2,2,1],
      [1,2,2,2,1,4,4,3,3,1,2,2,2,2,2,1],
      [1,1,1,1,1,4,4,3,3,1,2,2,2,2,2,1],
      [1,2,2,2,4,4,4,4,4,1,2,2,2,2,2,1],
      [1,2,2,2,1,4,4,4,2,1,2,2,2,2,2,1],
      [1,2,2,2,1,4,4,2,2,1,2,2,2,2,2,1],
      [1,2,2,2,1,1,1,1,1,1,2,2,2,2,2,1],
      [1,2,2,2,2,2,2,2,2,2,2,2,2,2,2,1],
      [1,2,2,2,2,2,2,2,2,2,2,2,2,2,2,1],
      [1,2,2,2,2,2,2,2,2,2,2,2,2,2,2,1],
      [1,4,4,2,2,2,2,2,2,2,2,2,2,2,2,1],
      [1,3,4,4,4,2,2,2,2,2,2,2,2,2,2,1],
      [1,3,3,4,4,2,2,2,2,2,2,2,2,2,2,1],
      [1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1],
    ];
    //(3) サイズをフィットさせた画像にする
    var img = [
      Image.asset("images/chip01.png", fit: BoxFit.fill),
      Image.asset("images/chip02.png", fit: BoxFit.fill),
      Image.asset("images/chip03.png", fit: BoxFit.fill),
      Image.asset("images/chip04.png", fit: BoxFit.fill),
    ];
    var tileNum = 16;
    var tileWidth = MediaQuery.of(context).size.width / tileNum;

    //(4) 任意の場所・サイズで画像を表示
    return Stack(
      children: <Widget>[
        for(var j=0; j< 16; j++)
          for(var i = 0; i < 16; i++)Positioned(
            left: tileWidth * i,
            top: j*tileWidth,
            width: tileWidth,
            height: tileWidth,
            child: img[map[j][i]-1],
          ),
      ],
    );
  }
}

コードのポイント

コード中の番号を付したコメントについて、解説していきます。

(1) ローカルに用意した画像ファイルを出力
  return Container(
    //(1) アセットからの画像出力
    child: Image.asset("images/image01.jpg"),
    alignment: Alignment.center,
  );

プロジェクトで予め用意した画像ファイルを表示する方法です。Image.assetという名前付きコンストラクタに、画像ファイルのパスを指定すればOKです。

pubspec.yamlにassetsを宣言しておく必要があります。これがないとエラーメッセージが出力されます。

ここではContainerのchildに単純にImageを渡しています。Containerの大きさに合わせて画像のサイズは調整されます。

(2) ネット越しに画像を読み込んで出力
  return Card(
    color: Colors.white,
    elevation: 4.0,
    margin: const EdgeInsets.all(4),
    shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
    child: Column(
      children: [
        Padding(
          padding: const EdgeInsets.all(16),
          //(2) ネットからの画像読み込み・出力
          child: Image.network(url),
        ),
        Text(title),
        const Padding(padding:EdgeInsets.all(10)),
      ],
    ),
  );

ネット越しに画像をダウンロードして表示するシチュエーションは、いまやローカルに用意する以上に多いかもしれませんね。

方法は非常に簡単で、Image.networkというコンストラクタにurlを指定するだけでOKです。これでサクッと表示してくれるのはありがたいですね。

(3) 画像サイズをフィットさせる
  //(3) サイズをフィットさせた画像にする
  var img = [
    Image.asset("images/chip01.png", fit: BoxFit.fill),
    Image.asset("images/chip02.png", fit: BoxFit.fill),
    Image.asset("images/chip03.png", fit: BoxFit.fill),
    Image.asset("images/chip04.png", fit: BoxFit.fill),
  ];

画像のサイズを親ウィジェットの枠にフィットさせるかどうかを「fit」パラメータで選択できます。

「fit」パラメタはBoxFit型の値で指定するのですが、様々な値があります。この値によってどういう画像サイズになっていくかは、次のページがわかりやすいです(英語ですけれども絵で解説されているところを見るとわかると思います):


api.flutter.dev

例えば上記のコードを下記のように変えてみますと:

  //(3) サイズをフィットさせた画像にする
  var img = [
      Image.asset("images/chip01.png", fit: BoxFit.contain),
      Image.asset("images/chip02.png", fit: BoxFit.cover),
      Image.asset("images/chip03.png", fit: BoxFit.scaleDown),
      Image.asset("images/chip04.png", fit: BoxFit.none),
  ];

結果はこのようになります:

f:id:linkedsort:20211202212709p:plain:w350

ちょっとおかしなことになってしまっていますよね。

(4) 任意の場所にウィジェットを表示
    //(4) 任意の場所・サイズで画像を表示
    return Stack(
      children: <Widget>[
        for(var j=0; j< 16; j++)
          for(var i = 0; i < 16; i++)Positioned(
            left: tileWidth * i,
            top: j*tileWidth,
            width: tileWidth,
            height: tileWidth,
            child: img[map[j][i]-1],
          ),
      ],
    );

Stackウィジェットは、その子要素にアドレスやサイズを指定することで任意の場所にウィジェットを表示することができるものです。これは画像に限らず任意のウィジェットを自由にレイアウトできます。

これを利用してここでは画像を敷き詰めています。leftとtopが画像の左上の座標、widthとheightが幅と高さです。







おわりに

今回はFlutterにおける画像の出力の様々なパターンを見てみました。

単体ではほとんど解説が必要ないほどに簡潔ですよね。このあたりの当たり前のAPIが当たり前に簡単に使えるのがFlutterのとても良いところです。当たり前のようでここが面倒な処理系がやたらと多かったりしますからね。

最後の方は趣味に走ってちょっとレトロゲームっぽいデザインとコードにしてみました。昔からゲームプログラミングしている方はちょっと懐かしい感じのコードではないですか?

基本ウィジェット関係のまとめが一段落したら、ちょっとこっち方面を掘り下げていきたいと思っています。

f:id:linkedsort:20211202214025j:plain