diff --git a/dev/benchmarks/macrobenchmarks/lib/src/web/material3.dart b/dev/benchmarks/macrobenchmarks/lib/src/web/material3.dart index 2e516d18309..02db61263d8 100644 --- a/dev/benchmarks/macrobenchmarks/lib/src/web/material3.dart +++ b/dev/benchmarks/macrobenchmarks/lib/src/web/material3.dart @@ -870,7 +870,6 @@ class _RadiosState extends State { title: const Text('Option 3'), value: Options.option3, groupValue: _selectedOption, - onChanged: null, ), ], ), diff --git a/dev/integration_tests/flutter_gallery/lib/demo/material/selection_controls_demo.dart b/dev/integration_tests/flutter_gallery/lib/demo/material/selection_controls_demo.dart index 00d8a13e261..7e44fd44981 100644 --- a/dev/integration_tests/flutter_gallery/lib/demo/material/selection_controls_demo.dart +++ b/dev/integration_tests/flutter_gallery/lib/demo/material/selection_controls_demo.dart @@ -155,9 +155,9 @@ class _SelectionControlsDemoState extends State { const Row( mainAxisSize: MainAxisSize.min, children: [ - Radio(value: 0, groupValue: 0, onChanged: null), - Radio(value: 1, groupValue: 0, onChanged: null), - Radio(value: 2, groupValue: 0, onChanged: null), + Radio(value: 0, groupValue: 0), + Radio(value: 1, groupValue: 0), + Radio(value: 2, groupValue: 0), ], ), ], diff --git a/dev/integration_tests/flutter_gallery/lib/gallery/example_code.dart b/dev/integration_tests/flutter_gallery/lib/gallery/example_code.dart index 573fbdabc78..36c7e6d64ab 100644 --- a/dev/integration_tests/flutter_gallery/lib/gallery/example_code.dart +++ b/dev/integration_tests/flutter_gallery/lib/gallery/example_code.dart @@ -179,7 +179,7 @@ class SelectionControls { ); // Creates a disabled radio button. - const Radio(value: 0, groupValue: 0, onChanged: null); + const Radio(value: 0, groupValue: 0); // END // START selectioncontrols_switch diff --git a/dev/integration_tests/flutter_gallery/test/demo/material/expansion_panels_demo_test.dart b/dev/integration_tests/flutter_gallery/test/demo/material/expansion_panels_demo_test.dart index 8bdbf07e2c7..4d91356fe6c 100644 --- a/dev/integration_tests/flutter_gallery/test/demo/material/expansion_panels_demo_test.dart +++ b/dev/integration_tests/flutter_gallery/test/demo/material/expansion_panels_demo_test.dart @@ -38,14 +38,15 @@ Widget get _radioPanelExpandIcon => _expandIcons.evaluate().toList()[1].widget; bool _isRadioSelected(int index) => _radios[index].value == _radios[index].groupValue; -List> get _radios => - List>.from(_radioFinder.evaluate().map((Element e) => e.widget)); +List> get _radios => List>.from( + _radioFinder.evaluate().map((Element e) => e.widget), +); // [find.byType] and [find.widgetWithText] do not match subclasses; `Radio` is // not sufficient to find a `Radio<_Location>`. Another approach is to grab the // `runtimeType` of a dummy instance; see // packages/flutter/test/material/radio_list_tile_test.dart. -Finder get _radioFinder => find.byWidgetPredicate((Widget w) => w is Radio); +Finder get _radioFinder => find.byWidgetPredicate((Widget w) => w is RadioListTile); List> get _radioListTiles => List>.from( _radioListTilesFinder.evaluate().map((Element e) => e.widget), diff --git a/dev/integration_tests/new_gallery/lib/demos/material/selection_controls_demo.dart b/dev/integration_tests/new_gallery/lib/demos/material/selection_controls_demo.dart index b8c01afb68b..f3b58042287 100644 --- a/dev/integration_tests/new_gallery/lib/demos/material/selection_controls_demo.dart +++ b/dev/integration_tests/new_gallery/lib/demos/material/selection_controls_demo.dart @@ -167,7 +167,7 @@ class _RadioDemoState extends State<_RadioDemo> with RestorationMixin { mainAxisAlignment: MainAxisAlignment.center, children: [ for (int index = 0; index < 2; ++index) - Radio(value: index, groupValue: radioValue.value, onChanged: null), + Radio(value: index, groupValue: radioValue.value), ], ), ], diff --git a/examples/api/lib/cupertino/radio/cupertino_radio.0.dart b/examples/api/lib/cupertino/radio/cupertino_radio.0.dart index 2ecfba0a753..a01b199d95d 100644 --- a/examples/api/lib/cupertino/radio/cupertino_radio.0.dart +++ b/examples/api/lib/cupertino/radio/cupertino_radio.0.dart @@ -37,33 +37,25 @@ class _CupertinoRadioExampleState extends State { @override Widget build(BuildContext context) { - return CupertinoListSection( - children: [ - CupertinoListTile( - title: const Text('Lafayette'), - leading: CupertinoRadio( - value: SingingCharacter.lafayette, - groupValue: _character, - onChanged: (SingingCharacter? value) { - setState(() { - _character = value; - }); - }, + return RadioGroup( + groupValue: _character, + onChanged: (SingingCharacter? value) { + setState(() { + _character = value; + }); + }, + child: CupertinoListSection( + children: const [ + CupertinoListTile( + title: Text('Lafayette'), + leading: CupertinoRadio(value: SingingCharacter.lafayette), ), - ), - CupertinoListTile( - title: const Text('Thomas Jefferson'), - leading: CupertinoRadio( - value: SingingCharacter.jefferson, - groupValue: _character, - onChanged: (SingingCharacter? value) { - setState(() { - _character = value; - }); - }, + CupertinoListTile( + title: Text('Thomas Jefferson'), + leading: CupertinoRadio(value: SingingCharacter.jefferson), ), - ), - ], + ], + ), ); } } diff --git a/examples/api/lib/cupertino/radio/cupertino_radio.toggleable.0.dart b/examples/api/lib/cupertino/radio/cupertino_radio.toggleable.0.dart index 2860b6c76da..3bcd3d39199 100644 --- a/examples/api/lib/cupertino/radio/cupertino_radio.toggleable.0.dart +++ b/examples/api/lib/cupertino/radio/cupertino_radio.toggleable.0.dart @@ -36,37 +36,33 @@ class _CupertinoRadioExampleState extends State { @override Widget build(BuildContext context) { - return CupertinoListSection( - children: [ - CupertinoListTile( - title: const Text('Hercules Mulligan'), - leading: CupertinoRadio( - value: SingingCharacter.mulligan, - groupValue: _character, - // TRY THIS: Try setting the toggleable value to false and - // see how that changes the behavior of the widget. - toggleable: true, - onChanged: (SingingCharacter? value) { - setState(() { - _character = value; - }); - }, + return RadioGroup( + groupValue: _character, + onChanged: (SingingCharacter? value) { + setState(() { + _character = value; + }); + }, + child: CupertinoListSection( + children: const [ + CupertinoListTile( + title: Text('Hercules Mulligan'), + leading: CupertinoRadio( + value: SingingCharacter.mulligan, + // TRY THIS: Try setting the toggleable value to false and + // see how that changes the behavior of the widget. + toggleable: true, + ), ), - ), - CupertinoListTile( - title: const Text('Eliza Hamilton'), - leading: CupertinoRadio( - value: SingingCharacter.hamilton, - groupValue: _character, - toggleable: true, - onChanged: (SingingCharacter? value) { - setState(() { - _character = value; - }); - }, + CupertinoListTile( + title: Text('Eliza Hamilton'), + leading: CupertinoRadio( + value: SingingCharacter.hamilton, + toggleable: true, + ), ), - ), - ], + ], + ), ); } } diff --git a/examples/api/lib/material/radio/radio.0.dart b/examples/api/lib/material/radio/radio.0.dart index 13495c41080..346556fa553 100644 --- a/examples/api/lib/material/radio/radio.0.dart +++ b/examples/api/lib/material/radio/radio.0.dart @@ -36,33 +36,25 @@ class _RadioExampleState extends State { @override Widget build(BuildContext context) { - return Column( - children: [ - ListTile( - title: const Text('Lafayette'), - leading: Radio( - value: SingingCharacter.lafayette, - groupValue: _character, - onChanged: (SingingCharacter? value) { - setState(() { - _character = value; - }); - }, + return RadioGroup( + groupValue: _character, + onChanged: (SingingCharacter? value) { + setState(() { + _character = value; + }); + }, + child: const Column( + children: [ + ListTile( + title: Text('Lafayette'), + leading: Radio(value: SingingCharacter.lafayette), ), - ), - ListTile( - title: const Text('Thomas Jefferson'), - leading: Radio( - value: SingingCharacter.jefferson, - groupValue: _character, - onChanged: (SingingCharacter? value) { - setState(() { - _character = value; - }); - }, + ListTile( + title: Text('Thomas Jefferson'), + leading: Radio(value: SingingCharacter.jefferson), ), - ), - ], + ], + ), ); } } diff --git a/examples/api/lib/material/radio/radio.toggleable.0.dart b/examples/api/lib/material/radio/radio.toggleable.0.dart index e746b63ff68..c672dd6078f 100644 --- a/examples/api/lib/material/radio/radio.toggleable.0.dart +++ b/examples/api/lib/material/radio/radio.toggleable.0.dart @@ -42,28 +42,30 @@ class _ToggleableExampleState extends State { @override Widget build(BuildContext context) { return Scaffold( - body: ListView.builder( - itemBuilder: (BuildContext context, int index) { - return Row( - mainAxisSize: MainAxisSize.min, - children: [ - Radio( - value: index, - groupValue: groupValue, - // TRY THIS: Try setting the toggleable value to false and - // see how that changes the behavior of the widget. - toggleable: true, - onChanged: (int? value) { - setState(() { - groupValue = value; - }); - }, - ), - Text(selections[index]), - ], - ); + body: RadioGroup( + groupValue: groupValue, + onChanged: (int? value) { + setState(() { + groupValue = value; + }); }, - itemCount: selections.length, + child: ListView.builder( + itemBuilder: (BuildContext context, int index) { + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + Radio( + value: index, + // TRY THIS: Try setting the toggleable value to false and + // see how that changes the behavior of the widget. + toggleable: true, + ), + Text(selections[index]), + ], + ); + }, + itemCount: selections.length, + ), ), ); } diff --git a/examples/api/lib/material/radio_list_tile/custom_labeled_radio.0.dart b/examples/api/lib/material/radio_list_tile/custom_labeled_radio.0.dart index 8f618ef11ad..a0645dd80d6 100644 --- a/examples/api/lib/material/radio_list_tile/custom_labeled_radio.0.dart +++ b/examples/api/lib/material/radio_list_tile/custom_labeled_radio.0.dart @@ -29,16 +29,12 @@ class LinkedLabelRadio extends StatelessWidget { super.key, required this.label, required this.padding, - required this.groupValue, required this.value, - required this.onChanged, }); final String label; final EdgeInsets padding; - final bool groupValue; final bool value; - final ValueChanged onChanged; @override Widget build(BuildContext context) { @@ -46,13 +42,7 @@ class LinkedLabelRadio extends StatelessWidget { padding: padding, child: Row( children: [ - Radio( - groupValue: groupValue, - value: value, - onChanged: (bool? newValue) { - onChanged(newValue!); - }, - ), + Radio(value: value), RichText( text: TextSpan( text: label, @@ -86,32 +76,28 @@ class _LabeledRadioExampleState extends State { @override Widget build(BuildContext context) { return Scaffold( - body: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - LinkedLabelRadio( - label: 'First tappable label text', - padding: const EdgeInsets.symmetric(horizontal: 5.0), - value: true, - groupValue: _isRadioSelected, - onChanged: (bool newValue) { - setState(() { - _isRadioSelected = newValue; - }); - }, - ), - LinkedLabelRadio( - label: 'Second tappable label text', - padding: const EdgeInsets.symmetric(horizontal: 5.0), - value: false, - groupValue: _isRadioSelected, - onChanged: (bool newValue) { - setState(() { - _isRadioSelected = newValue; - }); - }, - ), - ], + body: RadioGroup( + groupValue: _isRadioSelected, + onChanged: (bool? newValue) { + setState(() { + _isRadioSelected = newValue!; + }); + }, + child: const Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + LinkedLabelRadio( + label: 'First tappable label text', + padding: EdgeInsets.symmetric(horizontal: 5.0), + value: true, + ), + LinkedLabelRadio( + label: 'Second tappable label text', + padding: EdgeInsets.symmetric(horizontal: 5.0), + value: false, + ), + ], + ), ), ); } diff --git a/examples/api/lib/material/radio_list_tile/custom_labeled_radio.1.dart b/examples/api/lib/material/radio_list_tile/custom_labeled_radio.1.dart index 215216d7ea5..5634f81d545 100644 --- a/examples/api/lib/material/radio_list_tile/custom_labeled_radio.1.dart +++ b/examples/api/lib/material/radio_list_tile/custom_labeled_radio.1.dart @@ -23,43 +23,21 @@ class LabeledRadioApp extends StatelessWidget { } class LabeledRadio extends StatelessWidget { - const LabeledRadio({ - super.key, - required this.label, - required this.padding, - required this.groupValue, - required this.value, - required this.onChanged, - }); + const LabeledRadio({super.key, required this.label, required this.padding, required this.value}); final String label; final EdgeInsets padding; - final bool groupValue; final bool value; - final ValueChanged onChanged; @override Widget build(BuildContext context) { return InkWell( onTap: () { - if (value != groupValue) { - onChanged(value); - } + RadioGroup.maybeOf(context)?.onChanged(value); }, child: Padding( padding: padding, - child: Row( - children: [ - Radio( - groupValue: groupValue, - value: value, - onChanged: (bool? newValue) { - onChanged(newValue!); - }, - ), - Text(label), - ], - ), + child: Row(children: [Radio(value: value), Text(label)]), ), ); } @@ -78,32 +56,28 @@ class _LabeledRadioExampleState extends State { @override Widget build(BuildContext context) { return Scaffold( - body: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - LabeledRadio( - label: 'This is the first label text', - padding: const EdgeInsets.symmetric(horizontal: 5.0), - value: true, - groupValue: _isRadioSelected, - onChanged: (bool newValue) { - setState(() { - _isRadioSelected = newValue; - }); - }, - ), - LabeledRadio( - label: 'This is the second label text', - padding: const EdgeInsets.symmetric(horizontal: 5.0), - value: false, - groupValue: _isRadioSelected, - onChanged: (bool newValue) { - setState(() { - _isRadioSelected = newValue; - }); - }, - ), - ], + body: RadioGroup( + groupValue: _isRadioSelected, + onChanged: (bool? newValue) { + setState(() { + _isRadioSelected = newValue!; + }); + }, + child: const Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + LabeledRadio( + label: 'This is the first label text', + padding: EdgeInsets.symmetric(horizontal: 5.0), + value: true, + ), + LabeledRadio( + label: 'This is the second label text', + padding: EdgeInsets.symmetric(horizontal: 5.0), + value: false, + ), + ], + ), ), ); } diff --git a/examples/api/lib/material/radio_list_tile/radio_list_tile.0.dart b/examples/api/lib/material/radio_list_tile/radio_list_tile.0.dart index f5231720d12..520b6aeb228 100644 --- a/examples/api/lib/material/radio_list_tile/radio_list_tile.0.dart +++ b/examples/api/lib/material/radio_list_tile/radio_list_tile.0.dart @@ -36,29 +36,25 @@ class _RadioListTileExampleState extends State { @override Widget build(BuildContext context) { - return Column( - children: [ - RadioListTile( - title: const Text('Lafayette'), - value: SingingCharacter.lafayette, - groupValue: _character, - onChanged: (SingingCharacter? value) { - setState(() { - _character = value; - }); - }, - ), - RadioListTile( - title: const Text('Thomas Jefferson'), - value: SingingCharacter.jefferson, - groupValue: _character, - onChanged: (SingingCharacter? value) { - setState(() { - _character = value; - }); - }, - ), - ], + return RadioGroup( + groupValue: _character, + onChanged: (SingingCharacter? value) { + setState(() { + _character = value; + }); + }, + child: const Column( + children: [ + RadioListTile( + title: Text('Lafayette'), + value: SingingCharacter.lafayette, + ), + RadioListTile( + title: Text('Thomas Jefferson'), + value: SingingCharacter.jefferson, + ), + ], + ), ); } } diff --git a/examples/api/lib/material/radio_list_tile/radio_list_tile.1.dart b/examples/api/lib/material/radio_list_tile/radio_list_tile.1.dart index 18c1a47ed0b..261163b1a58 100644 --- a/examples/api/lib/material/radio_list_tile/radio_list_tile.1.dart +++ b/examples/api/lib/material/radio_list_tile/radio_list_tile.1.dart @@ -33,47 +33,37 @@ class _RadioListTileExampleState extends State { Widget build(BuildContext context) { return Scaffold( appBar: AppBar(title: const Text('RadioListTile Sample')), - body: Column( - children: [ - RadioListTile( - value: Groceries.pickles, - groupValue: _groceryItem, - onChanged: (Groceries? value) { - setState(() { - _groceryItem = value; - }); - }, - title: const Text('Pickles'), - subtitle: const Text('Supporting text'), - ), - RadioListTile( - value: Groceries.tomato, - groupValue: _groceryItem, - onChanged: (Groceries? value) { - setState(() { - _groceryItem = value; - }); - }, - title: const Text('Tomato'), - subtitle: const Text( - 'Longer supporting text to demonstrate how the text wraps and the radio is centered vertically with the text.', + body: RadioGroup( + groupValue: _groceryItem, + onChanged: (Groceries? value) { + setState(() { + _groceryItem = value; + }); + }, + child: const Column( + children: [ + RadioListTile( + value: Groceries.pickles, + title: Text('Pickles'), + subtitle: Text('Supporting text'), ), - ), - RadioListTile( - value: Groceries.lettuce, - groupValue: _groceryItem, - onChanged: (Groceries? value) { - setState(() { - _groceryItem = value; - }); - }, - title: const Text('Lettuce'), - subtitle: const Text( - "Longer supporting text to demonstrate how the text wraps and how setting 'RadioListTile.isThreeLine = true' aligns the radio to the top vertically with the text.", + RadioListTile( + value: Groceries.tomato, + title: Text('Tomato'), + subtitle: Text( + 'Longer supporting text to demonstrate how the text wraps and the radio is centered vertically with the text.', + ), ), - isThreeLine: true, - ), - ], + RadioListTile( + value: Groceries.lettuce, + title: Text('Lettuce'), + subtitle: Text( + "Longer supporting text to demonstrate how the text wraps and how setting 'RadioListTile.isThreeLine = true' aligns the radio to the top vertically with the text.", + ), + isThreeLine: true, + ), + ], + ), ), ); } diff --git a/examples/api/lib/material/radio_list_tile/radio_list_tile.toggleable.0.dart b/examples/api/lib/material/radio_list_tile/radio_list_tile.toggleable.0.dart index 75bde750d68..02aae148d77 100644 --- a/examples/api/lib/material/radio_list_tile/radio_list_tile.toggleable.0.dart +++ b/examples/api/lib/material/radio_list_tile/radio_list_tile.toggleable.0.dart @@ -42,21 +42,23 @@ class _RadioListTileExampleState extends State { @override Widget build(BuildContext context) { return Scaffold( - body: ListView.builder( - itemBuilder: (BuildContext context, int index) { - return RadioListTile( - value: index, - groupValue: groupValue, - toggleable: true, - title: Text(selections[index]), - onChanged: (int? value) { - setState(() { - groupValue = value; - }); - }, - ); + body: RadioGroup( + groupValue: groupValue, + onChanged: (int? value) { + setState(() { + groupValue = value; + }); }, - itemCount: selections.length, + child: ListView.builder( + itemBuilder: (BuildContext context, int index) { + return RadioListTile( + value: index, + toggleable: true, + title: Text(selections[index]), + ); + }, + itemCount: selections.length, + ), ), ); } diff --git a/examples/api/lib/widgets/radio_group/radio_group.0.dart b/examples/api/lib/widgets/radio_group/radio_group.0.dart new file mode 100644 index 00000000000..f4d7556dd31 --- /dev/null +++ b/examples/api/lib/widgets/radio_group/radio_group.0.dart @@ -0,0 +1,108 @@ +// Copyright 2014 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter/material.dart'; + +/// Flutter code sample for [Radio]. + +void main() => runApp(const RadioExampleApp()); + +class RadioExampleApp extends StatelessWidget { + const RadioExampleApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + home: Scaffold( + appBar: AppBar(title: const Text('Radio Group Sample')), + body: const Center(child: RadioExample()), + ), + ); + } +} + +enum SingingCharacter { lafayette, jefferson } + +enum Genre { metal, jazz, blues } + +class RadioExample extends StatelessWidget { + const RadioExample({super.key}); + + @override + Widget build(BuildContext context) { + return const Column(children: [SingingCharacterRadioGroup(), GenreRadioGroup()]); + } +} + +class SingingCharacterRadioGroup extends StatefulWidget { + const SingingCharacterRadioGroup({super.key}); + + @override + State createState() => SingingCharacterRadioGroupState(); +} + +class SingingCharacterRadioGroupState extends State { + SingingCharacter? _character = SingingCharacter.lafayette; + + @override + Widget build(BuildContext context) { + return RadioGroup( + groupValue: _character, + onChanged: (SingingCharacter? value) { + setState(() { + _character = value; + }); + }, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Selected: $_character'), + const ListTile( + title: Text('Lafayette'), + leading: Radio(value: SingingCharacter.lafayette), + ), + const ListTile( + title: Text('Thomas Jefferson'), + leading: Radio(value: SingingCharacter.jefferson), + ), + ], + ), + ); + } +} + +class GenreRadioGroup extends StatefulWidget { + const GenreRadioGroup({super.key}); + + @override + State createState() => GenreRadioGroupState(); +} + +class GenreRadioGroupState extends State { + Genre? _genre; + + @override + Widget build(BuildContext context) { + return RadioGroup( + groupValue: _genre, + onChanged: (Genre? value) { + setState(() { + _genre = value; + }); + }, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Selected: $_genre'), + const ListTile( + title: Text('Metal'), + leading: Radio(toggleable: true, value: Genre.metal), + ), + const ListTile(title: Text('Jazz'), leading: Radio(value: Genre.jazz)), + const ListTile(title: Text('Blues'), leading: Radio(value: Genre.blues)), + ], + ), + ); + } +} diff --git a/examples/api/test/cupertino/radio/cupertino_radio.0_test.dart b/examples/api/test/cupertino/radio/cupertino_radio.0_test.dart index c98998cc26b..c95dba6bb87 100644 --- a/examples/api/test/cupertino/radio/cupertino_radio.0_test.dart +++ b/examples/api/test/cupertino/radio/cupertino_radio.0_test.dart @@ -12,21 +12,15 @@ void main() { expect(find.byType(CupertinoRadio), findsNWidgets(2)); - CupertinoRadio radio = tester.widget( - find.byType(CupertinoRadio).first, + RadioGroup group = tester.widget( + find.byType(RadioGroup), ); - expect(radio.groupValue, example.SingingCharacter.lafayette); - - radio = tester.widget(find.byType(CupertinoRadio).last); - expect(radio.groupValue, example.SingingCharacter.lafayette); + expect(group.groupValue, example.SingingCharacter.lafayette); await tester.tap(find.byType(CupertinoRadio).last); await tester.pumpAndSettle(); - radio = tester.widget(find.byType(CupertinoRadio).last); - expect(radio.groupValue, example.SingingCharacter.jefferson); - - radio = tester.widget(find.byType(CupertinoRadio).first); - expect(radio.groupValue, example.SingingCharacter.jefferson); + group = tester.widget(find.byType(RadioGroup)); + expect(group.groupValue, example.SingingCharacter.jefferson); }); } diff --git a/examples/api/test/cupertino/radio/cupertino_radio.toggleable.0_test.dart b/examples/api/test/cupertino/radio/cupertino_radio.toggleable.0_test.dart index 5fcf9f80f68..2b46237fc13 100644 --- a/examples/api/test/cupertino/radio/cupertino_radio.toggleable.0_test.dart +++ b/examples/api/test/cupertino/radio/cupertino_radio.toggleable.0_test.dart @@ -12,27 +12,21 @@ void main() { expect(find.byType(CupertinoRadio), findsNWidgets(2)); - CupertinoRadio radio = tester.widget( - find.byType(CupertinoRadio).first, + RadioGroup group = tester.widget( + find.byType(RadioGroup), ); - expect(radio.groupValue, example.SingingCharacter.mulligan); - - radio = tester.widget(find.byType(CupertinoRadio).last); - expect(radio.groupValue, example.SingingCharacter.mulligan); + expect(group.groupValue, example.SingingCharacter.mulligan); await tester.tap(find.byType(CupertinoRadio).last); await tester.pumpAndSettle(); - radio = tester.widget(find.byType(CupertinoRadio).last); - expect(radio.groupValue, example.SingingCharacter.hamilton); - - radio = tester.widget(find.byType(CupertinoRadio).first); - expect(radio.groupValue, example.SingingCharacter.hamilton); + group = tester.widget(find.byType(RadioGroup)); + expect(group.groupValue, example.SingingCharacter.hamilton); await tester.tap(find.byType(CupertinoRadio).last); await tester.pumpAndSettle(); - radio = tester.widget(find.byType(CupertinoRadio).last); - expect(radio.groupValue, null); + group = tester.widget(find.byType(RadioGroup)); + expect(group.groupValue, null); }); } diff --git a/examples/api/test/material/radio/radio.0_test.dart b/examples/api/test/material/radio/radio.0_test.dart index e79cb1ae093..33996d27802 100644 --- a/examples/api/test/material/radio/radio.0_test.dart +++ b/examples/api/test/material/radio/radio.0_test.dart @@ -18,25 +18,26 @@ void main() { final Finder radioButton1 = find.byType(Radio).first; final Finder radioButton2 = find.byType(Radio).last; + final Finder radioGroup = find.byType(RadioGroup).last; await tester.tap(radioButton1); await tester.pumpAndSettle(); expect( - tester.widget>(radioButton1).groupValue, + tester.widget>(radioGroup).groupValue, tester.widget>(radioButton1).value, ); expect( - tester.widget>(radioButton2).groupValue, + tester.widget>(radioGroup).groupValue, isNot(tester.widget>(radioButton2).value), ); await tester.tap(radioButton2); await tester.pumpAndSettle(); expect( - tester.widget>(radioButton1).groupValue, + tester.widget>(radioGroup).groupValue, isNot(tester.widget>(radioButton1).value), ); expect( - tester.widget>(radioButton2).groupValue, + tester.widget>(radioGroup).groupValue, tester.widget>(radioButton2).value, ); }); diff --git a/examples/api/test/material/radio/radio.toggleable.0_test.dart b/examples/api/test/material/radio/radio.toggleable.0_test.dart index fa9b58fb90f..b010edd2826 100644 --- a/examples/api/test/material/radio/radio.toggleable.0_test.dart +++ b/examples/api/test/material/radio/radio.toggleable.0_test.dart @@ -21,8 +21,10 @@ void main() { await tester.tap(find.byType(Radio).at(i)); await tester.pump(); expect( - find.byWidgetPredicate((Widget widget) => widget is Radio && widget.groupValue == i), - findsExactly(5), + find.byWidgetPredicate( + (Widget widget) => widget is RadioGroup && widget.groupValue == i, + ), + findsOne, ); } }); diff --git a/examples/api/test/material/radio_list_tile/custom_labeled_radio.0_test.dart b/examples/api/test/material/radio_list_tile/custom_labeled_radio.0_test.dart index 8a60e6f41ac..af5a7a47e0b 100644 --- a/examples/api/test/material/radio_list_tile/custom_labeled_radio.0_test.dart +++ b/examples/api/test/material/radio_list_tile/custom_labeled_radio.0_test.dart @@ -15,28 +15,16 @@ void main() { final RichText richText = tester.widget(find.byType(RichText).first); expect(richText.text.toPlainText(), 'First tappable label text'); - // First Radio is initially unchecked. - Radio radio = tester.widget(find.byType(Radio).first); - expect(radio.value, true); - expect(radio.groupValue, false); - - // Last Radio is initially checked. - radio = tester.widget(find.byType(Radio).last); - expect(radio.value, false); - expect(radio.groupValue, false); + RadioGroup group = tester.widget>(find.byType(RadioGroup)); + // Second radio is checked. + expect(group.groupValue, isFalse); // Tap the first radio. await tester.tap(find.byType(Radio).first); await tester.pump(); // First Radio is now checked. - radio = tester.widget(find.byType(Radio).first); - expect(radio.value, true); - expect(radio.groupValue, true); - - // Last Radio is now unchecked. - radio = tester.widget(find.byType(Radio).last); - expect(radio.value, false); - expect(radio.groupValue, true); + group = tester.widget>(find.byType(RadioGroup)); + expect(group.groupValue, true); }); } diff --git a/examples/api/test/material/radio_list_tile/custom_labeled_radio.1_test.dart b/examples/api/test/material/radio_list_tile/custom_labeled_radio.1_test.dart index 47d31a65daa..a12e6bf987a 100644 --- a/examples/api/test/material/radio_list_tile/custom_labeled_radio.1_test.dart +++ b/examples/api/test/material/radio_list_tile/custom_labeled_radio.1_test.dart @@ -11,28 +11,16 @@ void main() { testWidgets('Tapping LabeledRadio toggles the radio', (WidgetTester tester) async { await tester.pumpWidget(const example.LabeledRadioApp()); - // First Radio is initially unchecked. - Radio radio = tester.widget(find.byType(Radio).first); - expect(radio.value, true); - expect(radio.groupValue, false); - - // Last Radio is initially checked. - radio = tester.widget(find.byType(Radio).last); - expect(radio.value, false); - expect(radio.groupValue, false); + RadioGroup group = tester.widget>(find.byType(RadioGroup)); + // Second radio is checked. + expect(group.groupValue, isFalse); // Tap the first labeled radio to toggle the Radio widget. await tester.tap(find.byType(example.LabeledRadio).first); await tester.pumpAndSettle(); - // First Radio is now checked. - radio = tester.widget(find.byType(Radio).first); - expect(radio.value, true); - expect(radio.groupValue, true); - - // Last Radio is now unchecked. - radio = tester.widget(find.byType(Radio).last); - expect(radio.value, false); - expect(radio.groupValue, true); + group = tester.widget>(find.byType(RadioGroup)); + // Second radio is checked. + expect(group.groupValue, isTrue); }); } diff --git a/examples/api/test/material/radio_list_tile/radio_list_tile.0_test.dart b/examples/api/test/material/radio_list_tile/radio_list_tile.0_test.dart index 765db2e9d7f..962797c4840 100644 --- a/examples/api/test/material/radio_list_tile/radio_list_tile.0_test.dart +++ b/examples/api/test/material/radio_list_tile/radio_list_tile.0_test.dart @@ -13,26 +13,23 @@ void main() { // Find the number of RadioListTiles. expect(find.byType(RadioListTile), findsNWidgets(2)); - // The initial group value is lafayette for the first RadioListTile. - RadioListTile radioListTile = tester.widget( - find.byType(RadioListTile).first, - ); - expect(radioListTile.groupValue, example.SingingCharacter.lafayette); - - // The initial group value is lafayette for the last RadioListTile. - radioListTile = tester.widget(find.byType(RadioListTile).last); - expect(radioListTile.groupValue, example.SingingCharacter.lafayette); + // The initial group value is lafayette. + RadioGroup group = tester + .widget>( + find.byType(RadioGroup), + ); + // Second radio is checked. + expect(group.groupValue, example.SingingCharacter.lafayette); // Tap the last RadioListTile to change the group value to jefferson. await tester.tap(find.byType(RadioListTile).last); await tester.pump(); - // The group value is now jefferson for the first RadioListTile. - radioListTile = tester.widget(find.byType(RadioListTile).first); - expect(radioListTile.groupValue, example.SingingCharacter.jefferson); - - // The group value is now jefferson for the last RadioListTile. - radioListTile = tester.widget(find.byType(RadioListTile).last); - expect(radioListTile.groupValue, example.SingingCharacter.jefferson); + // The group value is now jefferson. + group = tester.widget>( + find.byType(RadioGroup), + ); + // Second radio is checked. + expect(group.groupValue, example.SingingCharacter.jefferson); }); } diff --git a/examples/api/test/material/radio_list_tile/radio_list_tile.1_test.dart b/examples/api/test/material/radio_list_tile/radio_list_tile.1_test.dart index 58bf981f535..fb51907f5c1 100644 --- a/examples/api/test/material/radio_list_tile/radio_list_tile.1_test.dart +++ b/examples/api/test/material/radio_list_tile/radio_list_tile.1_test.dart @@ -35,56 +35,31 @@ void main() { await tester.pumpWidget(const example.RadioListTileApp()); expect(find.byType(RadioListTile), findsNWidgets(3)); - final Finder radioListTile = find.byType(RadioListTile); // Initially the first radio is checked. - expect( - tester.widget>(radioListTile.at(0)).groupValue, - example.Groceries.pickles, - ); - expect( - tester.widget>(radioListTile.at(1)).groupValue, - example.Groceries.pickles, - ); - expect( - tester.widget>(radioListTile.at(2)).groupValue, - example.Groceries.pickles, + RadioGroup group = tester.widget>( + find.byType(RadioGroup), ); + expect(group.groupValue, example.Groceries.pickles); // Tap the second radio. await tester.tap(find.byType(Radio).at(1)); await tester.pumpAndSettle(); // The second radio is checked. - expect( - tester.widget>(radioListTile.at(0)).groupValue, - example.Groceries.tomato, - ); - expect( - tester.widget>(radioListTile.at(1)).groupValue, - example.Groceries.tomato, - ); - expect( - tester.widget>(radioListTile.at(2)).groupValue, - example.Groceries.tomato, + group = tester.widget>( + find.byType(RadioGroup), ); + expect(group.groupValue, example.Groceries.tomato); // Tap the third radio. await tester.tap(find.byType(Radio).at(2)); await tester.pumpAndSettle(); // The third radio is checked. - expect( - tester.widget>(radioListTile.at(0)).groupValue, - example.Groceries.lettuce, - ); - expect( - tester.widget>(radioListTile.at(1)).groupValue, - example.Groceries.lettuce, - ); - expect( - tester.widget>(radioListTile.at(2)).groupValue, - example.Groceries.lettuce, + group = tester.widget>( + find.byType(RadioGroup), ); + expect(group.groupValue, example.Groceries.lettuce); }); } diff --git a/examples/api/test/material/radio_list_tile/radio_list_tile.toggleable.0_test.dart b/examples/api/test/material/radio_list_tile/radio_list_tile.toggleable.0_test.dart index e8e897c3e83..e375170d3fc 100644 --- a/examples/api/test/material/radio_list_tile/radio_list_tile.toggleable.0_test.dart +++ b/examples/api/test/material/radio_list_tile/radio_list_tile.toggleable.0_test.dart @@ -12,26 +12,23 @@ void main() { await tester.pumpWidget(const example.RadioListTileApp()); // Initially the third radio button is not selected. - Radio radio = tester.widget(find.byType(Radio).at(2)); - expect(radio.value, 2); - expect(radio.groupValue, null); + RadioGroup group = tester.widget>(find.byType(RadioGroup)); + expect(group.groupValue, null); // Tap the third radio button. await tester.tap(find.text('Philip Schuyler')); await tester.pumpAndSettle(); // The third radio button is now selected. - radio = tester.widget(find.byType(Radio).at(2)); - expect(radio.value, 2); - expect(radio.groupValue, 2); + group = tester.widget>(find.byType(RadioGroup)); + expect(group.groupValue, 2); // Tap the third radio button again. await tester.tap(find.text('Philip Schuyler')); await tester.pumpAndSettle(); // The third radio button is now unselected. - radio = tester.widget(find.byType(Radio).at(2)); - expect(radio.value, 2); - expect(radio.groupValue, null); + group = tester.widget>(find.byType(RadioGroup)); + expect(group.groupValue, null); }); } diff --git a/examples/api/test/widgets/radio_group/radio_group.0_test.dart b/examples/api/test/widgets/radio_group/radio_group.0_test.dart new file mode 100644 index 00000000000..2ce9bfacef2 --- /dev/null +++ b/examples/api/test/widgets/radio_group/radio_group.0_test.dart @@ -0,0 +1,79 @@ +// Copyright 2014 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter/material.dart'; +import 'package:flutter_api_samples/widgets/radio_group/radio_group.0.dart' as example; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('Radio Smoke Test - character', (WidgetTester tester) async { + await tester.pumpWidget(const example.RadioExampleApp()); + + expect(find.widgetWithText(AppBar, 'Radio Group Sample'), findsOneWidget); + final Finder listTile1 = find.widgetWithText(ListTile, 'Lafayette'); + expect(listTile1, findsOneWidget); + final Finder listTile2 = find.widgetWithText(ListTile, 'Thomas Jefferson'); + expect(listTile2, findsOneWidget); + + final Finder radioButton1 = find.byType(Radio).first; + final Finder radioButton2 = find.byType(Radio).last; + final Finder radioGroup = find.byType(RadioGroup).last; + + await tester.tap(radioButton1); + await tester.pumpAndSettle(); + expect( + tester.widget>(radioGroup).groupValue, + tester.widget>(radioButton1).value, + ); + expect( + tester.widget>(radioGroup).groupValue, + isNot(tester.widget>(radioButton2).value), + ); + await tester.tap(radioButton2); + await tester.pumpAndSettle(); + expect( + tester.widget>(radioGroup).groupValue, + isNot(tester.widget>(radioButton1).value), + ); + expect( + tester.widget>(radioGroup).groupValue, + tester.widget>(radioButton2).value, + ); + }); + + testWidgets('Radio Smoke Test - genre', (WidgetTester tester) async { + await tester.pumpWidget(const example.RadioExampleApp()); + + expect(find.widgetWithText(AppBar, 'Radio Group Sample'), findsOneWidget); + final Finder listTile1 = find.widgetWithText(ListTile, 'Metal'); + expect(listTile1, findsOneWidget); + final Finder listTile2 = find.widgetWithText(ListTile, 'Jazz'); + expect(listTile2, findsOneWidget); + + final Finder radioButton1 = find.byType(Radio).first; + final Finder radioButton2 = find.byType(Radio).last; + final Finder radioGroup = find.byType(RadioGroup).last; + + await tester.tap(radioButton1); + await tester.pumpAndSettle(); + expect( + tester.widget>(radioGroup).groupValue, + tester.widget>(radioButton1).value, + ); + expect( + tester.widget>(radioGroup).groupValue, + isNot(tester.widget>(radioButton2).value), + ); + await tester.tap(radioButton2); + await tester.pumpAndSettle(); + expect( + tester.widget>(radioGroup).groupValue, + isNot(tester.widget>(radioButton1).value), + ); + expect( + tester.widget>(radioGroup).groupValue, + tester.widget>(radioButton2).value, + ); + }); +} diff --git a/packages/flutter/lib/src/cupertino/radio.dart b/packages/flutter/lib/src/cupertino/radio.dart index 9d9b1bc88fe..7a9ff248236 100644 --- a/packages/flutter/lib/src/cupertino/radio.dart +++ b/packages/flutter/lib/src/cupertino/radio.dart @@ -57,7 +57,7 @@ const double _kBorderOutlineStrokeWidth = 0.3; const List _kDarkGradientOpacities = [0.14, 0.29]; const List _kDisabledDarkGradientOpacities = [0.08, 0.14]; -/// A macOS-style radio button. +/// A widget that builds a [RawRadio] with a macOS-style UI. /// /// Used to select between a number of mutually exclusive values. When one radio /// button in a group is selected, the other radio buttons in the group are @@ -103,8 +103,16 @@ class CupertinoRadio extends StatefulWidget { const CupertinoRadio({ super.key, required this.value, - required this.groupValue, - required this.onChanged, + @Deprecated( + 'Use a RadioGroup ancestor to manage group value instead. ' + 'This feature was deprecated after v3.32.0-0.0.pre.', + ) + this.groupValue, + @Deprecated( + 'Use RadioGroup to handle value change instead. ' + 'This feature was deprecated after v3.32.0-0.0.pre.', + ) + this.onChanged, this.mouseCursor, this.toggleable = false, this.activeColor, @@ -114,15 +122,21 @@ class CupertinoRadio extends StatefulWidget { this.focusNode, this.autofocus = false, this.useCheckmarkStyle = false, + this.enabled, + this.groupRegistry, }); /// {@macro flutter.widget.RawRadio.value} final T value; - /// {@macro flutter.widget.RawRadio.groupValue} + /// {@macro flutter.material.Radio.groupValue} + @Deprecated( + 'Use a RadioGroup ancestor to manage group value instead. ' + 'This feature was deprecated after v3.32.0-0.0.pre.', + ) final T? groupValue; - /// {@macro flutter.widget.RawRadio.onChanged} + /// {@macro flutter.material.Radio.onChanged} /// /// For example: /// @@ -137,6 +151,10 @@ class CupertinoRadio extends StatefulWidget { /// }, /// ) /// ``` + @Deprecated( + 'Use RadioGroup to handle value change instead. ' + 'This feature was deprecated after v3.32.0-0.0.pre.', + ) final ValueChanged? onChanged; /// {@macro flutter.widget.RawRadio.mouseCursor} @@ -194,6 +212,15 @@ class CupertinoRadio extends StatefulWidget { /// {@macro flutter.widgets.Focus.autofocus} final bool autofocus; + /// {@macro flutter.widget.RawRadio.groupRegistry} + /// + /// Unless provided, the [BuildContext] will be used to look up the ancestor + /// [RadioGroupRegistry]. + final RadioGroupRegistry? groupRegistry; + + /// {@macro flutter.material.Radio.enabled} + final bool? enabled; + @override State> createState() => _CupertinoRadioState(); } @@ -202,6 +229,27 @@ class _CupertinoRadioState extends State> { FocusNode get _effectiveFocusNode => widget.focusNode ?? (_internalFocusNode ??= FocusNode()); FocusNode? _internalFocusNode; + bool get _enabled => + widget.enabled ?? + (widget.onChanged != null || + widget.groupRegistry != null || + RadioGroup.maybeOf(context) != null); + + _RadioRegistry? _internalRadioRegistry; + RadioGroupRegistry get _effectiveRegistry { + if (widget.groupRegistry != null) { + return widget.groupRegistry!; + } + + final RadioGroupRegistry? inheritedRegistry = RadioGroup.maybeOf(context); + if (inheritedRegistry != null) { + return inheritedRegistry; + } + + // Handles deprecated API. + return _internalRadioRegistry ??= _RadioRegistry(this); + } + @override void dispose() { _internalFocusNode?.dispose(); @@ -210,6 +258,14 @@ class _CupertinoRadioState extends State> { @override Widget build(BuildContext context) { + assert( + !(widget.enabled ?? false) || + widget.onChanged != null || + widget.groupRegistry != null || + RadioGroup.maybeOf(context) != null, + 'Radio is enabled but has no CupertinoRadio.onChange, ' + 'CupertinoRadio.groupRegistry, or RadioGroup above', + ); final WidgetStateProperty effectiveMouseCursor = WidgetStateProperty.resolveWith((Set states) { return WidgetStateProperty.resolveAs(widget.mouseCursor, states) ?? @@ -220,12 +276,12 @@ class _CupertinoRadioState extends State> { return RawRadio( value: widget.value, - groupValue: widget.groupValue, - onChanged: widget.onChanged, + groupRegistry: _effectiveRegistry, mouseCursor: effectiveMouseCursor, toggleable: widget.toggleable, focusNode: _effectiveFocusNode, autofocus: widget.autofocus, + enabled: _enabled, builder: (BuildContext context, ToggleableStateMixin state) { return _RadioPaint( activeColor: widget.activeColor, @@ -233,7 +289,7 @@ class _CupertinoRadioState extends State> { fillColor: widget.fillColor, focusColor: widget.focusColor, useCheckmarkStyle: widget.useCheckmarkStyle, - isActive: widget.onChanged != null, + isActive: _enabled, toggleableState: state, focused: _effectiveFocusNode.hasFocus, ); @@ -242,6 +298,24 @@ class _CupertinoRadioState extends State> { } } +/// A registry for deprecated API. +// TODO(chunhtai): Remove this once deprecated API is removed. +class _RadioRegistry extends RadioGroupRegistry { + _RadioRegistry(this.state); + final _CupertinoRadioState state; + @override + T? get groupValue => state.widget.groupValue; + + @override + ValueChanged get onChanged => state.widget.onChanged!; + + @override + void registerClient(RadioClient radio) {} + + @override + void unregisterClient(RadioClient radio) {} +} + class _RadioPaint extends StatefulWidget { const _RadioPaint({ required this.focused, diff --git a/packages/flutter/lib/src/material/radio.dart b/packages/flutter/lib/src/material/radio.dart index 1f361665282..ef1baacaf9d 100644 --- a/packages/flutter/lib/src/material/radio.dart +++ b/packages/flutter/lib/src/material/radio.dart @@ -34,23 +34,23 @@ const double _kInnerRadius = 4.5; /// A Material Design radio button. /// +/// This widget builds a [RawRadio] with a material UI. +/// /// Used to select between a number of mutually exclusive values. When one radio /// button in a group is selected, the other radio buttons in the group cease to /// be selected. The values are of type `T`, the type parameter of the [Radio] /// class. Enums are commonly used for this purpose. /// -/// The radio button itself does not maintain any state. Instead, selecting the -/// radio invokes the [onChanged] callback, passing [value] as a parameter. If -/// [groupValue] and [value] match, this radio will be selected. Most widgets -/// will respond to [onChanged] by calling [State.setState] to update the -/// radio button's [groupValue]. +/// This widget typically has a [RadioGroup] ancestor, which takes in a +/// [RadioGroup.groupValue], and the [Radio] under it with matching [value] +/// will be selected. /// /// {@tool dartpad} /// Here is an example of Radio widgets wrapped in ListTiles, which is similar /// to what you could get with the RadioListTile widget. /// -/// The currently selected character is passed into `groupValue`, which is -/// maintained by the example's `State`. In this case, the first [Radio] +/// The currently selected character is passed into `RadioGroup.groupValue`, +/// which is maintained by the example's `State`. In this case, the first [Radio] /// will start off selected because `_character` is initialized to /// `SingingCharacter.lafayette`. /// @@ -71,25 +71,27 @@ const double _kInnerRadius = 4.5; /// * [Slider], for selecting a value in a range. /// * [Checkbox] and [Switch], for toggling a particular value on or off. /// * -class Radio extends StatelessWidget { +class Radio extends StatefulWidget { /// Creates a Material Design radio button. /// - /// The radio button itself does not maintain any state. Instead, when the - /// radio button is selected, the widget calls the [onChanged] callback. Most - /// widgets that use a radio button will listen for the [onChanged] callback - /// and rebuild the radio button with a new [groupValue] to update the visual - /// appearance of the radio button. + /// This widget typically has a [RadioGroup] ancestor, which takes in a + /// [RadioGroup.groupValue], and the [Radio] under it with matching [value] + /// will be selected. /// - /// The following arguments are required: - /// - /// * [value] and [groupValue] together determine whether the radio button is - /// selected. - /// * [onChanged] is called when the user selects this radio button. + /// The [value] is required. const Radio({ super.key, required this.value, - required this.groupValue, - required this.onChanged, + @Deprecated( + 'Use a RadioGroup ancestor to manage group value instead. ' + 'This feature was deprecated after v3.32.0-0.0.pre.', + ) + this.groupValue, + @Deprecated( + 'Use RadioGroup to handle value change instead. ' + 'This feature was deprecated after v3.32.0-0.0.pre.', + ) + this.onChanged, this.mouseCursor, this.toggleable = false, this.activeColor, @@ -102,6 +104,8 @@ class Radio extends StatelessWidget { this.visualDensity, this.focusNode, this.autofocus = false, + this.enabled, + this.groupRegistry, }) : _radioType = _RadioType.material, useCupertinoCheckmarkStyle = false; @@ -124,8 +128,16 @@ class Radio extends StatelessWidget { const Radio.adaptive({ super.key, required this.value, - required this.groupValue, - required this.onChanged, + @Deprecated( + 'Use a RadioGroup ancestor to manage group value instead. ' + 'This feature was deprecated after v3.32.0-0.0.pre.', + ) + this.groupValue, + @Deprecated( + 'Use RadioGroup to handle value change instead. ' + 'This feature was deprecated after v3.32.0-0.0.pre.', + ) + this.onChanged, this.mouseCursor, this.toggleable = false, this.activeColor, @@ -139,15 +151,46 @@ class Radio extends StatelessWidget { this.focusNode, this.autofocus = false, this.useCupertinoCheckmarkStyle = false, + this.enabled, + this.groupRegistry, }) : _radioType = _RadioType.adaptive; /// {@macro flutter.widget.RawRadio.value} final T value; - /// {@macro flutter.widget.RawRadio.groupValue} + /// {@template flutter.material.Radio.groupValue} + /// The currently selected value for a group of radio buttons. + /// + /// This radio button is considered selected if its [value] matches the + /// [groupValue]. + /// + /// This is deprecated, use [RadioGroup] to manage group value instead. + /// {@endtemplate} + @Deprecated( + 'Use a RadioGroup ancestor to manage group value instead. ' + 'This feature was deprecated after v3.32.0-0.0.pre.', + ) final T? groupValue; - /// {@macro flutter.widget.RawRadio.onChanged} + /// {@template flutter.material.Radio.onChanged} + /// Called when the user selects this radio button. + /// + /// The radio button passes [value] as a parameter to this callback. The radio + /// button does not actually change state until the parent widget rebuilds the + /// radio button with the new [groupValue]. + /// + /// If null, the radio button will be displayed as disabled. + /// + /// The provided callback will not be invoked if this radio button is already + /// selected and [toggleable] is not set to true. + /// + /// If the [toggleable] is set to true, tapping a already selected radio will + /// invoke this callback with `null` as value. + /// + /// The callback provided to [onChanged] should update the state of the parent + /// [StatefulWidget] using the [State.setState] method, so that the parent + /// gets rebuilt. + /// {@endtemplate} /// /// For example: /// @@ -162,6 +205,12 @@ class Radio extends StatelessWidget { /// }, /// ) /// ``` + /// + /// This is deprecated, use [RadioGroup] to handle value change instead. + @Deprecated( + 'Use RadioGroup to handle value change instead. ' + 'This feature was deprecated after v3.32.0-0.0.pre.', + ) final ValueChanged? onChanged; /// {@macro flutter.widget.RawRadio.mouseCursor} @@ -205,8 +254,6 @@ class Radio extends StatelessWidget { /// ```dart /// Radio( /// value: 1, - /// groupValue: 1, - /// onChanged: (_){}, /// fillColor: WidgetStateProperty.resolveWith((Set states) { /// if (states.contains(WidgetState.disabled)) { /// return Colors.orange.withOpacity(.32); @@ -328,12 +375,75 @@ class Radio extends StatelessWidget { /// Defaults to false. final bool useCupertinoCheckmarkStyle; + /// {@macro flutter.widget.RawRadio.groupRegistry} + /// + /// Unless provided, the [BuildContext] will be used to look up the ancestor + /// [RadioGroupRegistry]. + final RadioGroupRegistry? groupRegistry; + final _RadioType _radioType; + /// {@template flutter.material.Radio.enabled} + /// Whether this widget is interactive. + /// + /// If not provided, this widget will be interactable if one of the following + /// is true: + /// + /// * A [onChanged] is provided. + /// * Having a [RadioGroup] with the same type T above this widget. + /// * A [groupRegistry] is provided. + /// + /// If this is set to true, one of the above condition must also be true. + /// Otherwise, an assertion error is thrown. + /// {@endtemplate} + final bool? enabled; + + @override + State> createState() => _RadioState(); +} + +class _RadioState extends State> { + FocusNode? _internalFocusNode; + FocusNode get _focusNode => widget.focusNode ?? (_internalFocusNode ??= FocusNode()); + + bool get _enabled => + widget.enabled ?? + (widget.onChanged != null || + widget.groupRegistry != null || + RadioGroup.maybeOf(context) != null); + + _RadioRegistry? _internalRadioRegistry; + RadioGroupRegistry get _effectiveRegistry { + if (widget.groupRegistry != null) { + return widget.groupRegistry!; + } + + final RadioGroupRegistry? inheritedRegistry = RadioGroup.maybeOf(context); + if (inheritedRegistry != null) { + return inheritedRegistry; + } + + // Handles deprecated API. + return _internalRadioRegistry ??= _RadioRegistry(this); + } + + @override + void dispose() { + _internalFocusNode?.dispose(); + super.dispose(); + } + @override Widget build(BuildContext context) { + assert( + !(widget.enabled ?? false) || + widget.onChanged != null || + widget.groupRegistry != null || + RadioGroup.maybeOf(context) != null, + 'Radio is enabled but has no Radio.onChange or registry above', + ); assert(debugCheckHasMaterial(context)); - switch (_radioType) { + switch (widget._radioType) { case _RadioType.material: break; @@ -348,16 +458,18 @@ class Radio extends StatelessWidget { case TargetPlatform.iOS: case TargetPlatform.macOS: return CupertinoRadio( - value: value, - groupValue: groupValue, - onChanged: onChanged, - mouseCursor: mouseCursor, - toggleable: toggleable, - activeColor: activeColor, - focusColor: focusColor, - focusNode: focusNode, - autofocus: autofocus, - useCheckmarkStyle: useCupertinoCheckmarkStyle, + value: widget.value, + groupValue: widget.groupValue, + onChanged: widget.onChanged, + mouseCursor: widget.mouseCursor, + toggleable: widget.toggleable, + activeColor: widget.activeColor, + focusColor: widget.focusColor, + focusNode: _focusNode, + autofocus: widget.autofocus, + useCheckmarkStyle: widget.useCupertinoCheckmarkStyle, + groupRegistry: _effectiveRegistry, + enabled: _enabled, ); } } @@ -365,39 +477,56 @@ class Radio extends StatelessWidget { final RadioThemeData radioTheme = RadioTheme.of(context); final MaterialStateProperty effectiveMouseCursor = MaterialStateProperty.resolveWith((Set states) { - return MaterialStateProperty.resolveAs(mouseCursor, states) ?? + return MaterialStateProperty.resolveAs(widget.mouseCursor, states) ?? radioTheme.mouseCursor?.resolve(states) ?? MaterialStateProperty.resolveAs( MaterialStateMouseCursor.clickable, states, ); }); - return RawRadio( - value: value, - groupValue: groupValue, - onChanged: onChanged, + value: widget.value, mouseCursor: effectiveMouseCursor, - toggleable: toggleable, - focusNode: focusNode, - autofocus: autofocus, + toggleable: widget.toggleable, + focusNode: _focusNode, + autofocus: widget.autofocus, + groupRegistry: _effectiveRegistry, + enabled: _enabled, builder: (BuildContext context, ToggleableStateMixin state) { return _RadioPaint( toggleableState: state, - activeColor: activeColor, - fillColor: fillColor, - hoverColor: hoverColor, - focusColor: focusColor, - overlayColor: overlayColor, - splashRadius: splashRadius, - visualDensity: visualDensity, - materialTapTargetSize: materialTapTargetSize, + activeColor: widget.activeColor, + fillColor: widget.fillColor, + hoverColor: widget.hoverColor, + focusColor: widget.focusColor, + overlayColor: widget.overlayColor, + splashRadius: widget.splashRadius, + visualDensity: widget.visualDensity, + materialTapTargetSize: widget.materialTapTargetSize, ); }, ); } } +/// A registry for deprecated API. +// TODO(chunhtai): Remove this once deprecated API is removed. +class _RadioRegistry extends RadioGroupRegistry { + _RadioRegistry(this.state); + final _RadioState state; + @override + T? get groupValue => state.widget.groupValue; + + @override + ValueChanged get onChanged => state.widget.onChanged!; + + @override + void registerClient(RadioClient radio) {} + + @override + void unregisterClient(RadioClient radio) {} +} + class _RadioPaint extends StatefulWidget { const _RadioPaint({ required this.toggleableState, diff --git a/packages/flutter/lib/src/material/radio_list_tile.dart b/packages/flutter/lib/src/material/radio_list_tile.dart index 7da8f90fe98..8bad89ed564 100644 --- a/packages/flutter/lib/src/material/radio_list_tile.dart +++ b/packages/flutter/lib/src/material/radio_list_tile.dart @@ -37,10 +37,9 @@ enum _RadioType { material, adaptive } /// The entire list tile is interactive: tapping anywhere in the tile selects /// the radio button. /// -/// The [value], [groupValue], [onChanged], and [activeColor] properties of this -/// widget are identical to the similarly-named properties on the [Radio] -/// widget. The type parameter `T` serves the same purpose as that of the -/// [Radio] class' type parameter. +/// This widget typically has a [RadioGroup] ancestor, which takes in a +/// [RadioGroup.groupValue], and the [RadioListTile] under it with matching +/// [value] will be selected. /// /// The [title], [subtitle], [isThreeLine], and [dense] properties are like /// those of the same name on [ListTile]. @@ -67,15 +66,13 @@ enum _RadioType { material, adaptive } /// /// {@tool snippet} /// ```dart -/// ColoredBox( +/// const ColoredBox( /// color: Colors.green, /// child: Material( /// child: RadioListTile( /// tileColor: Colors.red, -/// title: const Text('AM'), -/// groupValue: Meridiem.am, +/// title: Text('AM'), /// value: Meridiem.am, -/// onChanged:(Meridiem? value) { }, /// ), /// ), /// ) @@ -88,9 +85,6 @@ enum _RadioType { material, adaptive } /// is expensive. Consider only wrapping the [RadioListTile]s that require it /// or include a common [Material] ancestor where possible. /// -/// To show the [RadioListTile] as disabled, pass null as the [onChanged] -/// callback. -/// /// {@tool dartpad} /// ![RadioListTile sample](https://flutter.github.io/assets-for-api-docs/assets/material/radio_list_tile.png) /// @@ -157,25 +151,27 @@ enum _RadioType { material, adaptive } /// * [CheckboxListTile], a similar widget for checkboxes. /// * [SwitchListTile], a similar widget for switches. /// * [ListTile] and [Radio], the widgets from which this widget is made. -class RadioListTile extends StatelessWidget { +class RadioListTile extends StatefulWidget { /// Creates a combination of a list tile and a radio button. /// - /// The radio tile itself does not maintain any state. Instead, when the radio - /// button is selected, the widget calls the [onChanged] callback. Most - /// widgets that use a radio button will listen for the [onChanged] callback - /// and rebuild the radio tile with a new [groupValue] to update the visual - /// appearance of the radio button. + /// This widget typically has a [RadioGroup] ancestor, which takes in a + /// [RadioGroup.groupValue], and the [RadioListTile] under it with matching + /// [value] will be selected. /// - /// The following arguments are required: - /// - /// * [value] and [groupValue] together determine whether the radio button is - /// selected. - /// * [onChanged] is called when the user selects this radio button. + /// [value] must be provided const RadioListTile({ super.key, required this.value, - required this.groupValue, - required this.onChanged, + @Deprecated( + 'Use a RadioGroup ancestor to manage group value instead. ' + 'This feature was deprecated after v3.32.0-0.0.pre.', + ) + this.groupValue, + @Deprecated( + 'Use RadioGroup to handle value change instead. ' + 'This feature was deprecated after v3.32.0-0.0.pre.', + ) + this.onChanged, this.mouseCursor, this.toggleable = false, this.activeColor, @@ -202,6 +198,7 @@ class RadioListTile extends StatelessWidget { this.enableFeedback, this.radioScaleFactor = 1.0, this.titleAlignment, + this.enabled, this.internalAddSemanticForOnTap = false, }) : _radioType = _RadioType.material, useCupertinoCheckmarkStyle = false, @@ -216,8 +213,16 @@ class RadioListTile extends StatelessWidget { const RadioListTile.adaptive({ super.key, required this.value, - required this.groupValue, - required this.onChanged, + @Deprecated( + 'Use a RadioGroup ancestor to manage group value instead. ' + 'This feature was deprecated after v3.32.0-0.0.pre.', + ) + this.groupValue, + @Deprecated( + 'Use RadioGroup to handle value change instead. ' + 'This feature was deprecated after v3.32.0-0.0.pre.', + ) + this.onChanged, this.mouseCursor, this.toggleable = false, this.activeColor, @@ -243,6 +248,7 @@ class RadioListTile extends StatelessWidget { this.onFocusChange, this.enableFeedback, this.radioScaleFactor = 1.0, + this.enabled, this.useCupertinoCheckmarkStyle = false, this.titleAlignment, this.internalAddSemanticForOnTap = false, @@ -256,6 +262,12 @@ class RadioListTile extends StatelessWidget { /// /// This radio button is considered selected if its [value] matches the /// [groupValue]. + /// + /// leave this unassigned or null if building this widget under [RadioGroup]. + @Deprecated( + 'Use a RadioGroup ancestor to manage group value instead. ' + 'This feature was deprecated after v3.32.0-0.0.pre.', + ) final T? groupValue; /// Called when the user selects this radio button. @@ -285,6 +297,10 @@ class RadioListTile extends StatelessWidget { /// }, /// ) /// ``` + @Deprecated( + 'Use RadioGroup to handle value change instead. ' + 'This feature was deprecated after v3.32.0-0.0.pre.', + ) final ValueChanged? onChanged; /// The cursor for a mouse pointer when it enters or is hovering over the @@ -307,14 +323,14 @@ class RadioListTile extends StatelessWidget { /// To indicate returning to an indeterminate state, [onChanged] will be /// called with null. /// - /// If true, [onChanged] is called with [value] when selected while - /// [groupValue] != [value], and with null when selected again while - /// [groupValue] == [value]. + /// If true, [RadioGroup.onChanged] is called with [value] when selected while + /// [RadioGroup.groupValue] != [value], and with null when selected again while + /// [RadioGroup.groupValue] == [value]. /// - /// If false, [onChanged] will be called with [value] when it is selected - /// while [groupValue] != [value], and only by selecting another radio button - /// in the group (i.e. changing the value of [groupValue]) can this radio - /// list tile be unselected. + /// If false, [RadioGroup.onChanged] will be called with [value] when it is + /// selected while [groupValue] != [value], and only by selecting another + /// radio button in the group (i.e. changing the value of + /// [RadioGroup.groupValue]) can this radio list tile be unselected. /// /// The default is false. /// @@ -402,7 +418,7 @@ class RadioListTile extends StatelessWidget { /// No effort is made to automatically coordinate the [selected] state and the /// [checked] state. To have the list tile appear selected when the radio /// button is the selected radio button, set [selected] to true when [value] - /// matches [groupValue]. + /// matches [RadioGroup.groupValue]. /// /// Normally, this property is left to its default value, false. final bool selected; @@ -421,11 +437,6 @@ class RadioListTile extends StatelessWidget { /// When null, `EdgeInsets.symmetric(horizontal: 16.0)` is used. final EdgeInsetsGeometry? contentPadding; - /// Whether this radio button is checked. - /// - /// To control this value, set [value] and [groupValue] appropriately. - bool get checked => value == groupValue; - /// If specified, [shape] defines the shape of the [RadioListTile]'s [InkWell] border. final ShapeBorder? shape; @@ -492,100 +503,203 @@ class RadioListTile extends StatelessWidget { /// Defaults to 1.0. final double radioScaleFactor; + /// Whether this widget is interactable. + /// + /// If not provided, this widget will be interactable if one of the following + /// is true: + /// + /// * A [onChanged] is provided. + /// * Having a [RadioGroup] with the same type T above this widget. + /// + /// If this is set to true, one of the above condition must also be true. + /// Otherwise, an assertion error is thrown. + final bool? enabled; + + /// Whether this radio button is checked. + /// + /// To control this value, set [value] and [groupValue] appropriately. + @Deprecated( + 'Use RadioGroup.groupValue to find which radio is checked. ' + 'This feature was deprecated after v3.32.0-0.0.pre.', + ) + bool get checked => value == groupValue; + + @override + State> createState() => _RadioListTileState(); +} + +class _RadioListTileState extends State> with RadioClient { + FocusNode? _internalFocusNode; + @override + FocusNode get focusNode => widget.focusNode ?? (_internalFocusNode ??= FocusNode()); + + @override + T get radioValue => widget.value; + + @override + bool get tristate => widget.toggleable; + + bool get checked => radioValue == effectiveGroupValue; + + late final _RadioRegistry _radioRegistry = _RadioRegistry(this); + + T? get effectiveGroupValue => registry?.groupValue ?? widget.groupValue; + + bool get _enabled => widget.enabled ?? (widget.onChanged != null || registry != null); + + void _handleListTileTap() { + if (!widget.toggleable && checked) { + return; + } + T? newValue; + if (checked) { + newValue = null; + } else { + newValue = radioValue; + } + handleChange(newValue); + } + + void handleChange(T? value) { + if (registry != null) { + registry!.onChanged(value); + } + + if (widget.onChanged != null) { + widget.onChanged!(value); + } + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + registry = RadioGroup.maybeOf(context); + } + + @override + void dispose() { + registry = null; + _internalFocusNode?.dispose(); + super.dispose(); + } + @override Widget build(BuildContext context) { + assert( + !(widget.enabled ?? false) || + widget.onChanged != null || + RadioGroup.maybeOf(context) != null, + 'Radio is enabled but has no RadioListTile.onChange or registry above', + ); Widget control; - switch (_radioType) { + switch (widget._radioType) { case _RadioType.material: control = ExcludeFocus( child: Radio( - value: value, - groupValue: groupValue, - onChanged: onChanged, - toggleable: toggleable, - activeColor: activeColor, - materialTapTargetSize: materialTapTargetSize ?? MaterialTapTargetSize.shrinkWrap, - autofocus: autofocus, - fillColor: fillColor, - mouseCursor: mouseCursor, - hoverColor: hoverColor, - overlayColor: overlayColor, - splashRadius: splashRadius, + value: radioValue, + groupValue: _radioRegistry.groupValue, + toggleable: widget.toggleable, + activeColor: widget.activeColor, + materialTapTargetSize: widget.materialTapTargetSize ?? MaterialTapTargetSize.shrinkWrap, + autofocus: widget.autofocus, + fillColor: widget.fillColor, + mouseCursor: widget.mouseCursor, + hoverColor: widget.hoverColor, + overlayColor: widget.overlayColor, + splashRadius: widget.splashRadius, + enabled: _enabled, + groupRegistry: _radioRegistry, ), ); case _RadioType.adaptive: control = ExcludeFocus( child: Radio.adaptive( - value: value, - groupValue: groupValue, - onChanged: onChanged, - toggleable: toggleable, - activeColor: activeColor, - materialTapTargetSize: materialTapTargetSize ?? MaterialTapTargetSize.shrinkWrap, - autofocus: autofocus, - fillColor: fillColor, - mouseCursor: mouseCursor, - hoverColor: hoverColor, - overlayColor: overlayColor, - splashRadius: splashRadius, - useCupertinoCheckmarkStyle: useCupertinoCheckmarkStyle, + value: radioValue, + groupValue: _radioRegistry.groupValue, + toggleable: widget.toggleable, + activeColor: widget.activeColor, + materialTapTargetSize: widget.materialTapTargetSize ?? MaterialTapTargetSize.shrinkWrap, + autofocus: widget.autofocus, + fillColor: widget.fillColor, + mouseCursor: widget.mouseCursor, + hoverColor: widget.hoverColor, + overlayColor: widget.overlayColor, + splashRadius: widget.splashRadius, + useCupertinoCheckmarkStyle: widget.useCupertinoCheckmarkStyle, + enabled: _enabled, + groupRegistry: _radioRegistry, ), ); } - if (radioScaleFactor != 1.0) { - control = Transform.scale(scale: radioScaleFactor, child: control); + if (widget.radioScaleFactor != 1.0) { + control = Transform.scale(scale: widget.radioScaleFactor, child: control); } final ListTileThemeData listTileTheme = ListTileTheme.of(context); final ListTileControlAffinity effectiveControlAffinity = - controlAffinity ?? listTileTheme.controlAffinity ?? ListTileControlAffinity.platform; + widget.controlAffinity ?? listTileTheme.controlAffinity ?? ListTileControlAffinity.platform; Widget? leading, trailing; (leading, trailing) = switch (effectiveControlAffinity) { - ListTileControlAffinity.leading || ListTileControlAffinity.platform => (control, secondary), - ListTileControlAffinity.trailing => (secondary, control), + ListTileControlAffinity.leading || + ListTileControlAffinity.platform => (control, widget.secondary), + ListTileControlAffinity.trailing => (widget.secondary, control), }; - final ThemeData theme = Theme.of(context); final RadioThemeData radioThemeData = RadioTheme.of(context); - final Set states = {if (selected) MaterialState.selected}; + final Set states = {if (widget.selected) MaterialState.selected}; final Color effectiveActiveColor = - activeColor ?? radioThemeData.fillColor?.resolve(states) ?? theme.colorScheme.secondary; + widget.activeColor ?? + radioThemeData.fillColor?.resolve(states) ?? + theme.colorScheme.secondary; return MergeSemantics( child: ListTile( selectedColor: effectiveActiveColor, leading: leading, - title: title, - subtitle: subtitle, + title: widget.title, + subtitle: widget.subtitle, trailing: trailing, - isThreeLine: isThreeLine, - dense: dense, - enabled: onChanged != null, - shape: shape, - tileColor: tileColor, - selectedTileColor: selectedTileColor, - onTap: - onChanged != null - ? () { - if (toggleable && checked) { - onChanged!(null); - return; - } - if (!checked) { - onChanged!(value); - } - } - : null, - selected: selected, - autofocus: autofocus, - contentPadding: contentPadding, - visualDensity: visualDensity, + isThreeLine: widget.isThreeLine, + dense: widget.dense, + enabled: _enabled, + shape: widget.shape, + tileColor: widget.tileColor, + selectedTileColor: widget.selectedTileColor, + onTap: _enabled ? _handleListTileTap : null, + selected: widget.selected, + autofocus: widget.autofocus, + contentPadding: widget.contentPadding, + visualDensity: widget.visualDensity, focusNode: focusNode, - onFocusChange: onFocusChange, - enableFeedback: enableFeedback, - titleAlignment: titleAlignment, - internalAddSemanticForOnTap: internalAddSemanticForOnTap, + onFocusChange: widget.onFocusChange, + enableFeedback: widget.enableFeedback, + titleAlignment: widget.titleAlignment, + internalAddSemanticForOnTap: widget.internalAddSemanticForOnTap, ), ); } } + +/// A registry to controls internal [Radio] and hides it from [RadioGroup] +/// ancestor. +/// +/// [RadioListTile] implements the [RadioClient] directly to register to +/// [RadioGroup] ancestor. Therefore, it has to hide the internal [Radio] from +/// participate in the [RadioGroup] ancestor. +class _RadioRegistry extends RadioGroupRegistry { + _RadioRegistry(this.state); + + final _RadioListTileState state; + + @override + T? get groupValue => state.effectiveGroupValue; + + @override + ValueChanged get onChanged => state.handleChange; + + @override + void registerClient(RadioClient radio) {} + + @override + void unregisterClient(RadioClient radio) {} +} diff --git a/packages/flutter/lib/src/widgets/focus_traversal.dart b/packages/flutter/lib/src/widgets/focus_traversal.dart index 64dbe58bf8d..4463589240e 100644 --- a/packages/flutter/lib/src/widgets/focus_traversal.dart +++ b/packages/flutter/lib/src/widgets/focus_traversal.dart @@ -1570,10 +1570,41 @@ class ReadingOrderTraversalPolicy extends FocusTraversalPolicy /// {@macro flutter.widgets.FocusTraversalPolicy.requestFocusCallback} ReadingOrderTraversalPolicy({super.requestFocusCallback}); + /// Sorts the input focus nodes into reading order. + static Iterable sort(Iterable nodes) { + if (nodes.length <= 1) { + return nodes; + } + + final List<_ReadingOrderSortData> data = <_ReadingOrderSortData>[ + for (final FocusNode node in nodes) _ReadingOrderSortData(node), + ]; + + final List sortedList = []; + final List<_ReadingOrderSortData> unplaced = data; + + // Pick the initial widget as the one that is at the beginning of the band + // of the topmost, or the topmost, if there are no others in its band. + _ReadingOrderSortData current = _pickNext(unplaced); + sortedList.add(current.node); + unplaced.remove(current); + + // Go through each node, picking the next one after eliminating the previous + // one, since removing the previously picked node will expose a new band in + // which to choose candidates. + while (unplaced.isNotEmpty) { + final _ReadingOrderSortData next = _pickNext(unplaced); + current = next; + sortedList.add(current.node); + unplaced.remove(current); + } + return sortedList; + } + // Collects the given candidates into groups by directionality. The candidates // have already been sorted as if they all had the directionality of the // nearest Directionality ancestor. - List<_ReadingOrderDirectionalGroupData> _collectDirectionalityGroups( + static List<_ReadingOrderDirectionalGroupData> _collectDirectionalityGroups( Iterable<_ReadingOrderSortData> candidates, ) { TextDirection? currentDirection = candidates.first.directionality; @@ -1602,7 +1633,7 @@ class ReadingOrderTraversalPolicy extends FocusTraversalPolicy return result; } - _ReadingOrderSortData _pickNext(List<_ReadingOrderSortData> candidates) { + static _ReadingOrderSortData _pickNext(List<_ReadingOrderSortData> candidates) { // Find the topmost node by sorting on the top of the rectangles. mergeSort<_ReadingOrderSortData>( candidates, @@ -1674,35 +1705,8 @@ class ReadingOrderTraversalPolicy extends FocusTraversalPolicy // Sorts the list of nodes based on their geometry into the desired reading // order based on the directionality of the context for each node. @override - Iterable sortDescendants(Iterable descendants, FocusNode currentNode) { - if (descendants.length <= 1) { - return descendants; - } - - final List<_ReadingOrderSortData> data = <_ReadingOrderSortData>[ - for (final FocusNode node in descendants) _ReadingOrderSortData(node), - ]; - - final List sortedList = []; - final List<_ReadingOrderSortData> unplaced = data; - - // Pick the initial widget as the one that is at the beginning of the band - // of the topmost, or the topmost, if there are no others in its band. - _ReadingOrderSortData current = _pickNext(unplaced); - sortedList.add(current.node); - unplaced.remove(current); - - // Go through each node, picking the next one after eliminating the previous - // one, since removing the previously picked node will expose a new band in - // which to choose candidates. - while (unplaced.isNotEmpty) { - final _ReadingOrderSortData next = _pickNext(unplaced); - current = next; - sortedList.add(current.node); - unplaced.remove(current); - } - return sortedList; - } + Iterable sortDescendants(Iterable descendants, FocusNode currentNode) => + sort(descendants); } /// Base class for all sort orders for [OrderedTraversalPolicy] traversal. diff --git a/packages/flutter/lib/src/widgets/radio_group.dart b/packages/flutter/lib/src/widgets/radio_group.dart new file mode 100644 index 00000000000..5086cb12aeb --- /dev/null +++ b/packages/flutter/lib/src/widgets/radio_group.dart @@ -0,0 +1,327 @@ +// Copyright 2014 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'dart:ui' show SemanticsRole; + +import 'package:collection/collection.dart'; +import 'package:flutter/services.dart'; + +import 'actions.dart'; +import 'basic.dart'; +import 'focus_manager.dart'; +import 'focus_traversal.dart'; +import 'framework.dart'; +import 'shortcuts.dart'; + +/// A group for radios. +/// +/// This widget treats all radios, such as [RawRadio], [Radio], [CupertinoRadio] +/// in the sub tree with the same type T as a group. Radios with different types +/// are not included in the group. +/// +/// This widget handles the group value for the radios in the subtree with the +/// same value type. +/// +/// Using this widget also provides keyboard navigation and semantics for the +/// radio buttons that matches [APG](https://www.w3.org/WAI/ARIA/apg/patterns/radio/). +/// +/// The keyboard behaviors are: +/// * Tab and Shift+Tab: moves focus into and out of radio group. When focus +/// moves into a radio group and a radio button is select, focus is set on +/// selected button. Otherwise, it focus the first radio button in reading +/// order. +/// * Space: toggle the selection on the focused radio button. +/// * Right and down arrow key: move selection to next radio button in the group +/// in reading order. +/// * Left and up arrow key: move selection to previous radio button in the +/// group in reading order. +/// +/// Arrow keys will wrap around if it reach the first or last radio in the +/// group. +/// +/// {@tool dartpad} +/// Here is an example of RadioGroup widget. +/// +/// Try using tab, arrow keys, and space to see how the widget responds. +/// +/// ** See code in examples/api/lib/widgets/radio_group/radio_group.0.dart ** +/// {@end-tool} +class RadioGroup extends StatefulWidget { + /// Creates a radio group. + /// + /// The `groupValue` set the selection on a subtree radio with the same + /// [RawRadio.value]. + /// + /// The `onChanged` is called when the selection has changed in the subtree + /// radios. + const RadioGroup({super.key, this.groupValue, required this.onChanged, required this.child}); + + /// The selected value under this radio group. + /// + /// [RawRadio] under this radio group where its [RawRadio.value] equals to this + /// value will be selected. + final T? groupValue; + + /// Called when selection has changed. + /// + /// The value can be null when unselect the [RawRadio] with + /// [RawRadio.toggleable] set to true. + final ValueChanged onChanged; + + /// {@macro flutter.widgets.ProxyWidget.child} + final Widget child; + + /// Gets the [RadioGroupRegistry] from the above the context. + /// + /// This registers a dependencies on the context that it causes rebuild + /// if [RadioGroupRegistry] has changed or its + /// [RadioGroupRegistry.groupValue] has changed. + static RadioGroupRegistry? maybeOf(BuildContext context) { + return context.dependOnInheritedWidgetOfExactType<_RadioGroupStateScope>()?.state; + } + + @override + State createState() => _RadioGroupState(); +} + +class _RadioGroupState extends State> implements RadioGroupRegistry { + late final Map _radioGroupShortcuts = { + const SingleActivator(LogicalKeyboardKey.arrowLeft): VoidCallbackIntent(_selectPreviousRadio), + const SingleActivator(LogicalKeyboardKey.arrowRight): VoidCallbackIntent(_selectNextRadio), + const SingleActivator(LogicalKeyboardKey.arrowDown): VoidCallbackIntent(_selectNextRadio), + const SingleActivator(LogicalKeyboardKey.arrowUp): VoidCallbackIntent(_selectPreviousRadio), + const SingleActivator(LogicalKeyboardKey.space): VoidCallbackIntent(_toggleFocusedRadio), + }; + + final Set> _radios = >{}; + + bool _debugCheckOnlySingleSelection() { + return _radios.where((RadioClient radio) => radio.radioValue == groupValue).length < 2; + } + + @override + T? get groupValue => widget.groupValue; + + @override + void registerClient(RadioClient radio) { + _radios.add(radio); + assert( + _debugCheckOnlySingleSelection(), + "RadioGroupPolicy can't be used for a radio group that allows multiple selection", + ); + } + + @override + void unregisterClient(RadioClient radio) => _radios.remove(radio); + + void _toggleFocusedRadio() { + final RadioClient? radio = _radios.firstWhereOrNull( + (RadioClient radio) => radio.focusNode.hasFocus, + ); + if (radio == null) { + return; + } + if (radio.radioValue != widget.groupValue) { + onChanged(radio.radioValue); + return; + } + + if (radio.tristate) { + onChanged(null); + } + } + + @override + ValueChanged get onChanged => widget.onChanged; + + void _selectNextRadio() => _selectRadioInDirection(true); + + void _selectPreviousRadio() => _selectRadioInDirection(false); + + void _selectRadioInDirection(bool forward) { + if (_radios.length < 2) { + return; + } + final FocusNode? currentFocus = + _radios.firstWhereOrNull((RadioClient radio) => radio.focusNode.hasFocus)?.focusNode; + if (currentFocus == null) { + // The focused node is either a non interactive radio or other controls. + return; + } + final List sorted = + ReadingOrderTraversalPolicy.sort( + _radios.map((RadioClient radio) => radio.focusNode), + ).toList(); + final Iterable nodesInEffectiveOrder = forward ? sorted : sorted.reversed; + + final Iterator iterator = nodesInEffectiveOrder.iterator; + FocusNode? nextFocus; + while (iterator.moveNext()) { + if (iterator.current == currentFocus) { + if (iterator.moveNext()) { + nextFocus = iterator.current; + } + break; + } + } + // Current focus is at the end, the next focus should wrap around. + nextFocus ??= nodesInEffectiveOrder.first; + final RadioClient radioToSelect = _radios.firstWhere( + (RadioClient radio) => radio.focusNode == nextFocus, + ); + onChanged(radioToSelect.radioValue); + nextFocus.requestFocus(); + } + + @override + Widget build(BuildContext context) { + return Semantics( + role: SemanticsRole.radioGroup, + child: Shortcuts( + shortcuts: _radioGroupShortcuts, + child: FocusTraversalGroup( + policy: _SkipUnselectedRadioPolicy(_radios, widget.groupValue), + child: _RadioGroupStateScope( + state: this, + groupValue: widget.groupValue, + child: widget.child, + ), + ), + ), + ); + } +} + +class _RadioGroupStateScope extends InheritedWidget { + const _RadioGroupStateScope({required this.state, required this.groupValue, required super.child}) + : super(); + final _RadioGroupState state; + // Need to include group value to notify listener when group value changes. + final T? groupValue; + + @override + bool updateShouldNotify(covariant _RadioGroupStateScope oldWidget) { + return state != oldWidget.state || groupValue != oldWidget.groupValue; + } +} + +/// An abstract interface for registering a group of radios. +/// +/// Use [registerClient] or [unregisterClient] to handle registrations of radios. +/// +/// The registry manages the group value for the radios. The radio needs to call +/// [onChanged] to notify the group value needs to be changed. +abstract class RadioGroupRegistry { + /// The group value for the group. + T? get groupValue; + + /// Registers a radio client. + /// + /// The subclass provides additional features, such as keyboard navigation + /// for the registered clients. + void registerClient(RadioClient radio); + + /// Unregisters a radio client. + void unregisterClient(RadioClient radio); + + /// Notifies the registry that the a radio is selected or unselected. + ValueChanged get onChanged; +} + +/// A client for a [RadioGroupRegistry]. +/// +/// This is typically mixed with a [State]. +/// +/// To register to a [RadioGroupRegistry], assign the registry to [registry]. +/// +/// To unregister from previous [RadioGroupRegistry], either assign a different +/// value to [registry] or set it to null. +mixin RadioClient { + /// Whether this radio support toggles. + /// + /// Used by registry to provide additional feature such as keyboard support. + bool get tristate; + + /// This value this radio represents. + /// + /// Used by registry to provide additional feature such as keyboard support. + T get radioValue; + + /// Focus node for this radio. + /// + /// Used by registry to provide additional feature such as keyboard support. + FocusNode get focusNode; + + /// The [RadioGroupRegistry] this client register to. + /// + /// Setting this property automatically register to the new value and + /// unregister the old value. + /// + /// This should set to null when dispose. + RadioGroupRegistry? get registry => _registry; + RadioGroupRegistry? _registry; + set registry(RadioGroupRegistry? newRegistry) { + if (_registry != newRegistry) { + _registry?.unregisterClient(this); + } + _registry = newRegistry; + _registry?.registerClient(this); + } +} + +/// A traversal policy that is the same as [ReadingOrderTraversalPolicy] except +/// it skips nodes of unselected radio button if there is one selected radio +/// button. +/// +/// If none of the radio is selected, this defaults to +/// [ReadingOrderTraversalPolicy] for all nodes. +/// +/// This policy is to ensure when tabbing into a radio group, it will only focus +/// the current selected radio button and prevent focus from reaching unselected +/// ones. +class _SkipUnselectedRadioPolicy extends ReadingOrderTraversalPolicy { + _SkipUnselectedRadioPolicy(this.radios, this.groupValue); + final Set> radios; + final T? groupValue; + + bool _radioSelected(RadioClient radio) => radio.radioValue == groupValue; + + @override + Iterable sortDescendants(Iterable descendants, FocusNode currentNode) { + final Iterable nodesInReadOrder = super.sortDescendants(descendants, currentNode); + RadioClient? selected = radios.firstWhereOrNull(_radioSelected); + + if (selected == null) { + // None of the radio are selected. Select the first radio in read order. + final Map> radioFocusNodes = >{}; + for (final RadioClient radio in radios) { + radioFocusNodes[radio.focusNode] = radio; + } + + for (final FocusNode node in nodesInReadOrder) { + selected = radioFocusNodes[node]; + if (selected != null) { + break; + } + } + } + + if (selected == null) { + // None of the radio is selected or focusable, defaults to reading order + return nodesInReadOrder; + } + + // Nodes that are not selected AND not currently focused, since we can't + // remove the focused node from the sorted result. + final Set nodeToSkip = + radios + .where((RadioClient radio) => selected != radio && radio.focusNode != currentNode) + .map((RadioClient radio) => radio.focusNode) + .toSet(); + final Iterable skipsNonSelected = descendants.where( + (FocusNode node) => !nodeToSkip.contains(node), + ); + return super.sortDescendants(skipsNonSelected, currentNode); + } +} diff --git a/packages/flutter/lib/src/widgets/raw_radio.dart b/packages/flutter/lib/src/widgets/raw_radio.dart index 27f1a27601e..1378b7fb17d 100644 --- a/packages/flutter/lib/src/widgets/raw_radio.dart +++ b/packages/flutter/lib/src/widgets/raw_radio.dart @@ -7,6 +7,7 @@ import 'package:flutter/foundation.dart'; import 'basic.dart'; import 'focus_manager.dart'; import 'framework.dart'; +import 'radio_group.dart'; import 'ticker_provider.dart'; import 'toggleable.dart'; import 'widget_state.dart'; @@ -30,11 +31,9 @@ typedef RadioBuilder = Widget Function(BuildContext context, ToggleableStateMixi /// group cease to be selected. The values are of type `T`, the type parameter /// of the radio class. Enums are commonly used for this purpose. /// -/// The radio button itself does not maintain any state. Instead, selecting the -/// radio invokes the [onChanged] callback, passing [value] as a parameter. If -/// [groupValue] and [value] match, this radio will be selected. Most widgets -/// will respond to [onChanged] by calling [State.setState] to update the -/// radio button's [groupValue]. +/// {@macro flutter.widget.RawRadio.groupValue} +/// +/// If [enabled] is false, the radio will not be interactive. /// /// See also: /// @@ -44,57 +43,24 @@ typedef RadioBuilder = Widget Function(BuildContext context, ToggleableStateMixi class RawRadio extends StatefulWidget { /// Creates a radio button. /// - /// The radio button itself does not maintain any state. Instead, when the - /// radio button is selected, the widget calls the [onChanged] callback. Most - /// widgets that use a radio button will listen for the [onChanged] callback - /// and rebuild the radio button with a new [groupValue] to update the visual - /// appearance of the radio button. + /// If [enabled] is true, the [groupRegistry] must not be null. const RawRadio({ super.key, required this.value, - required this.groupValue, - required this.onChanged, required this.mouseCursor, required this.toggleable, required this.focusNode, required this.autofocus, + required this.groupRegistry, + required this.enabled, required this.builder, - }); + }) : assert(!enabled || groupRegistry != null, 'an enabled raw radio must have a registry'); /// {@template flutter.widget.RawRadio.value} /// The value represented by this radio button. /// {@endtemplate} final T value; - /// {@template flutter.widget.RawRadio.groupValue} - /// The currently selected value for a group of radio buttons. - /// - /// This radio button is considered selected if its [value] matches the - /// [groupValue]. - /// {@endtemplate} - final T? groupValue; - - /// {@template flutter.widget.RawRadio.onChanged} - /// Called when the user selects this radio button. - /// - /// The radio button passes [value] as a parameter to this callback. The radio - /// button does not actually change state until the parent widget rebuilds the - /// radio button with the new [groupValue]. - /// - /// If null, the radio button will be displayed as disabled. - /// - /// The provided callback will not be invoked if this radio button is already - /// selected and [toggleable] is not set to true. - /// - /// If the [toggleable] is set to true, tapping a already selected radio will - /// invoke this callback with `null` as value. - /// - /// The callback provided to [onChanged] should update the state of the parent - /// [StatefulWidget] using the [State.setState] method, so that the parent - /// gets rebuilt. - /// {@endtemplate} - final ValueChanged? onChanged; - /// {@template flutter.widget.RawRadio.mouseCursor} /// The cursor for a mouse pointer when it enters or is hovering over the /// widget. @@ -113,24 +79,24 @@ class RawRadio extends StatefulWidget { /// Set to true if this radio button is allowed to be returned to an /// indeterminate state by selecting it again when selected. /// - /// To indicate returning to an indeterminate state, [onChanged] will be - /// called with null. + /// To indicate returning to an indeterminate state, [RadioGroup.onChanged] + /// of the [RadioGroup] above the widget tree will be called with null. /// - /// If true, [onChanged] is called with [value] when selected while - /// [groupValue] != [value], and with null when selected again while - /// [groupValue] == [value]. + /// If true, [RadioGroup.onChanged] is called with [value] when selected while + /// [RadioGroup.groupValue] != [value], and with null when selected again while + /// [RadioGroup.groupValue] == [value]. /// - /// If false, [onChanged] will be called with [value] when it is selected - /// while [groupValue] != [value], and only by selecting another radio button - /// in the group (i.e. changing the value of [groupValue]) can this radio - /// button be unselected. + /// If false, [RadioGroup.onChanged] will be called with [value] when it is + /// selected while [RadioGroup.groupValue] != [value], and only by selecting + /// another radio button in the group (i.e. changing the value of + /// [RadioGroup.groupValue]) can this radio button be unselected. /// /// The default is false. /// {@endtemplate} final bool toggleable; /// {@macro flutter.widgets.Focus.focusNode} - final FocusNode? focusNode; + final FocusNode focusNode; /// {@macro flutter.widgets.Focus.autofocus} final bool autofocus; @@ -142,40 +108,86 @@ class RawRadio extends StatefulWidget { /// {@macro flutter.widgets.ToggleableStateMixin.buildToggleableWithChild} final RadioBuilder builder; - bool get _selected => value == groupValue; + /// Whether this widget is enabled. + final bool enabled; + + /// {@template flutter.widget.RawRadio.groupRegistry} + /// The registry this radio registers to. + /// {@endtemplate} + /// + /// {@template flutter.widget.RawRadio.groupValue} + /// The radio relies on [groupRegistry] to maintains the state for selection. + /// If use in conjunction with a [RadioGroup] widget, use [RadioGroup.maybeOf] + /// to get the group registry from the context. + /// {@endtemplate} + final RadioGroupRegistry? groupRegistry; @override State> createState() => _RawRadioState(); } class _RawRadioState extends State> - with TickerProviderStateMixin, ToggleableStateMixin { + with TickerProviderStateMixin, ToggleableStateMixin, RadioClient { + @override + FocusNode get focusNode => widget.focusNode; + + @override + T get radioValue => widget.value; + + @override + void initState() { + // This has to be before the init state because the [ToggleableStateMixin] + // expect the [value] is up-to-date when init its state. + registry = widget.groupRegistry; + super.initState(); + } + + /// Handle selection status changed. + /// + /// if `selected` is false, nothing happens. + /// + /// if `selected` is true, select this radio. i.e. [Radio.onChanged] is called + /// with [Radio.value]. This also updates the group value in [RadioGroup] if it + /// is in use. + /// + /// if `selected` is null, unselect this radio. Same as `selected` is true + /// except group value is set to null. void _handleChanged(bool? selected) { - if (selected == null) { - widget.onChanged!(null); + assert(registry != null); + if (!(selected ?? true)) { return; } - if (selected) { - widget.onChanged!(widget.value); + if (selected ?? false) { + registry!.onChanged(widget.value); + } else { + registry!.onChanged(null); } } @override void didUpdateWidget(RawRadio oldWidget) { super.didUpdateWidget(oldWidget); - if (widget._selected != oldWidget._selected) { - animateToValue(); - } + registry = widget.groupRegistry; + animateToValue(); // The registry's group value may have changed } @override - ValueChanged? get onChanged => widget.onChanged != null ? _handleChanged : null; + void dispose() { + super.dispose(); + registry = null; + } + + @override + ValueChanged? get onChanged => registry != null ? _handleChanged : null; @override bool get tristate => widget.toggleable; @override - bool? get value => widget._selected; + bool? get value => widget.value == registry?.groupValue; + + @override + bool get isInteractive => widget.enabled; @override Widget build(BuildContext context) { @@ -188,15 +200,15 @@ class _RawRadioState extends State> accessibilitySelected = null; case TargetPlatform.iOS: case TargetPlatform.macOS: - accessibilitySelected = widget._selected; + accessibilitySelected = value; } return Semantics( inMutuallyExclusiveGroup: true, - checked: widget._selected, + checked: value, selected: accessibilitySelected, child: buildToggleableWithChild( - focusNode: widget.focusNode, + focusNode: focusNode, autofocus: widget.autofocus, mouseCursor: widget.mouseCursor, child: widget.builder(context, this), diff --git a/packages/flutter/lib/widgets.dart b/packages/flutter/lib/widgets.dart index 2a3d585f4a2..efe2858c674 100644 --- a/packages/flutter/lib/widgets.dart +++ b/packages/flutter/lib/widgets.dart @@ -104,6 +104,7 @@ export 'src/widgets/platform_view.dart'; export 'src/widgets/pop_scope.dart'; export 'src/widgets/preferred_size.dart'; export 'src/widgets/primary_scroll_controller.dart'; +export 'src/widgets/radio_group.dart'; export 'src/widgets/raw_keyboard_listener.dart'; export 'src/widgets/raw_menu_anchor.dart'; export 'src/widgets/raw_radio.dart'; diff --git a/packages/flutter/test/cupertino/radio_test.dart b/packages/flutter/test/cupertino/radio_test.dart index 967f7bf637a..7e049ac28ac 100644 --- a/packages/flutter/test/cupertino/radio_test.dart +++ b/packages/flutter/test/cupertino/radio_test.dart @@ -53,17 +53,36 @@ void main() { expect(log, isEmpty); + await tester.pumpWidget( + CupertinoApp(home: Center(child: CupertinoRadio(key: key, value: 1, groupValue: 2))), + ); + + await tester.tap(find.byKey(key)); + + expect(log, isEmpty); + }); + + testWidgets('Radio disabled', (WidgetTester tester) async { + final Key key = UniqueKey(); + final List log = []; + await tester.pumpWidget( CupertinoApp( home: Center( - child: CupertinoRadio(key: key, value: 1, groupValue: 2, onChanged: null), + child: CupertinoRadio( + key: key, + value: 1, + groupValue: 2, + enabled: false, + onChanged: log.add, + ), ), ), ); await tester.tap(find.byKey(key)); - expect(log, isEmpty); + expect(log, equals([])); }); testWidgets('Radio can be toggled when toggleable is set', (WidgetTester tester) async { @@ -111,13 +130,7 @@ void main() { await tester.pumpWidget( CupertinoApp( home: Center( - child: CupertinoRadio( - key: key, - value: 1, - groupValue: null, - onChanged: log.add, - toggleable: true, - ), + child: CupertinoRadio(key: key, value: 1, onChanged: log.add, toggleable: true), ), ), ); @@ -204,9 +217,7 @@ void main() { ); await tester.pumpWidget( - const CupertinoApp( - home: Center(child: CupertinoRadio(value: 1, groupValue: 2, onChanged: null)), - ), + const CupertinoApp(home: Center(child: CupertinoRadio(value: 1, groupValue: 2))), ); expect( @@ -233,9 +244,7 @@ void main() { ); await tester.pumpWidget( - const CupertinoApp( - home: Center(child: CupertinoRadio(value: 2, groupValue: 2, onChanged: null)), - ), + const CupertinoApp(home: Center(child: CupertinoRadio(value: 2, groupValue: 2))), ); expect( @@ -531,7 +540,7 @@ void main() { return CupertinoApp( home: Center( child: RepaintBoundary( - child: CupertinoRadio(value: value, groupValue: groupValue, onChanged: null), + child: CupertinoRadio(value: value, groupValue: groupValue), ), ), ); @@ -558,7 +567,7 @@ void main() { theme: const CupertinoThemeData(brightness: Brightness.dark), home: Center( child: RepaintBoundary( - child: CupertinoRadio(value: value, groupValue: groupValue, onChanged: null), + child: CupertinoRadio(value: value, groupValue: groupValue), ), ), ); @@ -892,12 +901,7 @@ void main() { await tester.pumpWidget( const CupertinoApp( home: Center( - child: CupertinoRadio( - value: 1, - groupValue: 1, - onChanged: null, - mouseCursor: _RadioMouseCursor(), - ), + child: CupertinoRadio(value: 1, groupValue: 1, mouseCursor: _RadioMouseCursor()), ), ), ); diff --git a/packages/flutter/test/material/radio_list_tile_test.dart b/packages/flutter/test/material/radio_list_tile_test.dart index dd75cc7f0ed..1fd31952a27 100644 --- a/packages/flutter/test/material/radio_list_tile_test.dart +++ b/packages/flutter/test/material/radio_list_tile_test.dart @@ -27,8 +27,7 @@ void main() { int? selectedValue; // Constructor parameters are required for [RadioListTile], but they are // irrelevant when searching with [find.byType]. - final Type radioListTileType = - const RadioListTile(value: 0, groupValue: 0, onChanged: null).runtimeType; + final Type radioListTileType = const RadioListTile(value: 0, groupValue: 0).runtimeType; List> generatedRadioListTiles; List> findTiles() => @@ -126,7 +125,6 @@ void main() { key: key, value: 1, groupValue: 2, - onChanged: null, title: Text('Title', key: titleKey), ), ), @@ -158,7 +156,7 @@ void main() { int? selectedValue; // Constructor parameters are required for [Radio], but they are irrelevant // when searching with [find.byType]. - final Type radioType = const Radio(value: 0, groupValue: 0, onChanged: null).runtimeType; + final Type radioType = const Radio(value: 0, groupValue: 0).runtimeType; final List log = []; Widget buildFrame() { @@ -223,7 +221,7 @@ void main() { int? selectedValue; // Constructor parameters are required for [Radio], but they are irrelevant // when searching with [find.byType]. - final Type radioType = const Radio(value: 0, groupValue: 0, onChanged: null).runtimeType; + final Type radioType = const Radio(value: 0, groupValue: 0).runtimeType; final List log = []; Widget buildFrame() { @@ -273,7 +271,7 @@ void main() { int? selectedValue; // Constructor parameters are required for [Radio], but they are irrelevant // when searching with [find.byType]. - final Type radioType = const Radio(value: 0, groupValue: 0, onChanged: null).runtimeType; + final Type radioType = const Radio(value: 0, groupValue: 0).runtimeType; final List log = []; Widget buildFrame() { @@ -362,15 +360,7 @@ void main() { await tester.pumpWidget( Material( - child: Center( - child: Radio( - key: key, - value: 1, - groupValue: null, - onChanged: log.add, - toggleable: true, - ), - ), + child: Center(child: Radio(key: key, value: 1, onChanged: log.add, toggleable: true)), ), ); @@ -466,7 +456,6 @@ void main() { child: const RadioListTile( value: 1, groupValue: 2, - onChanged: null, title: Text('Title'), internalAddSemanticForOnTap: true, ), @@ -504,7 +493,6 @@ void main() { child: const RadioListTile( value: 2, groupValue: 2, - onChanged: null, title: Text('Title'), internalAddSemanticForOnTap: true, ), @@ -607,7 +595,6 @@ void main() { child: RadioListTile( value: 1, groupValue: 2, - onChanged: null, title: Text('Title', key: childKey), autofocus: true, ), @@ -619,8 +606,7 @@ void main() { }); testWidgets('RadioListTile contentPadding test', (WidgetTester tester) async { - final Type radioType = - const Radio(groupValue: true, value: true, onChanged: null).runtimeType; + final Type radioType = const Radio(groupValue: true, value: true).runtimeType; await tester.pumpWidget( wrap( @@ -666,7 +652,6 @@ void main() { child: RadioListTile( value: true, groupValue: true, - onChanged: null, title: Text('Title'), shape: shapeBorder, ), @@ -686,7 +671,6 @@ void main() { child: RadioListTile( value: false, groupValue: true, - onChanged: null, title: const Text('Title'), tileColor: tileColor, ), @@ -706,7 +690,6 @@ void main() { child: RadioListTile( value: false, groupValue: true, - onChanged: null, title: const Text('Title'), selected: true, selectedTileColor: selectedTileColor, @@ -887,7 +870,7 @@ void main() { wrap( child: const MouseRegion( cursor: SystemMouseCursors.forbidden, - child: RadioListTile(value: 1, onChanged: null, groupValue: 2), + child: RadioListTile(value: 1, groupValue: 2), ), ), ); @@ -1610,9 +1593,7 @@ void main() { testWidgets('RadioListTile renders with default scale', (WidgetTester tester) async { await tester.pumpWidget( const MaterialApp( - home: Material( - child: RadioListTile(value: false, groupValue: false, onChanged: null), - ), + home: Material(child: RadioListTile(value: false, groupValue: false)), ), ); @@ -1629,12 +1610,7 @@ void main() { await tester.pumpWidget( const MaterialApp( home: Material( - child: RadioListTile( - value: false, - groupValue: false, - onChanged: null, - radioScaleFactor: scale, - ), + child: RadioListTile(value: false, groupValue: false, radioScaleFactor: scale), ), ), ); @@ -1668,7 +1644,6 @@ void main() { title: const Text('A'), subtitle: const Text('A\nB\nC\nD\nE\nF\nG\nH\nI\nJ\nK\nL\nM'), value: 0, - onChanged: null, groupValue: 1, ), RadioListTile( @@ -1676,7 +1651,6 @@ void main() { title: const Text('A'), subtitle: const Text('A'), value: 0, - onChanged: null, groupValue: 2, ), ], @@ -1787,7 +1761,6 @@ void main() { title: const Text('A'), subtitle: const Text('A\nB\nC\nD\nE\nF\nG\nH\nI\nJ\nK\nL\nM'), value: 0, - onChanged: null, groupValue: 1, ), RadioListTile.adaptive( @@ -1795,7 +1768,6 @@ void main() { title: const Text('A'), subtitle: const Text('A'), value: 0, - onChanged: null, groupValue: 2, ), ], diff --git a/packages/flutter/test/material/radio_test.dart b/packages/flutter/test/material/radio_test.dart index 283be4b5510..b05d31b7ff4 100644 --- a/packages/flutter/test/material/radio_test.dart +++ b/packages/flutter/test/material/radio_test.dart @@ -63,9 +63,7 @@ void main() { await tester.pumpWidget( Theme( data: theme, - child: Material( - child: Center(child: Radio(key: key, value: 1, groupValue: 2, onChanged: null)), - ), + child: Material(child: Center(child: Radio(key: key, value: 1, groupValue: 2))), ), ); @@ -74,6 +72,32 @@ void main() { expect(log, isEmpty); }); + testWidgets('Radio disabled', (WidgetTester tester) async { + final Key key = UniqueKey(); + final List log = []; + + await tester.pumpWidget( + Theme( + data: theme, + child: Material( + child: Center( + child: Radio( + key: key, + value: 1, + groupValue: 2, + enabled: false, + onChanged: log.add, + ), + ), + ), + ), + ); + + await tester.tap(find.byKey(key)); + + expect(log, equals([])); + }); + testWidgets('Radio can be toggled when toggleable is set', (WidgetTester tester) async { final Key key = UniqueKey(); final List log = []; @@ -127,13 +151,7 @@ void main() { data: theme, child: Material( child: Center( - child: Radio( - key: key, - value: 1, - groupValue: null, - onChanged: log.add, - toggleable: true, - ), + child: Radio(key: key, value: 1, onChanged: log.add, toggleable: true), ), ), ), @@ -291,10 +309,7 @@ void main() { ); await tester.pumpWidget( - Theme( - data: theme, - child: const Material(child: Radio(value: 1, groupValue: 2, onChanged: null)), - ), + Theme(data: theme, child: const Material(child: Radio(value: 1, groupValue: 2))), ); expect( @@ -343,10 +358,7 @@ void main() { ); await tester.pumpWidget( - Theme( - data: theme, - child: const Material(child: Radio(value: 2, groupValue: 2, onChanged: null)), - ), + Theme(data: theme, child: const Material(child: Radio(value: 2, groupValue: 2))), ); expect( @@ -1091,7 +1103,7 @@ void main() { child: Material( child: MouseRegion( cursor: SystemMouseCursors.forbidden, - child: Radio(value: 1, onChanged: null, groupValue: 2), + child: Radio(value: 1, groupValue: 2), ), ), ), @@ -1566,7 +1578,7 @@ void main() { home: const Material( child: Tooltip( message: longPressTooltip, - child: Radio(value: true, groupValue: false, onChanged: null), + child: Radio(value: true, groupValue: false), ), ), ), @@ -1596,7 +1608,7 @@ void main() { child: Tooltip( triggerMode: TooltipTriggerMode.tap, message: tapTooltip, - child: Radio(value: true, groupValue: false, onChanged: null), + child: Radio(value: true, groupValue: false), ), ), ), diff --git a/packages/flutter/test/widgets/radio_group_test.dart b/packages/flutter/test/widgets/radio_group_test.dart new file mode 100644 index 00000000000..f69895e0c36 --- /dev/null +++ b/packages/flutter/test/widgets/radio_group_test.dart @@ -0,0 +1,248 @@ +// Copyright 2014 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('Radio group control test', (WidgetTester tester) async { + final UniqueKey key0 = UniqueKey(); + final UniqueKey key1 = UniqueKey(); + + await tester.pumpWidget( + Material( + child: TestRadioGroup( + child: Column( + children: [Radio(key: key0, value: 0), Radio(key: key1, value: 1)], + ), + ), + ), + ); + expect( + tester.getSemantics(find.byKey(key0)), + containsSemantics(isInMutuallyExclusiveGroup: true, isChecked: false, isEnabled: true), + ); + expect( + tester.getSemantics(find.byKey(key1)), + containsSemantics(isInMutuallyExclusiveGroup: true, isChecked: false, isEnabled: true), + ); + + await tester.tap(find.byKey(key0)); + await tester.pumpAndSettle(); + expect( + tester.getSemantics(find.byKey(key0)), + containsSemantics(isInMutuallyExclusiveGroup: true, isChecked: true, isEnabled: true), + ); + expect( + tester.getSemantics(find.byKey(key1)), + containsSemantics(isInMutuallyExclusiveGroup: true, isChecked: false, isEnabled: true), + ); + + await tester.tap(find.byKey(key1)); + await tester.pumpAndSettle(); + expect( + tester.getSemantics(find.byKey(key0)), + containsSemantics(isInMutuallyExclusiveGroup: true, isChecked: false, isEnabled: true), + ); + expect( + tester.getSemantics(find.byKey(key1)), + containsSemantics(isInMutuallyExclusiveGroup: true, isChecked: true, isEnabled: true), + ); + }); + + testWidgets('Radio group can have disabled radio', (WidgetTester tester) async { + final UniqueKey key0 = UniqueKey(); + final UniqueKey key1 = UniqueKey(); + + await tester.pumpWidget( + Material( + child: TestRadioGroup( + child: Column( + children: [ + Radio(key: key0, value: 0, enabled: false), + Radio(key: key1, value: 1), + ], + ), + ), + ), + ); + expect( + tester.getSemantics(find.byKey(key0)), + containsSemantics(isInMutuallyExclusiveGroup: true, isChecked: false, isEnabled: false), + ); + expect( + tester.getSemantics(find.byKey(key1)), + containsSemantics(isInMutuallyExclusiveGroup: true, isChecked: false, isEnabled: true), + ); + + await tester.tap(find.byKey(key0)); + await tester.pumpAndSettle(); + // Can't be select because the radio is disabled. + expect( + tester.getSemantics(find.byKey(key0)), + containsSemantics(isInMutuallyExclusiveGroup: true, isChecked: false, isEnabled: false), + ); + expect( + tester.getSemantics(find.byKey(key1)), + containsSemantics(isInMutuallyExclusiveGroup: true, isChecked: false, isEnabled: true), + ); + }); + + testWidgets('Radio group can use arrow key', (WidgetTester tester) async { + final UniqueKey key0 = UniqueKey(); + final UniqueKey key1 = UniqueKey(); + final UniqueKey key2 = UniqueKey(); + final FocusNode focusNode = FocusNode(); + addTearDown(focusNode.dispose); + await tester.pumpWidget( + MaterialApp( + home: Material( + child: TestRadioGroup( + child: Column( + children: [ + Radio(key: key0, focusNode: focusNode, value: 0), + Radio(key: key1, value: 1), + Radio(key: key2, value: 2), + ], + ), + ), + ), + ), + ); + + final TestRadioGroupState state = tester.state>( + find.byType(TestRadioGroup), + ); + + await tester.tap(find.byKey(key0)); + focusNode.requestFocus(); + await tester.pumpAndSettle(); + expect(state.groupValue, 0); + expect(focusNode.hasFocus, isTrue); + + await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); + await tester.pumpAndSettle(); + expect(state.groupValue, 1); + + await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight); + await tester.pumpAndSettle(); + expect(state.groupValue, 2); + + await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight); + await tester.pumpAndSettle(); + // Wrap around + expect(state.groupValue, 0); + + await tester.sendKeyEvent(LogicalKeyboardKey.arrowLeft); + await tester.pumpAndSettle(); + // Wrap around + expect(state.groupValue, 2); + + await tester.sendKeyEvent(LogicalKeyboardKey.arrowUp); + await tester.pumpAndSettle(); + // Wrap around + expect(state.groupValue, 1); + }); + + testWidgets('Radio group can tab in and out', (WidgetTester tester) async { + final UniqueKey key0 = UniqueKey(); + final UniqueKey key1 = UniqueKey(); + final UniqueKey key2 = UniqueKey(); + final FocusNode radio0 = FocusNode(); + addTearDown(radio0.dispose); + final FocusNode radio1 = FocusNode(); + addTearDown(radio1.dispose); + final FocusNode textFieldBefore = FocusNode(); + addTearDown(textFieldBefore.dispose); + final FocusNode textFieldAfter = FocusNode(); + addTearDown(textFieldAfter.dispose); + await tester.pumpWidget( + MaterialApp( + home: Material( + child: Column( + children: [ + TextField(focusNode: textFieldBefore), + TestRadioGroup( + child: Column( + children: [ + Radio(key: key0, focusNode: radio0, value: 0), + Radio(key: key1, focusNode: radio1, value: 1), + Radio(key: key2, value: 2), + ], + ), + ), + TextField(focusNode: textFieldAfter), + ], + ), + ), + ), + ); + + textFieldBefore.requestFocus(); + await tester.pump(); + expect(textFieldBefore.hasFocus, isTrue); + + await tester.sendKeyEvent(LogicalKeyboardKey.tab); + await tester.pump(); + // If no selected radio, focus the first. + expect(textFieldBefore.hasFocus, isFalse); + expect(radio0.hasFocus, isTrue); + + // tab out the radio group. + await tester.sendKeyEvent(LogicalKeyboardKey.tab); + await tester.pump(); + expect(radio0.hasFocus, isFalse); + expect(radio1.hasFocus, isFalse); + expect(textFieldAfter.hasFocus, isTrue); + + // Select the radio 1 + await tester.tap(find.byKey(key1)); + await tester.pump(); + final TestRadioGroupState state = tester.state>( + find.byType(TestRadioGroup), + ); + expect(state.groupValue, 1); + // focus textFieldAfter again. + textFieldAfter.requestFocus(); + await tester.pump(); + expect(textFieldAfter.hasFocus, isTrue); + + // shift+tab in the radio again. + await tester.sendKeyDownEvent(LogicalKeyboardKey.shift); + await tester.sendKeyEvent(LogicalKeyboardKey.tab); + await tester.sendKeyUpEvent(LogicalKeyboardKey.shift); + await tester.pump(); + // Should focus selected radio + expect(radio0.hasFocus, isFalse); + expect(radio1.hasFocus, isTrue); + expect(textFieldAfter.hasFocus, isFalse); + }); +} + +class TestRadioGroup extends StatefulWidget { + const TestRadioGroup({super.key, required this.child}); + + final Widget child; + + @override + State createState() => TestRadioGroupState(); +} + +class TestRadioGroupState extends State> { + T? groupValue; + + @override + Widget build(BuildContext context) { + return RadioGroup( + onChanged: (T? newValue) { + setState(() { + groupValue = newValue; + }); + }, + groupValue: groupValue, + child: widget.child, + ); + } +} diff --git a/packages/flutter/test/widgets/raw_radio_test.dart b/packages/flutter/test/widgets/raw_radio_test.dart index 6e151053100..19faf69b748 100644 --- a/packages/flutter/test/widgets/raw_radio_test.dart +++ b/packages/flutter/test/widgets/raw_radio_test.dart @@ -14,20 +14,18 @@ void main() { testWidgets('RawRadio control test', (WidgetTester tester) async { final FocusNode node = FocusNode(); addTearDown(node.dispose); - int? actualValue; ToggleableStateMixin? actualState; + final TestRegistry registry = TestRegistry(); Widget buildWidget() { return RawRadio( value: 0, - groupValue: actualValue, - onChanged: (int? value) { - actualValue = value; - }, mouseCursor: WidgetStateProperty.all(SystemMouseCursors.click), toggleable: true, focusNode: node, autofocus: false, + enabled: true, + groupRegistry: registry, builder: (BuildContext context, ToggleableStateMixin state) { actualState = state; return CustomPaint(size: const Size(40, 40), painter: TestPainter()); @@ -38,14 +36,88 @@ void main() { await tester.pumpWidget(buildWidget()); expect(actualState!.tristate, isTrue); expect(actualState!.value, isFalse); + expect(registry.groupValue, isNull); + + final State state = tester.state(find.byType(RawRadio)); + expect(registry.clients.contains(state as RadioClient), isTrue); await tester.tap(find.byType(RawRadio)); // Rebuilds with new group value await tester.pumpWidget(buildWidget()); - expect(actualValue, 0); + expect(registry.groupValue, 0); expect(actualState!.value, isTrue); }); + + testWidgets('RawRadio disabled', (WidgetTester tester) async { + final FocusNode node = FocusNode(); + addTearDown(node.dispose); + final TestRegistry registry = TestRegistry(); + + Widget buildWidget() { + return RawRadio( + value: 0, + mouseCursor: WidgetStateProperty.all(SystemMouseCursors.click), + toggleable: true, + focusNode: node, + autofocus: false, + enabled: false, + groupRegistry: registry, + builder: (BuildContext context, ToggleableStateMixin state) { + return CustomPaint(size: const Size(40, 40), painter: TestPainter()); + }, + ); + } + + await tester.pumpWidget(buildWidget()); + await tester.tap(find.byType(RawRadio)); + // onChanged won't fire + expect(registry.groupValue, isNull); + }); + + testWidgets('RawRadio enabled without registry throws', (WidgetTester tester) async { + final FocusNode node = FocusNode(); + addTearDown(node.dispose); + + Widget buildWidget() { + return RawRadio( + value: 0, + mouseCursor: WidgetStateProperty.all(SystemMouseCursors.click), + toggleable: true, + focusNode: node, + autofocus: false, + enabled: true, + groupRegistry: null, + builder: (BuildContext context, ToggleableStateMixin state) { + return CustomPaint(size: const Size(40, 40), painter: TestPainter()); + }, + ); + } + + Object? error; + try { + await tester.pumpWidget(buildWidget()); + } catch (e) { + error = e; + } + + expect(error, isA()); + }); +} + +class TestRegistry extends RadioGroupRegistry { + final Set> clients = >{}; + @override + T? groupValue; + + @override + ValueChanged get onChanged => (T? newValue) => groupValue = newValue; + + @override + void registerClient(RadioClient radio) => clients.add(radio); + + @override + void unregisterClient(RadioClient radio) => clients.remove(radio); } class TestPainter extends CustomPainter {