💧

宣言的UIを水で表現する

2023/02/12に公開
3

はじめに

出オチのようですが、作成するサンプルAppのキャプチャを先に見て頂きましょう。

温度を操作することで水が氷になったり、水蒸気になったりしていますね。

本記事では上記のようにユーザー操作によって画面に変化が与えられるサンプルAppの作成を目指します。


Flutter × Riverpod(v2系) の組み合わせで宣言的UIを実現することが出来ます。

宣言的UIを抽象化すると

UI = f( state )

で表現することができます。
言葉に言い換えると

「State を Build(f) した結果がComponent(UI)である。」

という言い方が出来ます。
Riverpodを用いた実装方法では

「Component(UI)がStateをWatchする。Stateの更新によってUIは再描画される。」

と言い換えることが出来ます。
状態の変化を描画する事にスコープを絞りたいので、今回は通信を使わずに表現出来るようにサンプルAppを作成しました。

この記事の推奨読者

  • Flutter3.x系のサンプルカウンターAppを起動し、基本的なBuildの仕組みを理解された方
  • Riverpod2.x系の状態管理のドキュメントに目を透し、Prividerの実践的な使い方を学びたい方
  • Flutterにおける宣言的UIの実装サンプルの解説が欲しい方

題材は水とする

本記事で達成したい要件を整理します。

  • 事前に状態を定義する
  • ユーザー操作によって状態を変化させることが出来る
  • 定義した状態によって画面描画が変わる

の3点に絞ることが出来そうです。

そこで表題にもありますが今回は「水」を題材にしようと思いました。
水はその温度によって、状態を

  • 個体 (氷)
  • 液体 (水)
  • 気体 (水蒸気)

と変化させます。
今回の題材としてピッタリな気がします。

状態の変化は、温度をスライダーで自由に変更出来るようなUIにします。

水について詳しく調べていくと1気圧の条件において ~~~ みたいな細かい話になるのですがそれは置いておいて、

  • マイナスになったら凍る
  • 100℃を超えると水蒸気になる

と決めてしまいましょう。

記事で扱う技術要素

フレームワーク

基本的にサンプルApp作成時の最新バージョンになります。

Provider

今回は状態管理フレームワークとしてRiverpod v2から下記の2種類を選定しました。

  • Provider
    • Riverpodの提供する状態管理フレームワークの中でも最も基本的かる、汎用性の高いものです。
    • ref.watch と組み合わせれば非常に強力なキャッシュ機構を扱うことが出来るでしょう。
    • もしも通信を介した状態変化を管理したいならAsyncNotifierProviderを推奨します。(本記事では触れません。)
  • StateProvider
    • 複雑ではない変数の状態管理を簡素に実現したいなら、これを使いましょう。

各々の詳細な機能は、実際のユースケースで一緒に説明します。

設計

筆者はMVVM + リポジトリパターンを好むの近い形で作りつつ、今回はスコープにデータ通信を含まないのでdomain層はentityのみで組みました。
今回の要件においてViewModelは状態変更の結果を通知する役割しか持たないので、Notifierという命名で統一しています。

  • Domain
    • entity
  • ViewModel
    • notifier
  • View
    • template

前置きが長くなりましたが、各ディレクトリごとに解説していきます。

Domain

Entityの役割として状態の定義、温度の定義が必要です。
状態はイミュータブルに管理したいので、Riverpodの相棒 freezed を用いて作成しました。
今回のサンプルアプリでは基本的にSateとしての役割を持ちます。

WaterEntity

水そのものを定義します。

lib/domain/entity/water/entity.dart

class WaterEntity with _$WaterEntity {
  const factory WaterEntity.solid() = _WaterSolid;
  const factory WaterEntity.liquid() = _WaterLiquid;
  const factory WaterEntity.gas() = _WaterGas;
}

「個体」 「液体」 「気体」の3パターンの状態を定義しました。

ViewModel

状態の変化の結果をView層で伝達します。

temperatureStateProvider

今回温度情報に伝えるProviderにはNotifierProviderを選定しました。
公式docsにも記載されていますがRiverpod2.x以上において簡単なStateの伝達であれば基本的にはNotifierProvider (非同期での状態変更であればAsyncNotifierProvider) が推奨されます。
温度の情報(Temperature)を利用側に公開し、変更があった場合に通知する機能を持ちます。

lib/view_model/water/notifier.dart
final temperatureSateProvider = StateProvider<double>(
  (ref) => 0.0,
);

ここではProviderとしてStateProviderを選定しました。
StateProviderはStateNotifierProviderを簡易にしたもので、
ロジックを伴わない単純な変数の状態管理に向いています。
選定基準として言語化するなら、状態の変更にロジックを伴わなず、Testの実装が不要であるならStateProviderで十分だと思います。

waterEntityProvider

providerClassを継承し、StateとしてWaterEntityを持ちます。

Providerは協力なキャッシュ機構を備えた状態管理フレームワークであり、Riverpod v2系になってからもそれは変わりません。

lib/view_model/water/notifier.dart
final waterEntityProvider = Provider<WaterEntity>(
  (ref) {
    // 温度情報の状態変化をWatchする
    final double temperature = ref.watch(temperatureProvider);
    if (temperature < 0.0) {
      // 0度未満の場合は個体
      return const WaterEntity.solid();
    } else if (temperature >= 0.0 && temperature <= 100) {
      // 0度以上、100度以下のときは液体
      return const WaterEntity.liquid();
    } else if (temperature > 100) {
      // 100度以上のときは気体
      return const WaterEntity.gas();
    }
    return const WaterEntity.liquid();
  },
);

今回のサンプルAppとして一番重要なロジックである「温度によって状態が変化する」機能はここで提供されます。
この状態変化のロジックは前述したtemperatureSateProviderの結果が変更されるたびに再計算されます。よって温度が変わるたび、常に最新の情報で水の状態をView側に通知することが出来ます。

また、Providerによるキャッシュ機構のおかけで不要な再計算を減らせます。
例えばView側でReBuildが走った場合でもWaterEntityの結果が変わらない場合 (温度が変わらない場合)、このwaterEntityProviderはキャッシュからWaterEntityを返却するのです。

View

大きく二種類に分割しました。

部品用コンポーネント

  • 温度の状態をユーザーからの入力として受け付けるTemperature
  • 温度をテキスト表示するDegreesCelsius

Main画面となるコンポーネント

  • 部品用コンポーネントを組み合わせて一つの画面にするMaterialMain

今回はmodule分割の考え方はスコープの外と考え、全てTemplatesディレクトリとして配置していますが
例えばAtomicDesignとして分割するなら前者の2つはmoleculeとして配置し、後者はorganismsとして独立させるでしょう。

メイン画面がなぜMaterialというプレフィックスが付いてるのかというと、将来的に水以外の題材でも宣言的UIのサンプルを作れたら面白いなと考えているからです。

Temperature

主にSliderWidgetで構成されています。

lib/view/templates/water/temperature/widget.dart

class Temperature extends ConsumerWidget {
  const Temperature({super.key});

  
  Widget build(BuildContext context, WidgetRef ref) {
    return Padding(
      padding: const EdgeInsets.symmetric(vertical: 16),
      child: Slider(
        divisions: 22,
        max: 120.0, // 最大温度
        min: -100.0, // 最低温度
        value: ref.watch(temperatureSateProvider),
        onChanged: (value) {
          // 温度の更新
          ref.watch(temperatureSateProvider.notifier).update(
                (state) => value,
              );
        },
      ),
    );
  }
}

SliderWidgetの基本的な使い方は公式docにお任せするとして、重要なのはonChanged Callback関数の中になります。

        onChanged: (value) {
          // 温度の更新
          ref.watch(temperatureSateProvider.notifier).update(
                (state) => value,
              );
        },

SliderWidgetのシークバーを動かした場合、onChanged Callback関数は最新のシークバーの値 (今回は温度) をvalueに格納して発火します。
事前に作っておいたtemperatureSateProviderに最新の温度情報(valueに格納されている)を渡しましょう。

update関数を使って最新の状態に更新します。

DegreesCelsius

現在の温度情報をTextで表示させる為のWidgetになります。
◯◯℃のような表示ににするために引数としてtemperaturen (温度) を必要とします。

lib/view/templates/water/degrees_celsius/widget.dart
class DegreesCelsius extends StatelessWidget {
  const DegreesCelsius({
    super.key,
    required final String temperature,
  }) : _temperature = temperature;

  final String _temperature;
  
  Widget build(BuildContext context) {
    return Padding(
      padding: const EdgeInsets.all(16.0),
      child: Text(
        '$_temperature${String.fromCharCode(8451)}',
        style: const TextStyle(fontSize: 32),
      ),
    );
  }
}

MaterialMain

ここまでに説明した2つのComponent(Temperature,DegreesCelsiusを組み合わせて、サンプルAppのMain画面を作ります。

Widgetソースコード全文
lib/view/templates/material_main/widget.dart

class MaterialMain extends ConsumerWidget {
  const MaterialMain({super.key});

  
  Widget build(Object context, WidgetRef ref) {
    final String waterImagePath = ref.watch(waterEntityProvider).when(
          solid: () => Assets.images.waterSolid.path, // 個体のImagePath
          liquid: () => Assets.images.waterLiquid.path, // 液体のImagePath
          gas: () => Assets.images.waterGas.path, // 気体のImagePath
        );
    final double temperatureValue = ref.watch(temperatureSateProvider);
    return Column(
      children: <Widget>[
        Expanded(
          flex: 3,
          child: Center(
            child: Image.asset(waterImagePath),
          ),
        ),
        Expanded(
          flex: 1,
          child: SizedBox(
            height: 16,
            child: Column(
              mainAxisAlignment: MainAxisAlignment.center,
              children: <Widget>[
                DegreesCelsius(temperature: '${temperatureValue.ceil()}'),
                const Temperature(),
              ],
            ),
          ),
        ),
      ],
    );
  }
}

MaterialMainでは下記の2点が注目ポイントです。

  1. String waterImagePathはwaterEntityProviderが更新するStateによって決定される

waterEntityProviderのセクションで説明した通りwaterEntityProviderは常に最新のWaterEntityを返却します。
Watch関数は、それを監視する事を可能とするのでWaterEntityで定義したいずれかの状態を取得することが出来ます。(solidliquid、またはgasです。)

  1. double temperatureValuetemperatureStateProviderから常に最新の温度情報を取得することが出来る

TemperatureWidgetのonChanged Callback関数内でtemperatureStateProviderのStateを更新しています。
StateProviderをWatchしている状態でStateが更新されると新しい計算結果によって自動的にReBuildが走ります。
よって、DegreesCelsiusWidgetは常に最新のtemperatureValueの値を表示することが出来るのです。

まとめ

今回は水の状態変化を題材にして、事前に定義した状態及び状態変化の結果によって画面描画が変化する、サンプルコードの解説を行いました。
なるべく他の要素はコードに記述せず、要点を絞って執筆したつもりです。

サンプルAppのソースコードは下記のGithubにて公開しています。

https://github.com/Nuu-mA/material/releases/tag/1.1

誤字脱字、その他技術的指摘を頂けると大変ありがたいです。コメントお待ちしております。


本記事はZenn CLIを使用して執筆しました。ローカルで執筆出来る安心感や差分の管理もしやすく、大変体験の良いツールだったと思います。

Discussion

K9i - Kota HayashiK9i - Kota Hayashi

温度に関する状態をTemperatureウィジェットのState、temperatureStateProvider、waterNotifierProviderで三重に管理してしまっており、好ましく無いように感じました👀
以下のようにすれば、temperatureProviderに集約できシンプルです。

import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:material/domain/entity/water/entity.dart';

final temperatureProvider = StateProvider<double>(
  (ref) => 0,
);

final waterEntityProvider = Provider<WaterEntity>(
  (ref) {
    final temperature = ref.watch(temperatureProvider);
    if (temperature < 0.0) {
      // 0度未満の場合は個体
      return const WaterEntity.solid();
    } else if (temperature >= 0.0 && temperature <= 100) {
      // 0度以上、100度以下のときは液体
      return const WaterEntity.liquid();
    } else if (temperature > 100) {
      // 100度以上のときは気体
      return const WaterEntity.gas();
    }
    return const WaterEntity.liquid();
  },
);

ウィジェットもStatelessにできます👀

import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:material/view_model/temperature/notifier.dart';

class Temperature extends ConsumerWidget {
  const Temperature({super.key});

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    return Padding(
      padding: const EdgeInsets.symmetric(vertical: 16),
      child: Slider(
        divisions: 22,
        max: 120.0, // 最大温度
        min: -100.0, // 最低温度
        value: ref.watch(temperatureProvider),
        onChanged: (value) {
          // 温度の更新
          ref.watch(temperatureProvider.notifier).update((state) => value);
        },
      ),
    );
  }
}

ちょっとSSOT意識しすぎかもしれませんが、上記のようにしておけば状態の不整合など起きづらいようにできます👍

NumaTatsuNumaTatsu

K9i さん
ご指摘感謝です!確かに温度の情報はEntityにする必要性が薄く、単純な double 型 の情報で良さそうですね。
( サンプル作成時は体積や気圧の情報も格納しようと考えていた名残かもしれません。 )

Providerをまとめられる
TemperatureWidgetをStatelessに出来る

こちらもご指摘の通りですね!SSOTの観点からもWatchするStateは減らすべきなので、後日修正しようと思います 👍