【Flutter/Dart】画像をボタンに設定・タップした時に表示を変える

目次

概要

トランプゲームなどではボタンではなく、画像をタップすると何かしらの処理が行われると言うことが多い。
しかし、Flutterでボタンに画像を設定することはできない。設定できるのはアイコンだけ。
そのため、Containerを使用して画像を表示し、その

ソースコード

ディレクトリ構成

ここで大事なのは、imagesディレクトリの位置。
プロジェクトファイルの直下、libディレクトリなどと同じ階層に作る。
このimagesディレクトリに画像のファイルを追加していく。

pubspec.yaml

画像を読み込むために以下の記載を追記する必要がある。
ここにが上記の画像を格納しているディレクトリを追記する。

flutter:
  uses-material-design: true
  assets:
    - images/

追記したら、ターミナルでpubspec.yamlと同じ階層のディレクトリで以下のコマンドを実行しよう。

$ flutter pub upgrade

アプリ実行部分

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

import 'TrumpCard/Simple/TrumpCardViewSimple.dart';


void main() {
  runApp(ProviderScope(child: MyApp()));
}

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter Demo',
      theme: ThemeData(
        colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepOrange),
        useMaterial3: true,
      ),
      home: const TrumpCardViewSimple(title: 'Flutter Demo Home Page'),
    );
  }
}

トランプのマークの列挙体

enum TrumpMark {
  spade,
  heart,
  dia,
  club
}

extension TrumpMarkAdditional on TrumpMark {
  String get trumpMarkString {
    switch (this) {
      case TrumpMark.spade:
        return 'spade';
      case TrumpMark.heart:
        return 'heart';
      case TrumpMark.dia:
        return 'dia';
      case TrumpMark.club:
        return 'club';
    }
  }
}

トランプのクラス

import 'TrumpMark.dart';

class TrumpCard {
  bool isSelected;
  int number;
  TrumpMark mark;

  TrumpCard(this.isSelected, this.number, this.mark);

  void tap() {
    this.isSelected = !this.isSelected;
  }
}

トランプカードのオブジェクト

import 'package:flutter/material.dart';

import '../../Common/TrumpCard.dart';
import '../../Common/TrumpMark.dart';

class StateTrumpCard extends StatefulWidget {
  final Function() notifyParent;
  TrumpCard trumpCard;
  StateTrumpCard({super.key, required this.notifyParent, required this.trumpCard});

  @override
  State<StatefulWidget> createState() => _StateTrumpCard(notifyParent: notifyParent, trumpCard: trumpCard);
}

class _StateTrumpCard extends State<StateTrumpCard> {
  final Function() notifyParent;
  TrumpCard trumpCard;

  _StateTrumpCard({required this.notifyParent, required this.trumpCard});

  @override
  Widget build(BuildContext context) {
    return Center(
      child: Container(
        width: 120,
        height: 180,
        decoration: BoxDecoration(
          color: Colors.white,
          borderRadius: BorderRadius.circular(0),
          image:trumpDisplay(trumpCard),
        ),
        margin: const EdgeInsets.all(5),
        child: Material(
          color: Colors.transparent,
          child: InkWell(
            borderRadius: BorderRadius.circular(0),
            onTap: () {
              notifyParent();
            },
          ),
        ),
      ),
    );
  }

  DecorationImage trumpDisplay(TrumpCard trumpCard) {
    if (trumpCard.isSelected) {
      return DecorationImage(
        image: AssetImage('images/${trumpCard.mark.trumpMarkString}_${trumpCard.number+1}.png'),
        fit: BoxFit.cover,
      );
    } else {
      return DecorationImage(
        image: AssetImage('images/card_back.png'),
        fit: BoxFit.cover,
      );
    }
  }
}

View表示部分

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

import 'StateTrumpCard.dart';
import '../../Common/TrumpCard.dart';
import '../../Common/TrumpMark.dart';

class TrumpCardViewSimple extends StatefulWidget {
  const TrumpCardViewSimple({super.key, required this.title});
  final String title;

  @override
  State<TrumpCardViewSimple> createState() => _TrumpCardViewSimpleState();
}

class _TrumpCardViewSimpleState extends State<TrumpCardViewSimple> {
  List<TrumpCard> trumpCards = [];

  @override
  Widget build(BuildContext context) {
    final itemsPerRow = 13;

    // 52枚分のトランプのオブジェクトを生成
    initTrumpCards();
    // トランプボタンの配列を作成
    final List<StateTrumpCard> trumpButtons = createTrumpButtons();
    // トランプをマークに応じて13枚ずつ分ける
    final slicedTrumpButtons = trumpButtons.slices(itemsPerRow).toList();

    return Scaffold(
        appBar: AppBar(
          backgroundColor: Theme.of(context).colorScheme.inversePrimary,
          title: Text(widget.title),
        ),
        body: Stack(
            children: [
              Container(
                width: double.infinity,
                height: double.infinity,
                child: SingleChildScrollView(
                  scrollDirection: Axis.horizontal,
                    child: SingleChildScrollView(
                    scrollDirection: Axis.vertical,
                      child: Row(
                          mainAxisAlignment: MainAxisAlignment.center,
                          children: [
                            for (var slicedTrumpButton in slicedTrumpButtons)
                              columnTrumpButtons(slicedTrumpButton),
                          ]
                        ),
                      ),
                )
              ),
            ]
        )
    );
  }

  void initTrumpCards() {
    List<TrumpCard> trumpCards = [];
    for(TrumpMark mark in TrumpMark.values) {
      for (int num = 0; num < 13; num ++) {
        trumpCards.add(TrumpCard(false, num, mark));
      }
    }
    this.trumpCards = trumpCards;
  }

  List<StateTrumpCard> createTrumpButtons() {
    List<StateTrumpCard> trumpButtons = [];

    for (TrumpCard trumpCard in trumpCards) {
      var trumpButton = StateTrumpCard(notifyParent: () { tapped(trumpCard); },
        trumpCard: trumpCard,);

      trumpButtons.add(trumpButton);
    }
    return trumpButtons;
  }

  void tapped(TrumpCard trumpCard) {
    setState(() {
      trumpCard.tap();
    });
  }

  Widget columnTrumpButtons(List<StateTrumpCard> trumpButtons) {
    return Column(
        mainAxisAlignment: MainAxisAlignment.center,
        children: [
          for (var trumpButton in trumpButtons)
            trumpButton,
        ]
    );
  }
}

デモ動画

詳細

imagesディレクトリ

ここにはトランプの画像をどんどんいれていく。
コピー&ペーストでディレクトリに追加できる。
ファイル名の付け方は以下の法則で名づけていく。
法則を決めておけば、for文など繰り返し文を使って実装することができるからだ。

[トランプのマーク]_[数字].png

今回はチコデザ様から、トランプの裏面画像はハートの素材屋様から使わせていただきました。
ありがとうございます。

pubspec.yamlに関して

ここはプロジェクトファイル全体の設定の記載を行うところでもある。

たとえば、新しくライブラリを使用したい、画像を取り込めるようにしたいなど、
ソースコードで操作するだけではできない設定を行う。

今回はリソース(画像など)を追加したいため、「asset」のところにディレクトリ名を追加する。

意外と忘れやすいのはこの「flutter pub upgrade」コマンド。
これで、pubspec.yamlファイルの再読み込みを行う。

トランプのマークの列挙体

これは今までで出てきたから問題ないだろう。
今回は列挙体の拡張が肝になってくる。
列挙体の値に応じてtrumpMarkStringの内容を変えるというもの。
文字列を返しているが、その内容は先ほどの画像ファイルの「トランプのマーク」の部分の文字列を設定しよう。

例として、画像ファイルの法則でスペードの画像は「spade」から始めているので、trumpMarkStringも「spade」と設定している。

トランプのクラス

TrumpCardクラスのこと。
ここも特に難しいことはない。
以下の特徴を持っている。

メンバ変数

bool isSelected;
int number;
TrumpMark mark;
  • 選択されているかどうか(カードが表向きになってるかどうか)
  • トランプの数字
  • トランプのマーク

メソッド

void tap() {
  this.isSelected = !this.isSelected;
}

タップされた時、選択状態・未選択状態を入れ替える(カードが表向きなら裏向きに、裏向きなら表向きにする)処理

トランプカードのオブジェクト

StateTrumpCard.dartのこと。
大きく分けて三つに分けられる。

状態に応じて画像の更新がされるようにしている

class StateTrumpCard extends StatefulWidget {
  final Function() notifyParent;
  TrumpCard trumpCard;
  StateTrumpCard({super.key, required this.notifyParent, required this.trumpCard});

  @override
  State<StatefulWidget> createState() => _StateTrumpCard(notifyParent: notifyParent, trumpCard: trumpCard);
}

以下のようにStatefulWidgetとしておくことで、これは「値が変わったらそれに応じて画面表示も更新する」という役割を持っている。

そして、表示する内容はcreateState()の内容である「_StateTrumpCard」クラスだ。

トランプの表面と裏面を表示する

@override
Widget build(BuildContext context) {
  return Center(
    child: Container(
      width: 120,
      height: 180,
      decoration: BoxDecoration(
        color: Colors.white,
        borderRadius: BorderRadius.circular(0),
        image: trumpDisplay(trumpCard),
      ),
      margin: const EdgeInsets.all(5),
      child: Material(
        color: Colors.transparent,
        child: InkWell(
          borderRadius: BorderRadius.circular(0),
          onTap: () {
            notifyParent();
          },
        ),
      ),
    ),
  );
}

一つ一つ見ていこう。
まず、return直後のCenterに関して。
これはchildの内容を上下左右の中央に表示すること。

では、Centerの表示内容であるContainerの内容を見ていこう。
Containerは何かというと、UIを表示するためのレイアウトボックス。要は「何かを表示するために必要なもの」と思ってもらえればいい。

この辺りは画像を表示するページにも記載している。

代表的な項目は以下。

項目内容
widthレイアウトの幅
heightレイアウトの高さ
decoration様々なカスタマイズをする
margin領域の外側の余白
padding領域の内側の余白
child表示内容

今回の場合は以下のようになっている。

項目内容
width120
height180
decoration後述。
今回は背景色(color)、角丸(borderRadius)、表示する画像(image)を設定している。
marginEdgeInsets.all(5)
つまり、上下左右の余白を「5」に設定する。
child後述

BoxDecorationに関して、今回使用している設定値は以下がある。

項目内容
color背景色
borderRadius角丸
image画像

今回の場合は以下の通りになっている。

項目内容
colorColors.white
白に設定
borderRadiusBorderRadius.circular(0)
0に設定。つまり、普段と変わらない
imagetrumpDisplay(trumpCard)
これはtrumpDisplayの内容を確認してみよう。
結論を話すと、カードの裏面か表面のどちらかを表示するようにしている。

InkWellを見ていこう。Materialをなぜ飛ばしたかというと、「InkWellはMaterialの中に書かなくてはいけない」と公式ページに書いてあった。

The InkWell widget must have a Material widget as an ancestor. 

なので、こう書かなければならないとしか言えない…
InkWellはタップした時に、タップしたところを中心にタップしたアニメーションを行う。これは見た方が早いだろう。

そして、onTapにタップした時の処理を行う。
この「notifyParent();」というのはStateTrumpCardクラスインスタンス生成時に決定する。

カードの画像を裏面もしくは表面を画像で表示する

DecorationImage trumpDisplay(TrumpCard trumpCard) {
  if (trumpCard.isSelected) {
    return DecorationImage(
      image: AssetImage('images/${trumpCard.mark.trumpMarkString}_${trumpCard.number+1}.png'),
      fit: BoxFit.cover,
    );
  } else {
    return DecorationImage(
      image: AssetImage('images/card_back.png'),
      fit: BoxFit.cover,
    );
  }
}

ここではDecorationImageを返す処理を行う。つまり画像だ。
では、どのような処理を行なっているかというと、isSelectedがfalseならcard_back.pngを表示し、isSelectedならトランプのマークと数字に該当する画像を表示するようにしている。

fitに関しては別ページでも説明しようと思うが、BoxFit.coverと設定することで、画像をあアスペクト比を維持したまま拡大するようにしている。

StateTrumpCardのまとめ

さて、長かった。
まとめると、こんな感じだ。

  • タップしたら表示が変わるようにしている。(StateTrumpCardクラス)
  • Containerオブジェクトを表示
    • 幅120、縦180
    • 余白は上下左右5だけ空ける
    • タップしたらタップしたところを中心に色が変化するようにする(InkWellの設定)
    • 表示する画像はTrumpCardクラスのisSelectedに応じて変える(trumpDisplayメソッド)

これでカードの画像を表示したボタンを表示することができる。

View表示部分

トランプの表示部分は前回と同じなのでそちらを見てほしい。
ただ、変わったのは以下の部分だ。

return Scaffold(
    appBar: AppBar(
      backgroundColor: Theme.of(context).colorScheme.inversePrimary,
      title: Text(widget.title),
    ),
    body: Stack(
        children: [
          Container(
            width: double.infinity,
            height: double.infinity,
            child: SingleChildScrollView(
              scrollDirection: Axis.horizontal,
                child: SingleChildScrollView(
                scrollDirection: Axis.vertical,
                  child: Row(
                      mainAxisAlignment: MainAxisAlignment.center,
                      children: [
                        for (var slicedTrumpButton in slicedTrumpButtons)
                          columnTrumpButtons(slicedTrumpButton),
                      ]
                    ),
                  ),
            )
          ),
        ]
    )
);

ボタンと違って、カードの画像はそこそこ大きさがあるので、スクロールさせないと表示しきれない。
そのために、縦横共にスクロールできるようにしている。

Containerの表示内容

項目設定内容
widthdouble.infinity
heightdouble.infinity
childSingleChildScrollView(scrollDirectionがAxis.horizontalのため、横スクロールできる)
更にその中に(縦スクロールができる)SingleChildScrollViewが入っている

これだと上下左右どちらかにしかスクロールできず、斜めにはできない。
ただ、これはAndroidでは元々できないらしく、斜めにスクロールさせようとするとカードのタップ処理を妨害してしまうため、あえて今回は縦横どちらかにしかスクロールできないようにしている。

参考ページ