[Flutter]CupertinoDatePickerの日時を操作する

# [CupertinoDatePicker](https://api.flutter.dev/flutter/cupertino/CupertinoDatePicker-class.html)の日時を操作する手段は、ない

こちらのissueにも上がっているように、CupertinoDatePickerの値（日時）を外から操作して設定する手段は提供されていません。
https://github.com/flutter/flutter/issues/42869

よくあるUIだと、乗換検索アプリの出発時刻設定画面で、「現在時刻に設定する」などがあると思います。
無理矢理対応するやり方だとすると、以下のようにwidgetの再構築を促す方法があります。設定する値は、`initialDate`で設定します。

```dart
CupertinoDatePicker(
  // keyを指定することでwidgetが再利用されないようにして、再構築されるようにする
  key: UniqueKey(),
  // 更新したい値
  initialDateTime: date,
  ...
)
```

# `CupertinoDatePicker`をベースにTimePickerを作る

完成したものはこちらです。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/202533/6d3ca176-e762-500e-3fc5-0fbf4c38c807.png)

[CupertinoDatePicker](https://api.flutter.dev/flutter/cupertino/CupertinoDatePicker-class.html)をベースに、必要最低限の機能を切り出しています。
今回は単純にするため、AM/PMの表示設定や、日付の設定、l10nなどは全て排除しています。
また、個人的な好みで時と分の間にコロン【：】を配置しています。

外から時刻を設定するための手段は、後述の`TimePickerController`経由で操作できるようにしています。

## 実装方法

### Widget本体

中で出てくるマジックナンバーは、ほとんどが大元のCupertinoDatePickerから拝借してきたものです。必要に応じて変更してください。

```dart:time_picker.dart
// 時・分の表示カラムの幅です
const double _kColumnWidths = 48.0;

class TimePicker extends StatefulWidget {
  // 後述の[TimePickerController]が必須パラメータです。[TimePickerController]経由で日時の操作・取得を行います。
  const TimePicker({Key key, @required this.controller}) : super(key: key);

  final TimePickerController controller;

  @override
  _TimePickerState createState() => _TimePickerState();
}

class _TimePickerState extends State<TimePicker> {
  @override
  Widget build(BuildContext context) {
    return Row(
      mainAxisSize: MainAxisSize.min,
      children: [
        _buildHourPicker(),
        // 時、分の間のコロンです。私の好みで追加しました。
        _buildSeparator(),
        _buildMinutePicker(),
      ],
    );
  }

  /// 「時」選択Picker
  Widget _buildHourPicker() {
    return _buildPicker(24, widget.controller._hourController);
  }

  /// 「分」選択Picker
  Widget _buildMinutePicker() {
    return _buildPicker(60, widget.controller._minuteController);
  }

  /// Picker生成
  /// [size]の分のPickerを作ります。操作できるように[FixedExtentScrollController]を渡してください
  Widget _buildPicker(int size, FixedExtentScrollController controller) {
    return SizedBox(
      width: _kColumnWidths,
      // 数値は[CupertinoDatePicker]を参考にしています
      child: CupertinoPicker(
        backgroundColor: Colors.white,
        scrollController: controller,
        offAxisFraction: 0.45,
        itemExtent: 32,
        useMagnifier: true,
        magnification: 2.35 / 2.1,
        squeeze: 1.25,
        looping: true,
        onSelectedItemChanged: (_) {},
        children: List.generate(size, (int index) {
          return Center(
            child: Padding(
              padding: const EdgeInsets.symmetric(horizontal: 8),
              child: SizedBox(
                  width: _kColumnWidths, child: _buildLabel(context, index)),
            ),
          );
        }),
      ),
    );
  }

  /// Pickerの選択肢となるテキストラベルを生成します
  Widget _buildLabel(BuildContext context, int number) {
    return Text(
      number.toString().padLeft(2, '0'),
      style: CupertinoTheme.of(context).textTheme.dateTimePickerTextStyle,
    );
  }

  Widget _buildSeparator() {
    return const Text(' : ',
        style: TextStyle(fontSize: 20.0, fontWeight: FontWeight.w500));
  }

  @override
  void dispose() {
    widget.controller._dispose();
    super.dispose();
  }
}
```

[CupertinoPicker](https://api.flutter.dev/flutter/cupertino/CupertinoPicker-class.html)がPicker単体で、`CupertinoDatePicker`は`CupertinoPicker`を複数組み合わせたものになっています。なので、今回は`CupertinoPicker`を二つだけ持てば良いため、かなりシンプルになります。

`CupertinoDatePicker`のコードを見るととても複雑に見えてしまいますが、それはAM/PMの表示切替や日付の表示有無など、条件分岐が多くなってしまっているためです。なので、用途・機能を限定すれば、レイアウトの組み方自体は難しくありません。

また、`CupertinoPicker`の操作自体は、実は[FixedExtentScrollController](https://api.flutter.dev/flutter/widgets/FixedExtentScrollController-class.html)で元々可能になっています。
`CupertinoDatePicker`で操作できないのは、この`FixedExtentScrollController`が内部で自動生成され、隠蔽化されているためです。
なので、この自作Widgetの本質は【`FixedExtentScrollController`を外からどう操作できるようにするのか】という点となります。

### 操作用Controkker

上述の`FixedExtentScrollController`はここでも内部で自動生成し、意識はさせないようにしていますが、この`FixedExtentScrollController`へのアクセサとして`setTime`を用意しました。これで自由に時刻を操作することが可能になります。

```dart:time_picker.dart
// [TimePicker]と同じファイルに記述します

class TimePickerController {
  TimePickerController()
      : _hourController = FixedExtentScrollController(initialItem: 0),
        _minuteController = FixedExtentScrollController(initialItem: 0);

  final FixedExtentScrollController _hourController;
  final FixedExtentScrollController _minuteController;

  /// 時刻の設定。これが今回一番実現したかったこと。
  void setTime(DateTime time) {
    _hourController.animateToItem(time.hour,
        duration: const Duration(milliseconds: 100), curve: Curves.easeIn);
    _minuteController.animateToItem(time.minute,
        duration: const Duration(milliseconds: 100), curve: Curves.easeIn);
  }

  int get hour {
    if (!_hourController.hasClients) {
      return 0;
    }
    // 何周もすると、マイナスにも24以上の値にもなってしまうため、あまりを求める
    return _hourController.selectedItem % 24;
  }

  int get minute {
    if (!_minuteController.hasClients) {
      return 0;
    }
    return _minuteController.selectedItem % 60;
  }

  /// [TimePicker]内で自動で開放します
  void _dispose() {
    _hourController.dispose();
    _minuteController.dispose();
  }
}
```

初期値は`00:00`にしてしまいましたが、コンストラクタで指定できた方が便利かもしれません。

## 使い方

```dart
class Sample extends StatelessWidget {
  // 操作用に[TimePickerController]を保持します。引数は不要です。disposeも自動で行うため、不要です。
  final _controller = TimePickerController();

  @override
  Widget build(BuildContext context) {
    return Column(
      children: [
        TimePicker(controller: _controller),

        // 現在時刻に戻すボタン
        IconButton(
          icon: const Icon(Icons.access_time),
          onPressed: resetTime,
        )
      ],
    );
  }

  void resetTime() {
    // [TimePickerController]に対して`DateTime`を設定するだけです。
    _controller.setTime(DateTime.now());
  }

  void printTime() {
    // 値の取得も[TimePickerController]経由で`hour`/`minute`でアクセスできます。
    print('time = ${_controller.hour} : ${_controller.minute}');
  }
}
```