[Flutter]2つの選択リストの連携とプラットフォームごとのWidgetの出し分け

でプラットフォームごとのWidgetの出し分けと複数の選択肢から選択するUIをFlutterで扱う場合について書きました。
2021.02.28

個人開発で作っているアプリの実装の過程の小ネタです。

複数の選択肢からユーザーに選択させるUIを表現する時にDropDownButtonが浮かびます。iOSでは同じような表現をしたい時にUIPickerViewを使うことがあるかと思います。

業務でネイティブアプリケーションを作る時、基本的には両OSでUIを揃えてプラットフォーム依存でUIを分けた方が良い所に関しては、それぞれのプラットフォームで推奨されているUIを実装することが多いです。Flutterでかつ個人開発なのでその当たりは気にしなくても良いのですが、ここはUIを個別に分けておきたいと思った所だったので、プラットフォームによってUIを出し分けることにしました。

また、選択肢から選択した後、それに連携させて次の選択肢を限定させたいと思いました。

今回はプラットフォームごとのWidgetの出し分けと、2つのWidgetの連携させる部分の実装を書いていこうと思います。

DropdownButton

設定した項目一覧から選択できるマテリアルデザインに則ったボタンを提供するクラスです。要素1つ1つはDropdownMenuItemというクラスで表現します。

プラットフォームごとに別々のWidgetを返すためのStatelessWidgetを定義します。コンストラクタとプロパティは以下になります。

class DropDownControl extends StatelessWidget {
  final List<String> itemStrings;
  final String selectedItemString;
  final ValueChanged<String> onChanged;

  DropDownControl({this.itemStrings, this.selectedItemString, this.onChanged})
      : assert(itemStrings != null),
        assert(selectedItemString != null),
        assert(onChanged != null);

続けて、Android用のwidgetを返すメソッドを定義します。ここでDropdownButtonとDropdownMenuItemを使用します。

Widget _buildDropDownButton() {
  return Padding(
    padding: const EdgeInsets.only(left: 10.0),
    child: DropdownButton(
      value: selectedItem,
      icon: Icon(Icons.arrow_drop_down),
      iconSize: 30,
      elevation: 16,
      underline: Container(
        height: 2,
        color: Colors.grey,
      ),
      onChanged: onChanged,
      items: itemStrings.map((String itemString) {
        return DropdownMenuItem(
          value: itemString,
          child: Text(
            itemString,
          ),
        );
      }).toList(),
    ),
  );
}

DropdownButtonの実装で重要なコンストラクタ引き数はitemsとvalue、そしてonChangedです。valueは初期状態の値になります。itemsの型はList<DropdownMenuItem>なので、ここでこのアプリではListにmapを呼び出してListを返す式を定義しています。

ValueChanged型のonChangedです。選択された時にonChangedが呼ばれるので、そのタイミングで選択肢を更新します。

CupertinoPicker

iOSのデザインに調和するUIのウィジェット群を提供しているCupertinoの内、iOSのUIPickerViewに相当するのがCupertinotePickerです。

Widgetのカタログが以下です。

CupertinoPickerのドキュメントは以下です。

せっかくなのでボタンにはCupertinoButtonを設置します。タップ時に_showModalPickerというメソッド経由でCupertinoPickerを表示します。

Widget _buildCupertinoPicker(BuildContext context) {
    return Container(
      child: Center(
        child: CupertinoButton(
          child: Text(
            selectedItemString,
            textAlign: TextAlign.center,
            style: TextStyle(color: Colors.blue),
          ),
          onPressed: () => _showModalPicker(context),
        ),
      ),
    );
  }

_showModalPickerの実装です。選択肢をタップした時にドラムロールが閉じてほしいので、GestureDetectorをchildに渡してタップ時に閉じるようにしています。

childrenにはitemStringsをmapした結果にtoList()を呼び出してから渡しています。ここはDropdownButtonのitemsの実装とそう変わりないです。

void _showModalPicker(BuildContext context) {
    showModalBottomSheet(
      context: context,
      builder: (BuildContext context) {
        return Container(
          height: MediaQuery.of(context).size.height / 3,
          child: GestureDetector(
            onTap: () {
              Navigator.pop(context);
            },
            child: CupertinoPicker(
              itemExtent: 40,
              children: itemStrings.map(_pickerItem).toList(),
              onSelectedItemChanged: (value) {
                onChanged(itemStrings[value]);
              },
            ),
          ),
        );
      },
    );
  }

Widget _pickerItem(String string) {
  return Text(
    string,
  );
}

プラットフォームごとにUIを出し分ける

dart:ioをimportしてPlatformに生えているプロパティでプラットフォームを判定します。今回使用するのは1つです。

  • Platform.isIOS

Flutterは対応プラットフォームが増えているので他にも以下のようなプロパティがあります。

  • Platform.isAndroid
  • Platform.isWindows
  • Platform.isMacOS
  • Platform.isLinux
  • Platform.isFuchsia

実装に戻ります。今回リリースを予定しているのはモバイルアプリの2プラットフォームなので三項演算子

import 'dart:io';

@override
  Widget build(BuildContext context) {
    final defaultTextTheme = Theme.of(context).textTheme;
    final titleStyle = defaultTextTheme.subtitle1.copyWith(
      fontWeight: FontWeight.bold,
      color: Colors.grey,
    );

プラットフォームごとに出し分けてくれるパッケージについて

今回はやりたいことが単純なので自分でWidgetを定義しましたが、クロスプラットフォームなUIフレームワークということもあり、プラットフォーム依存のUIを出し分けてくれるパッケージは多数存在します。

アラートを出し分けるadaptive_dialogなどはとても品質が良さそうなのでエラーハンドリングでの使用を予定しています。

他にもWidget全体をカバーしたものも作られています。

実務でネイティブアプリをそれぞれ実装する時、両アプリの見た目は基本的に統一してアラートやボタンのスタイルはプラットフォーム依存なことが多いので、Flutterでやる時にもそのぐらいの出しわけで済ませたい気持ちがあります。

年、月、日を選択すると選択できる数字が変わるドロップダウンリスト

片方のドロップダウンリストから選択した時にともう一つの選択させたい要素の一覧が変わるので、片方の選択がもう一つのドロップダウンリストの選択肢を規定したり、ドロップダウンリストの表示自体を制御するようにします。

基本的にここまで説明で使った実装を使うのですが命名などを以下の動画を参考に変更しています。また、Flutterを始めたばかりの人でも手元で動かせるように全コードを載せたgistのURLを貼ります。また、パッケージは使用せず純粋なsetStateで書いているので冗長ですが、Flutterを始めたばかりの人でも読めるようになっていると思います。

この動画ではプラットフォームに応じて出し分けるWidgetの命名をAdaptiveというprefixをつけるようにしているのでそれに従います。実装はこれまで説明したものと同じなので命名以外は一部省略しています

class AdaptiveDropdown extends StatelessWidget {
  final List<String> itemStrings;
  final String selectedItemString;
  final ValueChanged<String> onChanged;

  AdaptiveDropdown({this.itemStrings, this.selectedItemString, this.onChanged})
      : assert(itemStrings != null),
        assert(selectedItemString != null),
        assert(onChanged != null);

  @override
  Widget build(BuildContext context) {
    return Platform.isIOS
        ? _buildCupertinoPicker(context)
        : _buildDropDownButton();
  }

// 実装は省略
}

このAdoptiveDropdownというWidgetを内包する親WidgetをStatelessWidgetを継承して定義します。実装の冒頭で選択肢に表示したい選択肢一覧をstaticメソッドで定義します。月を選択した時は数字部分は12、日を選択した時は365を表示したいとします。年はとりあえず10にしました。

class CycleSelectButtons extends StatelessWidget {
  static final List<String> strings = [
    Span.Year.stringValue,
    Span.Month.stringValue,
    Span.Day.stringValue,
    Span.None.stringValue,
  ];

  static final List<String> yearStrings =
      List<int>.generate(10, (i) => i + 1).map((i) => '$i').toList();
  static final List<String> monthStrings =
      List<int>.generate(12, (i) => i + 1).map((i) => '$i').toList();
  static final List<String> dayStrings =
      List<int>.generate(365, (i) => i + 1).map((i) => '$i').toList();

  final Span selectedSpan;
  final int range;
  final ValueChanged<String> onSpanChanged;
  final ValueChanged<int> onRangeChanged;

  CycleSelectButtons(
      {@required this.selectedSpan,
      @required this.onSpanChanged,
      @required this.onRangeChanged,
      @required this.range})
      : assert(selectedSpan != null),
        assert(onRangeChanged != null),
        assert(onSpanChanged != null),
        assert(range != null);

overrideしているbuildメソッドの実装が以下になります。

@override
  Widget build(BuildContext context) {
  return Row(
    children: _dropdowns,
    mainAxisAlignment: MainAxisAlignment.center,
  );
}

privateなgetterで引き数childrenに値を渡しています。年、月、日、未設定の内、未設定の時は後続の選択肢自体をなくしたいのでこのgetterに表示ロジックが含まれます。

List<Widget> get _dropdowns {
    List<Widget> dropdowns = [
      AdaptiveDropdown(
        itemStrings: strings,
        selectedItemString: selectedSpan.stringValue,
        onChanged: onSpanChanged,
      ),
    ];

    // 未設定だった場合は後続のWidgetを追加せずにreturnする
    if (selectedSpan == Span.None) {
      return dropdowns;
    } else {
      switch (selectedSpan) {
        case Span.Year:
          dropdowns.add(
            AdaptiveDropdown(
              itemStrings: yearStrings,
              selectedItemString: '$range',
              onChanged: (value) => onRangeChanged(int.parse(value)),
            ),
          );
          break;
        case Span.Month:
          dropdowns.add(
            AdaptiveDropdown(
              itemStrings: monthStrings,
              selectedItemString: '$range',
              onChanged: (value) => onRangeChanged(int.parse(value)),
            ),
          );
          break;
        case Span.Day:
          dropdowns.add(
            AdaptiveDropdown(
              itemStrings: dayStrings,
              selectedItemString: '$range',
              onChanged: (value) => onRangeChanged(int.parse(value)),
            ),
          );
          break;
        case Span.None:
          break;
      }
      return dropdowns;
    }
  }

// 年、月、日を型で扱うために定義した列挙型
enum Span {
  Year,
  Month,
  Day,
  None,
}

extension SpanExt on Span {
  static Span initFrom(String spanString) {
    switch (spanString) {
      case '年':
        return Span.Year;
        break;
      case '月':
        return Span.Month;
        break;
      case '日':
        return Span.Day;
        break;
    }
    return Span.None;
  }

  String get stringValue {
    switch (this) {
      case Span.Year:
        return '年';
        break;
      case Span.Month:
        return '月';
        break;
      case Span.Day:
        return '日';
        break;
      case Span.None:
        return '未設定';
        break;
    }
    return "未設定";
  }
}

以上で実装の説明は終わりです。実行します。iOSとAndroidで表示が切り替えられているのがわかります。

コード全文を載せたGistはこちら

まとめ

今回は一部のパーツのみプラットフォームごとに切り替えるようにしましたが、主要なUIや遷移などをプラットフォームごとに振り分けることもできます。

Flutterの学習・開発は始めたばかりで説明やその裏にある認識に誤りがあるかもしれません。なにかお気づきの際はコメントやTwitterなどでお知らせください。