【Flutter】ボタン押下してそのボタンの表示を更新(MVVM形式)

目次

概要

前回でボタンを羅列することをやった。今回は、ボタンを押下したら何のボタンをタップしたかをやってみよう。
また、本ページは実務に近いコード(今後メンテナンスしやすいなど)を意識したプログラミングを行っている。そのため、ある程度レベルが高いと思うが、頑張ろう。

また、簡単な方はこちらに記載。

ここでは、以下の知識が必要になるので、併せて勉強していこう。

  • 条件分岐
  • 値の変更と更新
  • 生成したオブジェクトのメソッドを実行
  • MVVMモデル(ここが初心者には難しいが、ネットではなかなか学べないところ)

まずは百聞は一件にしかず。
今回関係するコードをまとめておく。

ソースコード

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

import 'TrumpMark.dart';

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

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

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

トランプマークの列挙体

enum TrumpMark {
  spade,
  heart,
  dia,
  club
}

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

アプリ呼び出し部分

import 'package:concentration/TrumpButton/TrumpButtonView.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';

import 'TrumpButton/TrumpButtonView.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 TrumpButtonView(title: 'Flutter Demo Home Page'),
    );
  }
}

View表示部分

import 'package:collection/collection.dart';
import 'package:concentration/Common/TrumpCard.dart';
import 'package:concentration/Common/TrumpMark.dart';
import 'package:flutter/material.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';

import 'TrumpButtonViewModel.dart';

class TrumpButtonView extends ConsumerWidget {
  const TrumpButtonView({Key? key, required this.title}) : super(key: key);
  final String title;

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final _viewModel = ref.watch(trumpButtonViewModelProvider);
    List<OutlinedButton> trumpButtons = [];

    for (TrumpCard trumpCard in _viewModel.trumpCards) {
      var trumpButton = OutlinedButton(
          onPressed: () { _viewModel.tapped(trumpCard); },
          style: OutlinedButton.styleFrom(
              minimumSize: Size(MediaQuery.of(context).size.width/4, 40),
              backgroundColor: Colors.white10,
              foregroundColor: Colors.black,
          ),
          child: trumpDisplay(trumpCard)
      );

      trumpButtons.add(trumpButton);
    }
    final itemsPerRow = 13;
    final slicedTrumpButtons = trumpButtons.slices(itemsPerRow).toList();

    return Scaffold(
      appBar: AppBar(
        backgroundColor: Theme.of(context).colorScheme.inversePrimary,
        title: Text("TrumpButton"),
      ),
      body:
      LayoutBuilder(
        builder: (context, constraints) => SingleChildScrollView(
          physics: AlwaysScrollableScrollPhysics(),
          child: ConstrainedBox(
            constraints: BoxConstraints(minWidth: constraints.maxWidth, minHeight: constraints.maxHeight),
            child: Row(
              mainAxisAlignment: MainAxisAlignment.center,
              children: [
                for (var slicedTrumpButton in slicedTrumpButtons)
                  columnTrumpButtons(slicedTrumpButton),
              ]
            ),
          ),
        )
      )
    );
  }

  Widget trumpDisplay(TrumpCard trumpCard) {
    if (trumpCard.isSelected) {
      return Text("${trumpCard.mark.trumpMarkString} ${trumpCard.number + 1}");
    } else {
      return Text("?", textAlign: TextAlign.center);
    }
  }

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

ViewModelクラス

import 'dart:ffi';

import 'package:concentration/Common/TrumpMark.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../Common/TrumpCard.dart';
import 'TrumpButtonRepository.dart';

final trumpButtonViewModelProvider = ChangeNotifierProvider((ref) => TrumpButtonViewModel(repository: ref.read(trumpButtonRepositoryRepositoryProvider)));

class TrumpButtonViewModel extends ChangeNotifier {

  TrumpButtonRepository? repository;
  List<TrumpCard> trumpCards = [];

  TrumpButtonViewModel({this.repository}){
    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;
  }

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

Repositoryクラス

import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'TrumpButtonModel.dart';

final trumpButtonRepositoryRepositoryProvider = Provider((ref) => TrumpButtonRepositoryImpl(model: ref.read(trumpButtonModelModelProvider)));

abstract class TrumpButtonRepository {
  void tapped();
}

class TrumpButtonRepositoryImpl implements TrumpButtonRepository {
  TrumpButtonRepositoryImpl({required TrumpButtonModel model}): _model = model;

  final TrumpButtonModel _model;

  void tapped() {
    print("タップされたよ!");
  }
}

TrumpButtonModel

import 'package:flutter_riverpod/flutter_riverpod.dart';

final trumpButtonModelModelProvider = Provider((ref) => TrumpButtonModel());

class TrumpButtonModel {

}

デモ動画

詳細

さあ、ここから詳しく説明していこう。
色々書いているが、取り出して考えると、以下の流れになっている。

  1. TrumpCardのオブジェクトを生成する
  2. TrumpCardのオブジェクトをもとに、ボタンを生成する
  3. ボタンの文字列はtrumpCardのisSelectedがtrueだったらトランプのマークと数字を、falseだったら「???」と表示させるようにする。
  4. ボタンをタップしたらTrumpButtonViewModelのvoid tapped(TrumpCard trumpCard) のメソッドが呼ばれる
  5. trumpCard.tap()のメソッドが呼ばれ、TrumpCardのisSelectedのtrue/falseが入れ替わる
  6. void tapped(TrumpCard trumpCard)のnotifyListeners()メソッドで画面が更新される。
  7. 画面の更新により、3の処理が行われる。

では、順番に見ていこう。

TrumpCardのオブジェクトを生成する

ここはTrumpCard.dartの内容の通り、まずはオブジェクトの設計書にあたるクラスを定義しよう。
設定内容は以下

項目内容
クラス名TrumpCard
メンバ(クラスが持つ変数)bool isSelected;
int number;
TrumpMark mark;
コンストラクタ(オブジェクト生成時の変数設定)TrumpCard(this.isSelected, this.number, this.mark);
メソッドvoid tap()
tapメソッドの内容this.isSelected = !this.isSelected;
つまり、this.isSelectedがtrueならその否定のfalseを、falseならその否定のtrueを代入する。

そして、オブジェクトを生成している箇所はTrumpButtonViewModelクラスの20行目。
ここでTrumnCardのオブジェクトを生成して、trumpCardsの配列に格納している。

以下が該当の箇所。

TrumpButtonViewModel({this.repository}){
  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;
}

TrumpCardのオブジェクトをもとに、ボタンを生成する

そして、そこで生成したものをTrumpButtonViewの19行目で使用して、ボタンを生成している。
その生成したボタンの内容は以下。

final _viewModel = ref.watch(trumpButtonViewModelProvider);
List<OutlinedButton> trumpButtons = [];

for (TrumpCard trumpCard in _viewModel.trumpCards) { // viewModelのtrumpCardsを一つずつ参照
  var trumpButton = OutlinedButton( // 外枠のボタンを生成
      onPressed: () { _viewModel.tapped(trumpCard); }, // ボタンを押下した時の処理
      style: OutlinedButton.styleFrom(
          minimumSize: Size(MediaQuery.of(context).size.width/4, 40),
          backgroundColor: Colors.white10,
          foregroundColor: Colors.black,
      ),
      child: trumpDisplay(trumpCard) // trumpDisplayメソッドの内容を表示する
  );

  trumpButtons.add(trumpButton); // trumpButtonsに外枠ボタンのオブジェクトを追加
}

ボタンの文字列はtrumpCardのisSelectedがtrueだったらトランプのマークと数字を、falseだったら「???」と表示させるようにする。

先ほどボタンのオブジェクトを生成したが、ボタンの表示部分はchildの部分だ。
そのchildの部分はtrumpDisplayメソッドが呼ばれている。
いわゆる、以下の部分が表示するViewというわけだ。

Widget trumpDisplay(TrumpCard trumpCard) {
  if (trumpCard.isSelected) {
    return Text("${trumpCard.mark.trumpMarkString} ${trumpCard.number + 1}");
  } else {
    return Text("?", textAlign: TextAlign.center);
  }
}

まず、この処理をするにあたって、事前にTrumpCardのオブジェクトを呼び出すときに設定する必要がある。そして、そのTrumpCardのオブジェクトのisSelectedがtrueならトランプのマークと数字を、そうでなければ?と表示させる処理を行っている。

ここは条件分岐がわかっていればできるはずだ。

ボタンをタップしたらTrumpButtonViewModelのvoid tapped(TrumpCard trumpCard) のメソッドが呼ばれる

ボタンを押下したらどんなことが起こるか。そうonPressedの内容が行われる。
今回の場合は以下だ。

var trumpButton = OutlinedButton(
  onPressed: () { _viewModel.tapped(trumpCard); }, // ボタン押下時の処理はココ!
  style: OutlinedButton.styleFrom(
      minimumSize: Size(MediaQuery.of(context).size.width/4, 40),
      backgroundColor: Colors.white10,
      foregroundColor: Colors.black,
  ),
  child: trumpDisplay(trumpCard) // trumpDisplayメソッドの内容を表示する
);

そう、行われる処理は_viewModel.tapped(trumpCard);だ。
_viewModelは以下の通り、TrumpButtonViewModel型だ。

final _viewModel = ref.watch(trumpButtonViewModelProvider);
final trumpButtonViewModelProvider = ChangeNotifierProvider((ref) => TrumpButtonViewModel(repository: ref.read(trumpButtonRepositoryRepositoryProvider)));

そのTrumpButtonViewModelクラスのtappedメソッドは以下のようになっている。

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

trumpCard.tap()のメソッドが呼ばれ、TrumpCardのisSelectedのtrue/falseが入れ替わる

これはそのまま。TrumpButtonViewModelクラスのtappedメソッドの一行目は設定されたtrumpCardのtap()メソッドを呼び出している。
そのtap()メソッドの中身は以下。

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

ここでisSelectedのtrueとfalseが入れ替わっている。
つまり、未選択の状態なら選択された状態に、選択された状態なら未選択の状態にする処理を行っている。

void tapped(TrumpCard trumpCard)のnotifyListeners()メソッドで画面が更新される。

ここはnotifyListeners()を呼び出して、画面の更新を行っている。
まともに説明するとこのページのボリュームが膨大になるため、ここでは詳細を割愛する。

画面の更新により、3の処理が行われる。

画面の更新により、再度画面を更新する。その際にボタンの表示内容である以下の部分が再度読み込まれる。

var trumpButton = OutlinedButton(
  onPressed: () { _viewModel.tapped(trumpCard); },
  style: OutlinedButton.styleFrom(
      minimumSize: Size(MediaQuery.of(context).size.width/4, 40),
      backgroundColor: Colors.white10,
      foregroundColor: Colors.black,
  ),
  child: trumpDisplay(trumpCard) // ここが再度読み込まれる。
);

trumpCard.tap()のメソッドが呼ばれ、TrumpCardのisSelectedのtrue/falseが入れ替わったため、もし「?」が表示されている状態ならマークと数字が、マークと数字が表示されていたら「?」が表示されるようになる。

参考ページ