FlutterでiOSスタイルの生年入力ドラムロールを実装する

# これは何？
Flutterで「誕生年」を選択するためのドラムロールを実装したので、
忘れないようにメモをします。

# 完成形

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/795935/f83e739f-51ee-45b8-5438-ef6492dcf009.png" width="250">

ユーザーが何かしらのボタンをタップした際に表示される、お馴染みのドラムロールです。

最初は「上下にスクロール」が選択されているのがミソです。

いくつか他のアプリを確認したところ、
細かく気遣っているアプリとそうでないアプリがあるようでした。

# そもそもなぜ「生年」？
多いのは生年月日だと思いますが、アプリのコア機能とは直接関係のない個人情報をユーザーに登録することを要求している場合、Apple様にリジェクトされてしまうことがあります。
[Guideline 5.1.1 - Legal - Privacy - Data Collection and Storage](https://developer.apple.com/app-store/review/guidelines/#data-collection-and-storage)

開発チームとしてはアプリのコア機能と関係していると考えていたのですが、
Apple様がそう判断するなら従うしかありません（怒）

ということで、「誕生年」のみの取得となりました。

# 完成コード

```dart
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';

typedef Fn = Function({required DateTime date});

Future<void> showCupertinoYearPicker({
  required double sheetHeight,
  required double itemHeight,
  required double textSize,
  required Fn handler,
  required BuildContext context,
}) async {
  final int currentYear = DateTime.now().year - 16; // 何歳から登録できるかの制限値　適宜修正する（この例では16歳）
  final int startYear = currentYear - 110; // 125歳まで登録できることを想定
  final int totalYears = currentYear - startYear + 1;
  const int scrollHintIndex = 17; // 「上下にスクロール」の初期位置 適宜修正する

  await showCupertinoModalPopup(
    context: context,
    builder: (context) {
      return Container(
        height: sheetHeight,
        color: Colors.white,
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.end,
          children: [
            Padding(
              padding: const EdgeInsets.only(right: 20),
              child: TextButton(
                child: Text(
                  '閉じる',
                  style: TextStyle(
                    color: Colors.blue,
                    fontSize: textSize,
                  ),
                ),
                onPressed: () => Navigator.of(context).pop(),
              ),
            ),
            Expanded(
              child: CupertinoPicker(
                diameterRatio: 1.1,
                itemExtent: 35.0,
                onSelectedItemChanged: (int index) {
                  if (index != scrollHintIndex) {
                    handler(
                      date: DateTime(
                        currentYear - index + (index > scrollHintIndex ? 1 : 0),
                      ),
                    );
                  }
                },
                children: List<Widget>.generate(totalYears, (index) {
                  if (index == scrollHintIndex) {
                    return const Text(
                      '上下にスクロール',
                      style: TextStyle(color: CupertinoColors.black),
                    );
                  }
                  return Text(
                    '${currentYear - index + (index > scrollHintIndex ? 1 : 0)}',
                    style: const TextStyle(color: CupertinoColors.black),
                  );
                }),
                scrollController: FixedExtentScrollController(
                  initialItem: scrollHintIndex,
                ),
              ),
            ),
          ],
        ),
      );
    },
  );
}

```

# 解説

[showCupertinoModalPopup](https://api.flutter.dev/flutter/cupertino/showCupertinoModalPopup.html)を主に利用しています。

### 各プロパティ
- ```diameterRatio```: ピッカーの直径の比率。
- ```itemExtent```: 各アイテムの高さ（固定値35.0）。
- ```onSelectedItemChanged```: アイテムが選択されたときのコールバック。```scrollHintIndex```(「上下にスクロール」)が選択された場合を除き、選択された年に基づいてhandler関数が呼び出されます。
- ```children```: ピッカーの中の各アイテム。年のリストと```scrollHintIndex```にはヒントテキスト(「上下にスクロール」)が表示されます。

### ハンドラー

```dart
typedef Fn = Function({required DateTime date});
```

DateTimeを引数にとる新しい関数の型を定義しています。
目的としては、ユーザーが選択した生年を、呼び出す側のViewModelへ伝えたいために、
その関数(ハンドラー）を受け取っている、といった感じになります。

```dart
required Fn handler,
```
```dart
onSelectedItemChanged: (int index) {
                  if (index != scrollHintIndex) {
                    handler(
                      date: DateTime(
                        currentYear - index + (index > scrollHintIndex ? 1 : 0),
                      ),
                    );
                  }
                },
```
後述でも記載しますが、「上下にスクロール」が選択されているときは実行されません。

handlerはViewModel層で自由にプロジェクトの目的に合わせて定義してください。
必要なければ、不要です。

### 初期選択位置

```dart
                scrollController: FixedExtentScrollController(
                  initialItem: scrollHintIndex,
                ),
```

```FixedExtentScrollController```を使用してピッカーの初期選択位置を設定しています。なお、

```dart
currentYear - index + (index > scrollHintIndex ? 1 : 0)
```
でお分かりの通り、「上下にスクロール」の初期文字分調整しなくてはいけないので、
ListのアイテムのindexがscrollHintIndexによってずれてしまう場合は調整してください。
※今回は上から下に行くほど誕生年が遡る形式で、scrollHintIndexより下の場合影響を受けるため、その分の位置を調整しています。

# 終わりに
ChatGPTに記事作成を手伝ってもらっています。
もし誤りがあった場合、ご指摘いただければ幸いです。