Start Debugging

Como substituir groupValue e onChanged obsoletos do Radio no Flutter por RadioGroup

Radio.groupValue e Radio.onChanged ficaram obsoletos depois do Flutter 3.32 e o RadioGroup chegou no 3.35. Uma migração passo a passo para Radio, RadioListTile e CupertinoRadio, por que o dart fix não faz isso por você, e a armadilha de inferência de tipos genéricos que deixa um radio migrado desabilitado silenciosamente. Verificado no Flutter 3.44.2 stable.

Se o flutter analyze está avisando que groupValue e onChanged estão obsoletos em Radio, RadioListTile ou CupertinoRadio, a correção é tirar as duas propriedades dos radios individuais e colocá-las em um único ancestral RadioGroup<T> que os envolve. Reserve uns dez minutos por tela: é mecânico, mas o dart fix não faz isso por você (eu verifiquei, veja abaixo), e existe uma armadilha que não produz erro nenhum, apenas um radio que silenciosamente para de responder aos toques. A obsolescência chegou depois do v3.32.0-0.0.pre, o RadioGroup foi lançado no Flutter 3.35, e as propriedades antigas ainda estão presentes no stable 3.44. Tudo aqui foi verificado contra o Flutter 3.44.2 stable com Dart 3.12.

Por que o Flutter tirou o estado do grupo de dentro do radio

A API antiga não tinha nenhum conceito de grupo. Cada Radio comparava de forma independente o próprio value com um groupValue que você passava para cada um, o que significava que o próprio framework nunca sabia quais radios pertenciam ao mesmo conjunto. Isso serve para desenhar um ponto, e é inútil para acessibilidade.

O padrão de grupo de radios do WAI-ARIA exige que um grupo se comporte como uma única parada na ordem de tabulação, com as setas movendo a seleção dentro dele. Você não consegue implementar isso sem um widget que seja dono do conjunto. RadioGroup é esse widget, e é por isso que o redesenho aconteceu em vez de uma limpeza cosmética da API.

O comportamento que você ganha de graça depois de migrar, que confirmei em um widget test no 3.44.2:

Há ainda um ganho menor: os próprios radios ficam mais curtos. Um Radio<int> em uma árvore migrada é Radio<int>(value: 0) e nada mais.

O que quebra

ÁreaMudançaSeveridade
Radio.groupValue / Radio.onChangedObsoletos; mova para um ancestral RadioGroup<T>alta
RadioListTile.groupValue / .onChangedMesma obsolescência, mesma correçãoalta
CupertinoRadio.groupValue / .onChangedMesma obsolescência, mesma correçãoalta
Desabilitar um radioonChanged: null substituído por enabled: falsemédia
Inferência de tipos genéricosRadioGroup<T> é encontrado por tipo exato, e T é inferido de forma diferente do radioalta
Ordem de tabulaçãoO grupo agora é uma única parada em vez de Nmédia
RadioListTile.selectedContinua sem coordenar automaticamente com o estado marcadobaixa
Migração automatizadaNão existe regra de dart fix; isso é edição manualmédia

Checklist de pré-voo

Passos da migração

  1. Envolva o grupo em RadioGroup<T> e mova groupValue e onChanged para ele. Essa é a migração inteira em uma única edição. A variável de estado e a chamada de setState não mudam de lugar; só as propriedades mudam.

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

    Depois:

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

    Verifique: o flutter analyze naquele arquivo cai de quatro avisos deprecated_member_use para zero, e tocar no segundo radio continua atualizando o estado.

  2. Sempre escreva o argumento de tipo explicitamente no grupo e nos radios. A inferência de tipos não vai te dar o que você espera quando o tipo do valor é anulável. Escreva RadioGroup<Flavor?> e Radio<Flavor?>, nunca um RadioGroup(...) sem tipo. A próxima seção explica por que isso importa mais do que parece.

    Verifique: procure no diff por RadioGroup( sem <. Cada ocorrência é um bug latente.

  3. Substitua onChanged: null por enabled: false em qualquer radio que você desabilitava. Na API antiga, um callback nulo era o jeito de esmaecer uma opção. RadioGroup.onChanged é required e não anulável, então essa alavanca sumiu no nível do grupo e foi para 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),
        ],
      ),
    )

    Verifique: o radio desabilitado é desenhado em cinza e seu nó de semântica tem hasEnabledState sem isEnabled.

  4. Faça a mesma edição para RadioListTile e CupertinoRadio. Eles aceitam o mesmo ancestral RadioGroup. O RadioListTile também mantém a própria propriedade enabled, resolvida 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])),
      ),
    )

    Verifique: isso funciona com construção preguiçosa. Em um ListView.builder de 200 itens com apenas 11 tiles realmente construídos, tocar no item 3 definiu o valor do grupo como 3.

  5. Separe grupos mistos por tipo, ou aninhe-os. Se uma coluna contém radios de dois tipos de valor diferentes, envolva o conjunto interno no próprio RadioGroup. O aninhamento funciona porque a busca é por tipo e, para tipos idênticos, o ancestral mais próximo vence. Confirmei que um RadioGroup<String> aninhado dentro de outro RadioGroup<String> roteia os toques apenas para o onChanged do grupo interno.

    Verifique: toque em um radio de cada subgrupo e confirme que cada callback dispara exatamente uma vez.

  6. Rode o analisador e os widget tests. O flutter analyze não pode reportar nenhum deprecated_member_use para membros de radio, e qualquer teste que toque em um radio precisa continuar passando. Os testes são onde a falha silenciosa descrita abaixo é pega.

Verificação

Depois da migração, rode estas quatro checagens antes de considerar a tela pronta:

Plano de rollback

Essa migração é genuinamente reversível. As propriedades obsoletas ainda existem no stable 3.44 e não estão marcadas para remoção em nenhuma versão anunciada, então um git revert do commit de migração compila e roda exatamente como antes. Mesmo assim, faça o trabalho em uma branch, porque o modo de falha aqui é silencioso e você vai querer um diff limpo para fazer bisect.

A armadilha: um radio migrado que para de funcionar em silêncio

Esta é a parte que o guia oficial de migração não cobre, e está por trás da flutter/flutter#175705, uma issue que foi fechada sem diagnóstico.

Dois fatos se combinam mal.

Primeiro, um Radio sem ancestral RadioGroup e sem onChanged não lança exceção. Veja como o _RadioState resolve isso:

// 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);

Com os três em null, _enabled é false e o radio é desenhado como um controle desabilitado. A asserção 'Radio is enabled but has no Radio.onChange or registry above' só dispara se você passar enabled: true explicitamente. Montei dois widgets Radio<Flavor> sem grupo nenhum: nenhuma exceção, e o nó de semântica voltou como flags: [hasCheckedState, hasEnabledState, isInMutuallyExclusiveGroup]. Repare no que falta: isEnabled, e qualquer ação de toque.

Segundo, o RadioGroup é encontrado por tipo genérico exato:

// 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> não satisfaz uma busca por _RadioGroupStateScope<Flavor?>. Covariância não ajuda aqui.

Agora junte isso com a inferência do Dart. O RadioGroup declara T? groupValue, enquanto Radio e RadioListTile declaram T value. Passe uma variável anulável para os dois e eles inferem argumentos de tipo diferentes:

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

Esses são os tipos em tempo de execução impressos por uma execução real do teste. O grupo é RadioGroup<String>; o tile é RadioListTile<String?>. O tile procura por _RadioGroupStateScope<String?>, não acha nada, resolve _enabled para false, e é desenhado morto. Sem exceção, sem aviso do analisador.

A reprodução tem exatamente o formato que as pessoas encontram ao migrar uma opção “System default”, onde null é uma escolha legítima. Em um grupo em que um tile recebeu Flavor? e o irmão recebeu Flavor, a semântica voltou assim:

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

Tocar em “System” disparou o onChanged do grupo zero vezes. Tocar em “Vanilla” disparou uma vez.

A correção é fixar o argumento de tipo dos dois 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')),
    ],
  ),
)

Com RadioGroup<Flavor?> escrito por extenso, tocar em “System” define corretamente o valor do grupo como null. Essa é a resposta para a issue fechada: valores anuláveis não são desabilitados por design, os argumentos de tipo inferidos é que simplesmente não batiam.

Armadilhas menores que vale conhecer

toggleable continuou no radio. Não é uma propriedade de nível de grupo. Um Radio<Flavor>(value: Flavor.vanilla, toggleable: true) dentro de um RadioGroup<Flavor> ainda chama o onChanged do grupo com null quando você toca na opção já selecionada. Verificado no 3.44.2. Portanto seu groupValue precisa ser anulável se você usar isso, o que te devolve direto para a armadilha de inferência acima.

Não existe desabilitação no nível do grupo. RadioGroup.onChanged é obrigatório e não anulável, então você não consegue esmaecer um grupo inteiro anulando um callback como fazia antes. Coloque enabled: false em cada radio, ou percorra suas opções e passe um sinalizador.

RadioListTile.selected continua manual. O framework documenta que “no effort is made to automatically coordinate the selected state and the checked state” e manda você definir selected: true quando value corresponder a RadioGroup.groupValue. Migrar não muda isso; você continua comparando na mão.

A navegação por teclado só alcança os radios construídos. Em um ListView.builder, as setas só conseguem percorrer os tiles que estão no momento na árvore de widgets. Na minha sonda de 200 itens, 11 foram construídos. Para uma lista longa de opções isso é um limite real de acessibilidade, e é um bom motivo para preferir uma Column delimitada dentro de um scroll view em vez da construção preguiçosa para grupos de radios. Se você precisa da lista preguiçosa mesmo assim, os padrões de lista com scroll infinito continuam valendo.

Radio.adaptive está tranquilo. Ele repassa groupRegistry: _effectiveRegistry e enabled: _enabled para o CupertinoRadio, então um radio adaptativo dentro de um RadioGroup pega o registro no iOS e no macOS sem trabalho extra.

Para widgets customizados no estilo radio, implemente o registro. RadioGroupRegistry<T> é uma interface pública pequena (groupValue, onChanged, registerClient, unregisterClient) e o RawRadio aceita um groupRegistry direto. Esse é o caminho suportado se você está construindo um controle com tema próprio que precisa participar da navegação por teclado do grupo. O RawRadio afirma 'an enabled raw radio must have a registry', então conecte antes de habilitar.

A migração não é urgente, já que as propriedades obsoletas ainda compilam no 3.44. Ainda assim vale a pena fazer, porque o comportamento de acessibilidade não é algo que você consiga adaptar depois, e porque cada tela que você deixa na API antiga é uma tela que vai migrar mais tarde sob pressão de prazo. Faça agora, escreva os argumentos de tipo, e deixe o analisador te dizer quando terminou.

Relacionados

Fontes

Comments

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

< Voltar