Start Debugging

Как заменить устаревшие groupValue и onChanged у Radio во Flutter на RadioGroup

Radio.groupValue и Radio.onChanged объявлены устаревшими после Flutter 3.32, а RadioGroup появился в 3.35. Пошаговая миграция для Radio, RadioListTile и CupertinoRadio, почему dart fix не сделает её за вас, и ловушка вывода обобщённых типов, из-за которой мигрированный radio молча остаётся отключённым. Проверено на Flutter 3.44.2 stable.

Если flutter analyze сообщает, что groupValue и onChanged устарели у Radio, RadioListTile или CupertinoRadio, решение состоит в том, чтобы вынести обе эти свойства из отдельных radio и поднять их в один предок RadioGroup<T>, который их оборачивает. Закладывайте примерно десять минут на экран: работа механическая, но dart fix не сделает её за вас (я проверил, см. ниже), и есть одна ловушка, которая не выдаёт вообще никакой ошибки, а просто оставляет radio, переставший реагировать на нажатия. Пометка устаревшими появилась после 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 с groupValue, который вы передавали каждому по отдельности, а значит сам фреймворк никогда не знал, какие radio относятся к одной группе. Для отрисовки точки этого достаточно, а для доступности бесполезно.

Паттерн группы радиокнопок WAI-ARIA требует, чтобы группа вела себя как одна остановка в порядке табуляции, а стрелки перемещали выбор внутри неё. Реализовать это без widget, который владеет всем набором, невозможно. RadioGroup и есть такой widget, и именно поэтому был сделан редизайн, а не косметическая чистка API.

Поведение, которое вы получаете бесплатно после миграции, подтверждено мной в widget-тесте на 3.44.2:

Есть и более мелкий выигрыш: сами radio становятся короче. Radio<int> в мигрированном дереве это Radio<int>(value: 0) и больше ничего.

Что ломается

ОбластьИзменениеСерьёзность
Radio.groupValue / Radio.onChangedУстарели; переносятся в предок RadioGroup<T>высокая
RadioListTile.groupValue / .onChangedТа же пометка, то же решениевысокая
CupertinoRadio.groupValue / .onChangedТа же пометка, то же решениевысокая
Отключение одного radioonChanged: null заменён на enabled: falseсредняя
Вывод обобщённых типовRadioGroup<T> ищется по точному типу, и T выводится иначе, чем у radioвысокая
Порядок табуляцииГруппа теперь одна остановка вместо Nсредняя
RadioListTile.selectedПо-прежнему не согласуется автоматически с отмеченным состояниемнизкая
Автоматическая миграцияПравила dart fix не существует; это ручная правкасредняя

Предварительная проверка

Шаги миграции

  1. Оберните группу в RadioGroup<T> и перенесите туда groupValue и onChanged. Это вся миграция в одной правке. Переменная состояния и вызов 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 analyze по этому файлу падает с четырёх сообщений deprecated_member_use до нуля, а нажатие на второй radio по-прежнему обновляет состояние.

  2. Всегда пишите аргумент типа явно и у группы, и у radio. Вывод типов не даст ожидаемого результата, когда тип значения допускает null. Пишите RadioGroup<Flavor?> и Radio<Flavor?>, никогда не голый RadioGroup(...). Следующий раздел объясняет, почему это важнее, чем кажется.

    Проверка: поищите в diff RadioGroup( без <. Каждое совпадение это скрытая ошибка.

  3. Замените onChanged: null на enabled: false у каждого radio, который вы отключали. В старом API null-колбэк был способом сделать один вариант неактивным. RadioGroup.onChanged объявлен required и не допускает 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 отрисовывается серым, а его узел семантики содержит hasEnabledState без isEnabled.

  4. Сделайте ту же правку для RadioListTile и CupertinoRadio. Они принимают тот же предок 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])),
      ),
    )

    Проверка: это работает и с ленивым построением. В ListView.builder на 200 элементов, где реально построено было только 11 плиток, нажатие на элемент 3 установило значение группы в 3.

  5. Разделяйте смешанные группы по типу или вкладывайте их. Если в одной колонке лежат radio с двумя разными типами значений, оберните внутренний набор в собственный RadioGroup. Вложенность работает, потому что поиск идёт по типу, а при совпадающих типах побеждает ближайший предок. Я подтвердил, что RadioGroup<String>, вложенный в другой RadioGroup<String>, направляет нажатия только в onChanged внутренней группы.

    Проверка: нажмите по одному radio из каждой подгруппы и убедитесь, что каждый колбэк сработал ровно один раз.

  6. Запустите анализатор и widget-тесты. flutter analyze не должен выдавать ни одного deprecated_member_use для членов radio, а любой тест, нажимающий на radio, должен продолжать проходить. Именно тесты ловят описанный ниже молчаливый сбой.

Проверка результата

После миграции выполните эти четыре проверки, прежде чем считать экран готовым:

План отката

Эта миграция действительно обратима. Устаревшие свойства всё ещё существуют в stable 3.44 и не назначены к удалению ни в одной анонсированной версии, так что git revert коммита с миграцией компилируется и работает ровно как раньше. Всё равно делайте работу в отдельной ветке, потому что режим отказа здесь молчаливый и вам понадобится чистый diff для bisect.

Ловушка: мигрированный radio, который молча перестаёт работать

Это та часть, которую официальное руководство по миграции не покрывает, и она стоит за flutter/flutter#175705, issue, закрытым без диагноза.

Два факта складываются неудачно.

Во-первых, Radio без предка RadioGroup и без onChanged не бросает исключение. Посмотрите, как это вычисляет _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);

Когда все три равны null, _enabled становится false, и radio отрисовывается как отключённый элемент управления. Утверждение 'Radio is enabled but has no Radio.onChange or registry above' срабатывает только если вы явно передали enabled: true. Я отрисовал два widget Radio<Flavor> вообще без группы: ни одного исключения, а узел семантики вернулся как flags: [hasCheckedState, hasEnabledState, isInMutuallyExclusiveGroup]. Обратите внимание, чего не хватает: isEnabled и любого действия нажатия.

Во-вторых, 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. RadioGroup объявляет T? groupValue, тогда как Radio и RadioListTile объявляют T 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?>, ничего не находит, вычисляет _enabled как false и отрисовывается мёртвой. Ни исключения, ни предупреждения анализатора.

Воспроизведение имеет ровно ту форму, с которой сталкиваются при миграции варианта “System default”, где null это законный выбор. В группе, где одна плитка получила Flavor?, а её сосед Flavor, семантика вернулась такой:

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

Нажатие на “System” вызвало onChanged группы ноль раз. Нажатие на “Vanilla” вызвало его один раз.

Решение состоит в том, чтобы зафиксировать аргумент типа с обеих сторон:

// 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. Это не свойство уровня группы. Radio<Flavor>(value: Flavor.vanilla, toggleable: true) внутри RadioGroup<Flavor> по-прежнему вызывает onChanged группы со значением null, когда вы нажимаете на уже выбранный вариант. Проверено на 3.44.2. Значит, ваш groupValue обязан допускать null, если вы этим пользуетесь, что возвращает вас прямо к описанной выше ловушке вывода типов.

Отключения на уровне группы нет. RadioGroup.onChanged обязателен и не допускает null, так что сделать всю группу неактивной, обнулив один колбэк, как раньше, не получится. Ставьте enabled: false на каждый radio или пройдитесь по списку вариантов и передайте флаг.

RadioListTile.selected по-прежнему ручной. Фреймворк документирует, что “no effort is made to automatically coordinate the selected state and the checked state”, и предписывает ставить selected: true, когда value совпадает с RadioGroup.groupValue. Миграция этого не меняет; сравнивать по-прежнему приходится вручную.

Навигация с клавиатуры доходит только до построенных radio. В ListView.builder стрелки могут перемещаться только по тем плиткам, которые в данный момент есть в дереве widget. В моей пробе на 200 элементов построено было 11. Для длинного списка вариантов это реальное ограничение доступности и хороший повод предпочесть ограниченную Column внутри scroll view ленивому построению для групп radio. Если ленивый список всё же нужен, паттерны списков с бесконечной прокруткой остаются в силе.

Radio.adaptive в порядке. Он передаёт groupRegistry: _effectiveRegistry и enabled: _enabled вниз в CupertinoRadio, так что адаптивный radio внутри RadioGroup подхватывает реестр на iOS и macOS без дополнительной работы.

Для собственных radio-подобных widget реализуйте реестр. RadioGroupRegistry<T> это небольшой публичный интерфейс (groupValue, onChanged, registerClient, unregisterClient), а RawRadio принимает groupRegistry напрямую. Это поддерживаемый путь, если вы строите собственный оформленный элемент управления, который должен участвовать в клавиатурной навигации группы. 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.

< Назад