Start Debugging

Cómo reemplazar groupValue y onChanged obsoletos de Radio en Flutter con RadioGroup

Radio.groupValue y Radio.onChanged quedaron obsoletos después de Flutter 3.32 y RadioGroup llegó en 3.35. Una migración paso a paso para Radio, RadioListTile y CupertinoRadio, por qué dart fix no puede hacerla por ti, y la trampa de inferencia de tipos genéricos que deja un radio migrado deshabilitado en silencio. Verificado en Flutter 3.44.2 stable.

Si flutter analyze te dice que groupValue y onChanged están obsoletos en Radio, RadioListTile o CupertinoRadio, la solución es sacar ambas propiedades de los radios individuales y llevarlas a un único ancestro RadioGroup<T> que los envuelva. Calcula unos diez minutos por pantalla: es mecánico, pero dart fix no puede hacerlo por ti (lo comprobé, ver más abajo), y hay una trampa que no produce ningún error, solo un radio que deja de responder a los toques sin avisar. La obsolescencia llegó después de v3.32.0-0.0.pre, RadioGroup se publicó en Flutter 3.35, y las propiedades antiguas siguen presentes en stable 3.44. Todo lo que sigue está verificado contra Flutter 3.44.2 stable con Dart 3.12.

Por qué Flutter sacó el estado del grupo fuera del radio

La API antigua no tenía ningún concepto de grupo. Cada Radio comparaba de forma independiente su propio value con un groupValue que le pasabas a cada uno, lo que significaba que el framework nunca sabía qué radios pertenecían al mismo conjunto. Eso está bien para pintar un punto, y no sirve para accesibilidad.

El patrón de grupo de radios de WAI-ARIA exige que un grupo se comporte como una sola parada en el orden de tabulación, con las flechas moviendo la selección dentro de él. No puedes implementar eso sin un widget que sea dueño del conjunto. RadioGroup es ese widget, y por eso el rediseño ocurrió en lugar de una limpieza cosmética de la API.

El comportamiento que obtienes gratis después de migrar, que confirmé en un widget test sobre 3.44.2:

Hay otra ganancia menor: los radios en sí quedan más cortos. Un Radio<int> en un árbol migrado es Radio<int>(value: 0) y nada más.

Qué se rompe

ÁreaCambioSeveridad
Radio.groupValue / Radio.onChangedObsoletos; muévelos a un ancestro RadioGroup<T>alta
RadioListTile.groupValue / .onChangedMisma obsolescencia, misma soluciónalta
CupertinoRadio.groupValue / .onChangedMisma obsolescencia, misma soluciónalta
Deshabilitar un radioonChanged: null reemplazado por enabled: falsemedia
Inferencia de tipos genéricosRadioGroup<T> se busca por tipo exacto, y T se infiere distinto que en el radioalta
Orden de tabulaciónEl grupo ahora es una sola parada en vez de Nmedia
RadioListTile.selectedSigue sin coordinarse automáticamente con el estado marcadobaja
Migración automatizadaNo existe ninguna regla de dart fix; esto es una edición a manomedia

Lista de verificación previa

Pasos de la migración

  1. Envuelve el grupo en RadioGroup<T> y mueve groupValue y onChanged a él. Esta es toda la migración en una sola edición. La variable de estado y la llamada a setState no se mueven; solo las propiedades.

    Antes, en 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),
          ),
        ],
      );
    }

    Después:

    // 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),
          ],
        ),
      );
    }

    Verifica: flutter analyze sobre ese archivo baja de cuatro avisos deprecated_member_use a cero, y tocar el segundo radio sigue actualizando el estado.

  2. Escribe siempre el argumento de tipo de forma explícita tanto en el grupo como en los radios. La inferencia de tipos no te dará lo que esperas cuando el tipo del valor es anulable. Escribe RadioGroup<Flavor?> y Radio<Flavor?>, nunca un RadioGroup(...) pelado. La siguiente sección explica por qué esto importa más de lo que parece.

    Verifica: busca en el diff RadioGroup( sin <. Cada coincidencia es un error latente.

  3. Reemplaza onChanged: null por enabled: false en cualquier radio que estuvieras deshabilitando. En la API antigua, un callback nulo era la forma de atenuar una opción. RadioGroup.onChanged es required y no anulable, así que esa palanca desapareció a nivel de grupo y se movió a cada 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),
        ],
      ),
    )

    Verifica: el radio deshabilitado se dibuja en gris y su nodo de semántica tiene hasEnabledState sin isEnabled.

  4. Haz la misma edición para RadioListTile y CupertinoRadio. Aceptan el mismo ancestro RadioGroup. RadioListTile además conserva su propia propiedad enabled, resuelta como 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])),
      ),
    )

    Verifica: esto funciona con construcción diferida. En un ListView.builder de 200 elementos con solo 11 tiles realmente construidos, tocar el elemento 3 fijó el valor del grupo en 3.

  5. Separa los grupos mixtos por tipo, o anídalos. Si una columna contiene radios de dos tipos de valor distintos, envuelve el conjunto interior en su propio RadioGroup. El anidamiento funciona porque la búsqueda es por tipo y, para tipos idénticos, gana el ancestro más cercano. Confirmé que un RadioGroup<String> anidado dentro de otro RadioGroup<String> enruta los toques solo al onChanged del grupo interior.

    Verifica: toca un radio de cada subgrupo y confirma que cada callback se dispara exactamente una vez.

  6. Ejecuta el analizador y los widget tests. flutter analyze no debe reportar ningún deprecated_member_use para miembros de radio, y cualquier test que toque un radio debe seguir pasando. Los tests son donde se detecta el fallo silencioso que se describe abajo.

Verificación

Después de la migración, ejecuta estas cuatro comprobaciones antes de dar la pantalla por terminada:

Plan de reversión

Esta migración sí es reversible de verdad. Las propiedades obsoletas siguen existiendo en stable 3.44 y no están programadas para eliminarse en ninguna versión anunciada, así que un git revert del commit de migración compila y se ejecuta exactamente como antes. Aun así, haz el trabajo en una rama, porque el modo de fallo aquí es silencioso y querrás un diff limpio contra el que hacer bisect.

La trampa: un radio migrado que deja de funcionar en silencio

Esta es la parte que la guía oficial de migración no cubre, y está detrás de flutter/flutter#175705, un issue que se cerró sin diagnóstico.

Dos hechos se combinan mal.

Primero, un Radio sin ancestro RadioGroup y sin onChanged no lanza excepción. Mira cómo lo resuelve _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);

Con los tres en null, _enabled es false y el radio se dibuja como un control deshabilitado. La aserción 'Radio is enabled but has no Radio.onChange or registry above' solo se dispara si pasas enabled: true de forma explícita. Monté dos widgets Radio<Flavor> sin grupo alguno: ninguna excepción, y el nodo de semántica volvió como flags: [hasCheckedState, hasEnabledState, isInMutuallyExclusiveGroup]. Fíjate en lo que falta: isEnabled, y cualquier acción de toque.

Segundo, RadioGroup se encuentra por tipo genérico exacto:

// 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 significa que _RadioGroupStateScope<Flavor> no satisface una búsqueda de _RadioGroupStateScope<Flavor?>. La covarianza no te ayuda aquí.

Ahora junta eso con la inferencia de Dart. RadioGroup declara T? groupValue, mientras que Radio y RadioListTile declaran T value. Pásale a ambos una variable anulable y cada uno infiere un argumento de tipo distinto:

// 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?>

Esos son los tipos en tiempo de ejecución impresos por una ejecución real del test. El grupo es RadioGroup<String>; el tile es RadioListTile<String?>. El tile busca _RadioGroupStateScope<String?>, no encuentra nada, resuelve _enabled a false, y se dibuja muerto. Sin excepción, sin aviso del analizador.

La reproducción tiene exactamente la forma con la que la gente se topa al migrar una opción “System default”, donde null es una elección legítima. En un grupo donde un tile recibió Flavor? y su hermano recibió Flavor, la semántica volvió así:

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

Tocar “System” disparó el onChanged del grupo cero veces. Tocar “Vanilla” lo disparó una vez.

La solución es fijar el argumento de tipo en ambos lados:

// 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')),
    ],
  ),
)

Con RadioGroup<Flavor?> escrito de forma explícita, tocar “System” fija correctamente el valor del grupo en null. Esa es la respuesta al issue cerrado: los valores anulables no están deshabilitados por diseño, simplemente los argumentos de tipo inferidos no coincidían.

Trampas menores que conviene conocer

toggleable se quedó en el radio. No es una propiedad a nivel de grupo. Un Radio<Flavor>(value: Flavor.vanilla, toggleable: true) dentro de un RadioGroup<Flavor> sigue llamando al onChanged del grupo con null cuando tocas la opción ya seleccionada. Verificado en 3.44.2. Por lo tanto tu groupValue tiene que ser anulable si lo usas, lo cual te devuelve directo a la trampa de inferencia de arriba.

No hay deshabilitación a nivel de grupo. RadioGroup.onChanged es requerido y no anulable, así que no puedes atenuar un grupo entero anulando un callback como hacías antes. Pon enabled: false en cada radio, o recorre tus opciones y pasa un indicador.

RadioListTile.selected sigue siendo manual. El framework documenta que “no effort is made to automatically coordinate the selected state and the checked state” y te dice que pongas selected: true cuando value coincida con RadioGroup.groupValue. Migrar no cambia eso; sigues comparando a mano.

La navegación por teclado solo alcanza los radios construidos. En un ListView.builder, las flechas solo pueden moverse por los tiles que están en ese momento en el árbol de widgets. En mi prueba de 200 elementos, se construyeron 11. Para una lista larga de opciones esto es un límite real de accesibilidad, y es una buena razón para preferir una Column acotada dentro de un scroll view antes que la construcción diferida para grupos de radios. Si de todos modos necesitas la lista diferida, los patrones de listas con scroll infinito siguen aplicando.

Radio.adaptive está bien. Reenvía groupRegistry: _effectiveRegistry y enabled: _enabled hacia CupertinoRadio, así que un radio adaptativo dentro de un RadioGroup recoge el registro en iOS y macOS sin trabajo extra.

Para widgets tipo radio personalizados, implementa el registro. RadioGroupRegistry<T> es una interfaz pública pequeña (groupValue, onChanged, registerClient, unregisterClient) y RawRadio acepta un groupRegistry directamente. Ese es el camino soportado si estás construyendo un control con tema propio que debe participar en la navegación por teclado del grupo. RawRadio afirma 'an enabled raw radio must have a registry', así que conéctalo antes de habilitarlo.

La migración no es urgente, ya que las propiedades obsoletas siguen compilando en 3.44. Vale la pena hacerla igual, porque el comportamiento de accesibilidad no es algo que puedas añadir tú después, y porque cada pantalla que dejes en la API antigua es una pantalla que migrarás más tarde bajo presión de tiempo. Hazlo ahora, escribe los argumentos de tipo, y deja que el analizador te diga cuándo terminaste.

Relacionados

Fuentes

Comments

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

< Volver