Start Debugging

Flutter で非推奨になった Radio の groupValue と onChanged を RadioGroup に置き換える方法

Radio.groupValue と Radio.onChanged は Flutter 3.32 以降で非推奨になり、RadioGroup は 3.35 で導入されました。Radio、RadioListTile、CupertinoRadio の段階的な移行手順、dart fix が代わりにやってくれない理由、そして移行した radio が何も告げずに無効化されるジェネリック型推論の落とし穴を解説します。Flutter 3.44.2 stable で検証しました。

flutter analyzeRadioRadioListTileCupertinoRadiogroupValueonChanged は非推奨だと知らせてくる場合、対処法は両方のプロパティを個々の radio から取り出し、それらを囲む単一の RadioGroup<T> 祖先へ移すことです。画面あたり 10 分ほどを見込んでください。作業自体は機械的ですが、dart fix は代わりにやってくれませんし (実際に確認しました。後述します)、エラーをまったく出さずに radio がタップに反応しなくなるだけ、という落とし穴が 1 つあります。非推奨化は v3.32.0-0.0.pre 以降に行われ、RadioGroup は Flutter 3.35 で出荷され、古いプロパティは stable 3.44 にもまだ存在しています。本稿の内容はすべて Flutter 3.44.2 stable と Dart 3.12 で検証済みです。

Flutter がグループの状態を radio の外へ出した理由

旧 API にはグループという概念がありませんでした。各 Radio は自分の value と、あなたが 1 つずつ渡した groupValue を独立に比較していたため、フレームワーク自身はどの radio が同じグループに属するのかを一度も知りませんでした。点を描くだけならそれで十分ですが、アクセシビリティには役立ちません。

WAI-ARIA のラジオグループパターンは、グループがタブ順序上の 1 つの停止点として振る舞い、矢印キーがグループ内で選択を移動することを要求します。これは集合を所有するウィジェットなしには実装できません。RadioGroup がそのウィジェットであり、見た目だけの API 整理ではなく再設計が行われた理由もそこにあります。

移行後に自動的に得られる挙動です。3.44.2 上のウィジェットテストで確認しました。

もう 1 つ小さな利点もあります。radio 自体が短くなります。移行後のツリーでの Radio<int>Radio<int>(value: 0) だけです。

何が壊れるか

領域変更点深刻度
Radio.groupValue / Radio.onChanged非推奨。RadioGroup<T> 祖先へ移動する
RadioListTile.groupValue / .onChanged同じ非推奨化、同じ対処
CupertinoRadio.groupValue / .onChanged同じ非推奨化、同じ対処
個別の radio を無効化する方法onChanged: null から enabled: false に変更
ジェネリック型推論RadioGroup<T> は厳密な型で照合され、T は radio 側と異なる形で推論される
タブ順序グループは N 個ではなく 1 つの停止点になる
RadioListTile.selectedチェック状態と自動的には連動しないまま
自動移行dart fix のルールは存在せず、手作業での編集になる

事前チェックリスト

移行手順

  1. グループを RadioGroup<T> で包み、groupValueonChanged をそちらへ移します。 これが 1 回の編集で完結する移行のすべてです。状態変数と setState の呼び出しは移動しません。動くのはプロパティだけです。

    Flutter 3.44 での変更前:

    // Flutter 3.44, Dart 3.12 - deprecated API
    Widget build(BuildContext context) {
      return Column(
        children: <Widget>[
          Radio<Flavor>(
            value: Flavor.vanilla,
            groupValue: _flavor,
            onChanged: (Flavor? v) => setState(() => _flavor = v),
          ),
          Radio<Flavor>(
            value: Flavor.chocolate,
            groupValue: _flavor,
            onChanged: (Flavor? v) => setState(() => _flavor = v),
          ),
        ],
      );
    }

    変更後:

    // Flutter 3.44, Dart 3.12 - RadioGroup API
    Widget build(BuildContext context) {
      return RadioGroup<Flavor>(
        groupValue: _flavor,
        onChanged: (Flavor? v) => setState(() => _flavor = v),
        child: const Column(
          children: <Widget>[
            Radio<Flavor>(value: Flavor.vanilla),
            Radio<Flavor>(value: Flavor.chocolate),
          ],
        ),
      );
    }

    確認方法: そのファイルに対する flutter analyzedeprecated_member_use が 4 件からゼロに減り、2 番目の radio をタップすれば引き続き状態が更新されます。

  2. グループと radio の両方で、型引数を必ず明示的に書きます。 値の型が null 許容の場合、型推論は期待どおりの結果を返しません。RadioGroup<Flavor?>Radio<Flavor?> と書き、型引数なしの RadioGroup(...) は決して使わないでください。これが見た目以上に重要な理由は次の節で説明します。

    確認方法: diff から < を伴わない RadioGroup( を検索します。ヒットした箇所はすべて潜在的なバグです。

  3. 無効化していた radio では onChanged: nullenabled: false に置き換えます。 旧 API では null のコールバックが 1 つの選択肢をグレーアウトする手段でした。RadioGroup.onChangedrequired かつ null 非許容なので、そのレバーはグループ側から消え、各 radio へ移りました。

    // Flutter 3.44 - one disabled option inside an otherwise live group
    RadioGroup<int>(
      groupValue: _value,
      onChanged: (int? v) => setState(() => _value = v),
      child: const Column(
        children: <Widget>[
          Radio<int>(value: 0),
          Radio<int>(value: 2, enabled: false),
        ],
      ),
    )

    確認方法: 無効化した radio がグレーで描画され、そのセマンティクスノードが isEnabled を持たずに hasEnabledState を持ちます。

  4. RadioListTileCupertinoRadio にも同じ編集を行います。 どちらも同じ RadioGroup 祖先を受け取ります。RadioListTile は独自の enabled プロパティも保持しており、widget.enabled ?? (widget.onChanged != null || registry != null) として解決されます。

    // Flutter 3.44 - RadioListTile inside a lazy list
    RadioGroup<int>(
      groupValue: _value,
      onChanged: (int? v) => setState(() => _value = v),
      child: ListView.builder(
        itemCount: options.length,
        itemBuilder: (BuildContext context, int i) =>
            RadioListTile<int>(value: i, title: Text(options[i])),
      ),
    )

    確認方法: これは遅延ビルドでも動作します。200 件の ListView.builder で実際に構築されたタイルが 11 件だけの状態でも、項目 3 をタップするとグループの値が 3 になりました。

  5. 型が混在するグループは型ごとに分けるか、入れ子にします。 1 つのカラムに 2 種類の値型の radio が入っている場合は、内側の集合を独自の RadioGroup で包みます。入れ子が機能するのは検索が型で行われるためで、型が同一の場合は最も近い祖先が優先されます。RadioGroup<String> を別の RadioGroup<String> の中に入れ子にすると、タップは内側のグループの onChanged にのみ届くことを確認しました。

    確認方法: 各サブグループから radio を 1 つずつタップし、それぞれのコールバックがちょうど 1 回発火することを確認します。

  6. アナライザーとウィジェットテストを実行します。 flutter analyze は radio のメンバーについて deprecated_member_use を 1 件も報告してはならず、radio をタップするテストは引き続き通る必要があります。後述する無言の失敗が捕まるのはテストです。

検証

移行後、画面を完了とみなす前に次の 4 つを確認してください。

ロールバック計画

この移行は本当に元に戻せます。非推奨のプロパティは stable 3.44 にまだ存在し、公表済みのどのリリースでも削除予定に入っていないため、移行コミットを git revert すれば以前とまったく同じようにコンパイルされ動作します。それでも作業はブランチ上で行ってください。ここでの失敗モードは無言であり、bisect に使えるきれいな diff が欲しくなるからです。

落とし穴: 移行後に無言で動かなくなる radio

これは公式の移行ガイドが扱っていない部分であり、診断されないまま閉じられた issue flutter/flutter#175705 の背後にあるものです。

2 つの事実が悪い形で噛み合います。

第 1 に、RadioGroup 祖先も onChanged も持たない Radio は例外を投げません。_RadioState がどう解決しているかを見てください。

// packages/flutter/lib/src/material/radio.dart, Flutter 3.44 stable
bool get _enabled =>
    widget.enabled ??
    (widget.onChanged != null ||
        widget.groupRegistry != null ||
        RadioGroup.maybeOf<T>(context) != null);

3 つとも null であれば _enabledfalse になり、radio は無効なコントロールとして描画されます。アサーション 'Radio is enabled but has no Radio.onChange or registry above' が発火するのは、enabled: true を明示的に渡した場合だけです。グループをまったく持たない Radio<Flavor> を 2 つ描画してみたところ、例外はなく、セマンティクスノードは flags: [hasCheckedState, hasEnabledState, isInMutuallyExclusiveGroup] として返ってきました。欠けているものに注目してください。isEnabled と、タップアクションです。

第 2 に、RadioGroup は厳密なジェネリック型で検索されます。

// packages/flutter/lib/src/widgets/radio_group.dart, Flutter 3.44 stable
static RadioGroupRegistry<T>? maybeOf<T>(BuildContext context) {
  return context.dependOnInheritedWidgetOfExactType<_RadioGroupStateScope<T>>()?.state;
}

dependOnInheritedWidgetOfExactType である以上、_RadioGroupStateScope<Flavor>_RadioGroupStateScope<Flavor?> の検索を満たしません。ここでは共変性は助けになりません。

これを Dart の型推論と組み合わせてみます。RadioGroupT? groupValue を宣言し、RadioRadioListTileT value を宣言しています。両方に null 許容の変数を渡すと、推論される型引数が食い違います。

// Flutter 3.44, Dart 3.12
String? selected;
final group = RadioGroup(groupValue: selected, onChanged: (v) {}, child: const SizedBox());
final tile = RadioListTile(value: selected, title: const Text('x'));
// group.runtimeType -> RadioGroup<String>
// tile.runtimeType  -> RadioListTile<String?>

これは実際のテスト実行で出力された実行時型です。グループは RadioGroup<String>、タイルは RadioListTile<String?> です。タイルは _RadioGroupStateScope<String?> を探して何も見つけられず、_enabledfalse に解決し、死んだ状態で描画されます。例外もなく、アナライザーの警告もありません。

この再現例は、null が正当な選択肢である “System default” のオプションを移行するときに人々が直面する形そのものです。片方のタイルが Flavor? を、隣のタイルが Flavor を受け取ったグループでは、セマンティクスは次のようになりました。

System  -> flags: [hasEnabledState, hasSelectedState]
Vanilla -> actions: [focus, tap], flags: [hasEnabledState, isEnabled, isFocusable, hasSelectedState]

“System” をタップしてもグループの onChanged は 0 回しか発火しませんでした。“Vanilla” をタップすると 1 回発火しました。

対処法は、両側で型引数を固定することです。

// Flutter 3.44 - explicit nullable type argument on group and tiles
RadioGroup<Flavor?>(
  groupValue: _flavor,
  onChanged: (Flavor? v) => setState(() => _flavor = v),
  child: const Column(
    children: <Widget>[
      RadioListTile<Flavor?>(value: null, title: Text('System')),
      RadioListTile<Flavor?>(value: Flavor.vanilla, title: Text('Vanilla')),
    ],
  ),
)

RadioGroup<Flavor?> と書き出しておけば、“System” のタップでグループの値が正しく null になります。これが閉じられた issue への答えです。null 許容の値が設計上無効化されているのではなく、推論された型引数が単に一致していなかっただけです。

知っておくべき細かい落とし穴

toggleable は radio 側に残りました。 グループ単位のプロパティではありません。RadioGroup<Flavor> の中の Radio<Flavor>(value: Flavor.vanilla, toggleable: true) は、すでに選択済みの選択肢をタップしたときに、引き続きグループの onChangednull で呼びます。3.44.2 で検証済みです。したがってこれを使うなら groupValue は null 許容でなければならず、話は上記の推論の落とし穴へ直結します。

グループ単位の無効化はありません。 RadioGroup.onChanged は必須かつ null 非許容なので、以前のようにコールバックを null にしてグループ全体をグレーアウトすることはできません。各 radio に enabled: false を設定するか、選択肢を走査してフラグを渡してください。

RadioListTile.selected は依然として手動です。 フレームワークは “no effort is made to automatically coordinate the selected state and the checked state” と明記し、valueRadioGroup.groupValue と一致するときに selected: true を設定するよう指示しています。移行してもこれは変わりません。比較は引き続き自分で行います。

キーボードナビゲーションは構築済みの radio にしか届きません。 ListView.builder では、矢印キーはその時点でウィジェットツリーにあるタイルの中しか移動できません。200 件の調査では 11 件が構築されていました。長い選択肢リストにとってこれは実際のアクセシビリティ上の制約であり、radio グループでは遅延ビルドよりも scroll view 内の範囲が確定した Column を選ぶ十分な理由になります。それでも遅延リストが必要なら、無限スクロールのリストのパターンはそのまま適用できます。

Radio.adaptive は問題ありません。 groupRegistry: _effectiveRegistryenabled: _enabledCupertinoRadio へ渡すので、RadioGroup 内の adaptive な radio は iOS と macOS でも追加作業なしにレジストリを拾います。

独自の radio 風ウィジェットにはレジストリを実装します。 RadioGroupRegistry<T> は小さな公開インターフェース (groupValueonChangedregisterClientunregisterClient) であり、RawRadiogroupRegistry を直接受け取ります。グループのキーボードナビゲーションに参加させたい独自テーマのコントロールを作る場合は、これがサポートされた経路です。RawRadio'an enabled raw radio must have a registry' をアサートするので、有効化する前に配線してください。

非推奨のプロパティは 3.44 でもコンパイルできるため、この移行は急ぎではありません。それでもやる価値はあります。アクセシビリティの挙動は自分で後付けできるものではありませんし、旧 API のまま残した画面は、いずれ時間に追われながら移行することになる画面だからです。今のうちに済ませ、型引数を書き出し、完了かどうかはアナライザーに教えてもらいましょう。

関連記事

参考資料

Comments

Sign in with GitHub to comment. Reactions and replies thread back to the comments repo.

< 戻る