🐥

Decolator パターンで Widget を構築する

2021/05/16に公開

はじめに

最近 Flutter でとある電卓アプリを作っているのですが、Flutter の宣言的 UI と Decolator パターンは相性が良いのでは?と考え採用してみました。
あと、実際のプロダクトコードの一部を書いているのでちょっとグダグダ長くなってしまっているのですが、本筋だけ見てもらえると幸いです🙇‍♂️

ちなみにこんな感じの電卓を作っていますw

今回はこの電卓のキーを例にしたいと思います
尚、(できるだけ)シンプルにするためにプロダクトコードよりは大きく情報を落としていますので、ご了承ください。

Decolator パターンとは

超簡単に表現するとある Component に対して Decoration (装飾) するという感じです。

イメージ

  1. スポンジケーキがある
  2. そこに生クリームとイチゴをトッピングするとショートケーキになるし、チョコソースでコーディングするとチョコレートケーキになる

以上!!!!🤣

っていうのは前回の C++ の記事でも説明しました。

もう少し具体的に表現すると DecolatorComponent に機能を追加していく形になります。

雑な設計イメージ

冒頭で述べましたが、今回は電卓のキーを作るイメージで実装します。

  1. Component / Decolator を実装
  2. 1 を組み合わせる Widget を実装( ComponentStack で積み上げていく)
  3. 2 の Widget を利用して電卓のキーを作る

Decolator パターンのメリット

  1. パーツとなる Component クラスを用意すれば、専用のカスタムクラスを随時作らなくて良い
  2. ベースクラスにそのまま拡張機能を持たせる場合、ベースクラスが変更されると全体に影響が出てしまうが、Decorator パターンは影響が少なくて済む
  3. 組み合わせや順序は自由にできる
  4. 宣言時で機能や Widget が決定するので Flutter と相性が良い(たぶん)

Decolator パターンの Widget を構築してみる

というわけで実装に入りたいと思います。

ベースクラスの定義

まずは Decolator パターンで使う全てのベースとなるクラスを実装します。

abstract class CalcItemPart {
  CalcItem? calcItem;
  CalcItemPart({this.calcItem});

  List<Widget> buildParts();
}

buildParts メソッドで Widget を構築させていく感じです。

CalcItem / CalcItemType が存在しますが、本筋とはあまり関係ないのでさらっと見てもらうかスルーしてもらって大丈夫です(一応、コードは載せておきます)。


abstract class CalcItem with _$CalcItem {
  const factory CalcItem (
    CalcItemType type,
    String value,
  ) = _CalcItem;

  static CalcItem create(CalcItemType type) {
    switch(type) {
      case CalcItemType.Number_0: return CalcItem(type, '0');
      case CalcItemType.Number_1: return CalcItem(type, '1');
      case CalcItemType.Number_2: return CalcItem(type, '2');
      case CalcItemType.Number_3: return CalcItem(type, '3');
      case CalcItemType.Number_4: return CalcItem(type, '4');
      case CalcItemType.Number_5: return CalcItem(type, '5');
      case CalcItemType.Number_6: return CalcItem(type, '6');
      case CalcItemType.Number_7: return CalcItem(type, '7');
      case CalcItemType.Number_8: return CalcItem(type, '8');
      case CalcItemType.Number_9: return CalcItem(type, '9');
      case CalcItemType.AC: return CalcItem(type, 'AC');
      case CalcItemType.Back: return CalcItem(type, '←');
      case CalcItemType.Keyboard: return CalcItem(type, '');
      case CalcItemType.Plus: return CalcItem(type, '+');
      case CalcItemType.Minus: return CalcItem(type, '−');
      case CalcItemType.Multiple: return CalcItem(type, '×');
      case CalcItemType.Division: return CalcItem(type, '÷');
      case CalcItemType.Dot: return CalcItem(type, '・');
      case CalcItemType.PlusMinus: return CalcItem(type, '±');
      case CalcItemType.Equal: return CalcItem(type, '=');
    }
  }
}
enum CalcItemType {
  Number_0,
  Number_1,
  Number_2,
  Number_3,
  Number_4,
  Number_5,
  Number_6,
  Number_7,
  Number_8,
  Number_9,
  AC,
  Back,
  Keyboard,
  Plus,
  Minus,
  Multiple,
  Division,
  PlusMinus,
  Dot,
  Equal
}
extension CalcItemTypeExt on CalcItemType {  
  CalcItemFont get fontInfo {
      // 省略:フォントの情報を取得する.
  }
}

Decolator の定義

次に Decolator の定義です。 DecolatorComponent を持っており、 DecolatorComponent は同じベースクラスです。

abstract class CalcItemDecorator extends CalcItemPart {
  CalcItemPart? component;
  CalcItemDecorator({this.component}) : super(calcItem: component?.calcItem);
}

これで下準備は完了です。次はパーツを作っていきましょう!

各パーツの実装

各パーツは CalcItemDecorator をベースにします。

class CalcItemText extends CalcItemDecorator {
  CalcItemText({CalcItemPart? component}) : super(component: component);

  
  List<Widget> buildParts() {
    if (calcItem == null) return [];
    final font =  calcItem!.type.fontInfo;
    final widget =
    Center(child:
      Text(calcItem!.value,
        style: TextStyle(
            color: Colors.white,
            fontSize: font.size,
            fontFamily: font.family,
            fontWeight: font.weight
        ),
      textAlign: TextAlign.center,
    ));

    if (component == null) {
      return [widget];
    }

    var parts = component!.buildParts();
    parts.add(widget);
    return parts;
  }
}
typedef OnPressedCalcKey = void Function(CalcItemType itemType);

class CalcItemButton extends CalcItemDecorator {

  OnPressedCalcKey? _onPressed;

  CalcItemButton({CalcItemPart? component, OnPressedCalcKey? onPressed}) : super(component: component) {
    _onPressed = onPressed;
  }

  
  List<Widget> buildParts() {
    // TextButton は仮.
    final button = TextButton(
      onPressed: () {
        // let は 自作拡張関数です.
        component?.calcItem?.let((it) {
          _onPressed?.let((run) => run(it.type));
        });
      },
      child: Container()
    );

    if (component == null) {
      return [button];
    }

    var parts = component?.buildParts();
    parts?.add(button);
    return parts!;
  }
}

root 用のクラスを実装します。

class RootCalcItem extends CalcItemPart {
  RootCalcItem({CalcItem? calcItem}) : super(calcItem: calcItem);

  
  List<Widget> buildParts() {
    return [];
  }
}

Component を構成する Widget の実装

Stack で Widget を組み合わせていきます。

class CalcItemWidget extends StatelessWidget {
  CalcItemPart? _component;
  CalcItemWidget(this._component);

  
  Widget build(BuildContext context) {
    if (_component == null) return Container();
    return Center(
      child: Container(
        height: double.infinity,
        width: double.infinity,
        child: Center(child:
        Stack(
          fit: StackFit.expand,
          children: _component!.buildParts(),
          alignment: Alignment.center,
        )),
      ),
    );
  }
}

Widget を組み合わせていく

最後に定義した Widget を組み合わせていきます。
かなり雑で申し訳ないですが、 _mode == 1 にはボタンがあり、 _mode == 0 のときはボタン機能がない見た目だけのキーを実装します。

_calcItemTypeList には CalcItemType が格納されておりこれをベースに電卓のキーが構成されていく感じです。

class CalculateKeyWidget extends StatelessWidget {

  List<CalcItemType> _calcItemTypeList
  int _mode;
  CalculateKeyWidget(this._calcItemTypeList, this._mode);

  
  Widget build(BuildContext context) {
    final List<Widget> keys;
    switch (_mode) {
      case 1: keys = _getCalcKeys(_calcItemTypeList); break;
      default: keys = _getCalcKeysWithoutButton(_calcItemTypeList); break;
    }

    final keyHeight = 80;
    
    return
      Column(
        crossAxisAlignment: CrossAxisAlignment.center,
        children: [
          Container(
            height: keyHeight,
            child: Row(
              children: [
                keys[0], keys[1], keys[2], keys[3],
              ],
            ),
          ),

        Container(
          height: keyHeight,
          child: Row(
            children: [
              keys[4], keys[5], keys[6], keys[7],
            ],
          ),
        ),

        Container(
          height: keyHeight,
          child: Row(
            children: [
              keys[8], keys[9], keys[10], keys[11],
            ],
          ),
        ),

        Container(
          height: keyHeight,
          child: Row(
            children: [
              keys[12], keys[13], keys[14], keys[15],
            ],
          ),
        ),

      Container(
        height: keyHeight,
        child: Row(
          children: [
            keys[16], keys[17], keys[18], keys[19],
          ],
        ),
      )
    ]);
  }

  List<Widget> _getCalcKeys(List<CalcItemType> calcItemTypeList) {

    List<Widget> result = [];
    calcItemTypeList.forEach((element) {
      final widget =
        CalcItemWidget(
          CalcItemButton(
            component: CalcItemText(
              component: RootCalcItem(calcItem: CalcItem.create(element))
            ),
            onPressed: (type) {
              print(type);
            }
          )
        );
      result.add(widget);
    });

    return result;
  }

  List<Widget> _getCalcKeysWithoutButton(List<CalcItemType> calcItemTypeList) {

    List<Widget> result = [];
    calcItemInfoList.forEach((element) {
      final widget =
        CalcItemWidget(
          CalcItemText(
              component: RootCalcItem(calcItem: CalcItem.create(element))
          ),
        );
      result.add(widget);
    });

    return result;
  }
}

以上が Decolator パターンによる実装例となります。

補足

今回はなるべくシンプルに伝えたかったのでパターンがかなり少ないですが、プロダクトコードでは CalcItemTypeKeyboard の時は CalcItemText を使用せずに CalcItemIcon というのを用意しアイコンを乗せるようにしたり、あるいは CalcItemImage などを作って画像を乗せたりすることも可能になります。

Decolator パターンは下準備が必要とはなりますが、一度作ってしまうとカスタマイズがしやすく、生成時に機能を決定できるので宣言的 UI と相性が良いと思っています。

もし採用できそうなら一度試してみる価値はあるかなと個人的には思います。

終わり(どうでもいいこと)

Flutter での開発は業務経験ではないのですが、宣言的 UI の素晴らしさはすでに結構堪能しています。なんというかデータを基準にするみたいな感覚で作っていて爽快感があり、また自ずと設計に対して妥協がしにくくなるので、色々と試される気がします。

最後まで読んでもらってありがとうございました!
電卓アプリもリリース予定なので良かったら遊んでみてくださいね(╹◡╹)

Discussion