From cd6e3b1ac223a719e6813d01920c46c516bd1ce8 Mon Sep 17 00:00:00 2001 From: Ian Hickson Date: Fri, 19 May 2017 08:46:17 -0700 Subject: [PATCH] CheckboxListTile, RadioListTile, SwitchListTile (#10160) --- .../flutter_gallery/lib/gallery/drawer.dart | 122 +++------- packages/flutter/lib/material.dart | 3 + .../flutter/lib/src/material/checkbox.dart | 23 +- .../lib/src/material/checkbox_list_tile.dart | 204 ++++++++++++++++ .../flutter/lib/src/material/icon_theme.dart | 4 +- .../flutter/lib/src/material/list_tile.dart | 64 ++++- packages/flutter/lib/src/material/radio.dart | 28 ++- .../lib/src/material/radio_list_tile.dart | 229 ++++++++++++++++++ packages/flutter/lib/src/material/switch.dart | 14 +- .../lib/src/material/switch_list_tile.dart | 190 +++++++++++++++ packages/flutter/lib/src/widgets/image.dart | 1 + packages/flutter/lib/src/widgets/text.dart | 2 +- .../test/material/control_list_tile_test.dart | 118 +++++++++ .../test/widgets/semantics_tester.dart | 19 +- 14 files changed, 898 insertions(+), 123 deletions(-) create mode 100644 packages/flutter/lib/src/material/checkbox_list_tile.dart create mode 100644 packages/flutter/lib/src/material/radio_list_tile.dart create mode 100644 packages/flutter/lib/src/material/switch_list_tile.dart create mode 100644 packages/flutter/test/material/control_list_tile_test.dart diff --git a/examples/flutter_gallery/lib/gallery/drawer.dart b/examples/flutter_gallery/lib/gallery/drawer.dart index fdb8904e21..e0ff3e3e6a 100644 --- a/examples/flutter_gallery/lib/gallery/drawer.dart +++ b/examples/flutter_gallery/lib/gallery/drawer.dart @@ -132,77 +132,52 @@ class GalleryDrawer extends StatelessWidget { final TextStyle aboutTextStyle = themeData.textTheme.body2; final TextStyle linkStyle = themeData.textTheme.body2.copyWith(color: themeData.accentColor); - final Widget lightThemeItem = new ListTile( - leading: const Icon(Icons.brightness_5), + final Widget lightThemeItem = new RadioListTile( + secondary: const Icon(Icons.brightness_5), title: const Text('Light'), - trailing: new Radio( - value: true, - groupValue: useLightTheme, - onChanged: onThemeChanged, - ), + value: true, + groupValue: useLightTheme, + onChanged: onThemeChanged, selected: useLightTheme, - onTap: () { - onThemeChanged(true); - }, ); - final Widget darkThemeItem = new ListTile( - leading: const Icon(Icons.brightness_7), + final Widget darkThemeItem = new RadioListTile( + secondary: const Icon(Icons.brightness_7), title: const Text('Dark'), - trailing: new Radio( - value: false, - groupValue: useLightTheme, - onChanged: onThemeChanged - ), + value: false, + groupValue: useLightTheme, + onChanged: onThemeChanged, selected: !useLightTheme, - onTap: () { - onThemeChanged(false); - }, ); - final Widget mountainViewItem = new ListTile( + final Widget mountainViewItem = new RadioListTile( // on iOS, we don't want to show an Android phone icon - leading: new Icon(defaultTargetPlatform == TargetPlatform.iOS ? Icons.star : Icons.phone_android), + secondary: new Icon(defaultTargetPlatform == TargetPlatform.iOS ? Icons.star : Icons.phone_android), title: const Text('Android'), - trailing: new Radio( - value: TargetPlatform.android, - groupValue: Theme.of(context).platform, - onChanged: onPlatformChanged, - ), + value: TargetPlatform.android, + groupValue: Theme.of(context).platform, + onChanged: onPlatformChanged, selected: Theme.of(context).platform == TargetPlatform.android, - onTap: () { - onPlatformChanged(TargetPlatform.android); - }, ); - final Widget cupertinoItem = new ListTile( + final Widget cupertinoItem = new RadioListTile( // on iOS, we don't want to show the iPhone icon - leading: new Icon(defaultTargetPlatform == TargetPlatform.iOS ? Icons.star_border : Icons.phone_iphone), + secondary: new Icon(defaultTargetPlatform == TargetPlatform.iOS ? Icons.star_border : Icons.phone_iphone), title: const Text('iOS'), - trailing: new Radio( - value: TargetPlatform.iOS, - groupValue: Theme.of(context).platform, - onChanged: onPlatformChanged, - ), + value: TargetPlatform.iOS, + groupValue: Theme.of(context).platform, + onChanged: onPlatformChanged, selected: Theme.of(context).platform == TargetPlatform.iOS, - onTap: () { - onPlatformChanged(TargetPlatform.iOS); - }, ); - final Widget animateSlowlyItem = new ListTile( - leading: const Icon(Icons.hourglass_empty), + final Widget animateSlowlyItem = new CheckboxListTile( title: const Text('Animate Slowly'), - trailing: new Checkbox( - value: timeDilation != 1.0, - onChanged: (bool value) { - onTimeDilationChanged(value ? 20.0 : 1.0); - }, - ), - selected: timeDilation != 1.0, - onTap: () { - onTimeDilationChanged(timeDilation != 1.0 ? 1.0 : 20.0); + value: timeDilation != 1.0, + onChanged: (bool value) { + onTimeDilationChanged(value ? 20.0 : 1.0); }, + secondary: const Icon(Icons.hourglass_empty), + selected: timeDilation != 1.0, ); final Widget sendFeedbackItem = new ListTile( @@ -271,53 +246,32 @@ class GalleryDrawer extends StatelessWidget { ]; if (onShowPerformanceOverlayChanged != null) { - allDrawerItems.insert(8, new ListTile( - leading: const Icon(Icons.assessment), + allDrawerItems.insert(8, new CheckboxListTile( title: const Text('Performance Overlay'), - trailing: new Checkbox( - value: showPerformanceOverlay, - onChanged: (bool value) { - onShowPerformanceOverlayChanged(!showPerformanceOverlay); - }, - ), + value: showPerformanceOverlay, + onChanged: onShowPerformanceOverlayChanged, + secondary: const Icon(Icons.assessment), selected: showPerformanceOverlay, - onTap: () { - onShowPerformanceOverlayChanged(!showPerformanceOverlay); - }, )); } if (onCheckerboardRasterCacheImagesChanged != null) { - allDrawerItems.insert(8, new ListTile( - leading: const Icon(Icons.assessment), + allDrawerItems.insert(8, new CheckboxListTile( title: const Text('Checkerboard Raster Cache Images'), - trailing: new Checkbox( - value: checkerboardRasterCacheImages, - onChanged: (bool value) { - onCheckerboardRasterCacheImagesChanged(!checkerboardRasterCacheImages); - }, - ), + value: checkerboardRasterCacheImages, + onChanged: onCheckerboardRasterCacheImagesChanged, + secondary: const Icon(Icons.assessment), selected: checkerboardRasterCacheImages, - onTap: () { - onCheckerboardRasterCacheImagesChanged(!checkerboardRasterCacheImages); - }, )); } if (onCheckerboardOffscreenLayersChanged != null) { - allDrawerItems.insert(8, new ListTile( - leading: const Icon(Icons.assessment), + allDrawerItems.insert(8, new CheckboxListTile( title: const Text('Checkerboard Offscreen Layers'), - trailing: new Checkbox( - value: checkerboardOffscreenLayers, - onChanged: (bool value) { - onCheckerboardOffscreenLayersChanged(!checkerboardOffscreenLayers); - }, - ), + value: checkerboardOffscreenLayers, + onChanged: onCheckerboardOffscreenLayersChanged, + secondary: const Icon(Icons.assessment), selected: checkerboardOffscreenLayers, - onTap: () { - onCheckerboardOffscreenLayersChanged(!checkerboardOffscreenLayers); - }, )); } diff --git a/packages/flutter/lib/material.dart b/packages/flutter/lib/material.dart index 049c82f1c6..141b396f82 100644 --- a/packages/flutter/lib/material.dart +++ b/packages/flutter/lib/material.dart @@ -25,6 +25,7 @@ export 'src/material/button.dart'; export 'src/material/button_bar.dart'; export 'src/material/card.dart'; export 'src/material/checkbox.dart'; +export 'src/material/checkbox_list_tile.dart'; export 'src/material/chip.dart'; export 'src/material/circle_avatar.dart'; export 'src/material/colors.dart'; @@ -65,6 +66,7 @@ export 'src/material/paginated_data_table.dart'; export 'src/material/popup_menu.dart'; export 'src/material/progress_indicator.dart'; export 'src/material/radio.dart'; +export 'src/material/radio_list_tile.dart'; export 'src/material/raised_button.dart'; export 'src/material/refresh_indicator.dart'; export 'src/material/scaffold.dart'; @@ -74,6 +76,7 @@ export 'src/material/slider.dart'; export 'src/material/snack_bar.dart'; export 'src/material/stepper.dart'; export 'src/material/switch.dart'; +export 'src/material/switch_list_tile.dart'; export 'src/material/tab_controller.dart'; export 'src/material/tabs.dart'; export 'src/material/text_field.dart'; diff --git a/packages/flutter/lib/src/material/checkbox.dart b/packages/flutter/lib/src/material/checkbox.dart index 1d6c04e454..e38b3ed18e 100644 --- a/packages/flutter/lib/src/material/checkbox.dart +++ b/packages/flutter/lib/src/material/checkbox.dart @@ -13,7 +13,7 @@ import 'debug.dart'; import 'theme.dart'; import 'toggleable.dart'; -/// A material design checkbox +/// A material design checkbox. /// /// The checkbox itself does not maintain any state. Instead, when the state of /// the checkbox changes, the widget calls the [onChanged] callback. Most @@ -25,7 +25,9 @@ import 'toggleable.dart'; /// /// See also: /// -/// * [Switch], another widget with similar semantics. +/// * [CheckboxListTile], which combines this widget with a [ListTile] so that +/// you can give the checkbox a label. +/// * [Switch], a widget with semantics similar to [Checkbox]. /// * [Radio], for selecting among a set of explicit values. /// * [Slider], for selecting a value in a range. /// * @@ -39,16 +41,23 @@ class Checkbox extends StatefulWidget { /// rebuild the checkbox with a new [value] to update the visual appearance of /// the checkbox. /// - /// * [value] determines whether the checkbox is checked. - /// * [onChanged] is called when the value of the checkbox should change. + /// The following arguments are required: + /// + /// * [value], which determines whether the checkbox is checked, and must not + /// be null. + /// * [onChanged], which is called when the value of the checkbox should + /// change. It can be set to null to disable the checkbox. const Checkbox({ Key key, @required this.value, @required this.onChanged, - this.activeColor - }) : super(key: key); + this.activeColor, + }) : assert(value != null), + super(key: key); /// Whether this checkbox is checked. + /// + /// This property must not be null. final bool value; /// Called when the value of the checkbox should change. @@ -59,7 +68,7 @@ class Checkbox extends StatefulWidget { /// /// If null, the checkbox will be displayed as disabled. /// - /// The callback provided to onChanged should update the state of the parent + /// The callback provided to [onChanged] should update the state of the parent /// [StatefulWidget] using the [State.setState] method, so that the parent /// gets rebuilt; for example: /// diff --git a/packages/flutter/lib/src/material/checkbox_list_tile.dart b/packages/flutter/lib/src/material/checkbox_list_tile.dart new file mode 100644 index 0000000000..478c31ea0b --- /dev/null +++ b/packages/flutter/lib/src/material/checkbox_list_tile.dart @@ -0,0 +1,204 @@ +// Copyright 2017 The Chromium 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/foundation.dart'; +import 'package:flutter/widgets.dart'; + +import 'checkbox.dart'; +import 'list_tile.dart'; +import 'theme.dart'; + +/// A [ListTile] with a [Checkbox]. In other words, a checkbox with a label. +/// +/// The entire list tile is interactive: tapping anywhere in the tile toggles +/// the checkbox. +/// +/// The [value], [onChanged], and [activeColor] properties of this widget are +/// identical to the similarly-named properties on the [Checkbox] widget. +/// +/// The [title], [subtitle], [isThreeLine], and [dense] properties are like +/// those of the same name on [ListTile]. +/// +/// The [selected] property on this widget is similar to the [ListTile.selected] +/// property, but the color used is that described by [activeColor], if any, +/// defaulting to the accent color of the current [Theme]. No effort is made to +/// coordinate the [selected] state and the [value] state; to have the list tile +/// appear selected when the checkbox is checked, pass the same value to both. +/// +/// The checkbox is shown on the right by default in left-to-right languages +/// (i.e. the trailing edge). This can be changed using [controlAffinity]. The +/// [secondary] widget is placed on the opposite side. This maps to the +/// [ListTile.leading] and [ListTile.trailing] properties of [ListTile]. +/// +/// ## Sample code +/// +/// This widget shows a checkbox that, when checked, slows down all animations +/// (including the animation of the checkbox itself getting checked!). +/// +/// ```dart +/// new CheckboxListTile( +/// title: const Text('Animate Slowly'), +/// value: timeDilation != 1.0, +/// onChanged: (bool value) { +/// setState(() { timeDilation = value ? 20.0 : 1.0; }); +/// }, +/// secondary: const Icon(Icons.hourglass_empty), +/// ) +/// ``` +/// +/// This sample requires that you also import 'package:flutter/scheduler.dart', +/// so that you can reference [timeDilation]. +/// +/// See also: +/// +/// * [ListTileTheme], which can be used to affect the style of list tiles, +/// including checkbox list tiles. +/// * [RadioListTile], a similar widget for radio buttons. +/// * [SwitchListTile], a similar widget for switches. +/// * [ListTile] and [Checkbox], the widgets from which this widget is made. +class CheckboxListTile extends StatelessWidget { + /// Creates a combination of a list tile and a checkbox. + /// + /// The checkbox tile itself does not maintain any state. Instead, when the + /// state of the checkbox changes, the widget calls the [onChanged] callback. + /// Most widgets that use a checkbox will listen for the [onChanged] callback + /// and rebuild the checkbox tile with a new [value] to update the visual + /// appearance of the checkbox. + /// + /// The following arguments are required: + /// + /// * [value], which determines whether the checkbox is checked, and must not + /// be null. + /// + /// * [onChanged], which is called when the value of the checkbox should + /// change. It can be set to null to disable the checkbox. + const CheckboxListTile({ + Key key, + @required this.value, + @required this.onChanged, + this.activeColor, + this.title, + this.subtitle, + this.isThreeLine: false, + this.dense, + this.secondary, + this.selected: false, + this.controlAffinity: ListTileControlAffinity.platform, + }) : assert(value != null), + assert(isThreeLine != null), + assert(!isThreeLine || subtitle != null), + assert(selected != null), + assert(controlAffinity != null), + super(key: key); + + /// Whether this checkbox is checked. + /// + /// This property must not be null. + final bool value; + + /// Called when the value of the checkbox should change. + /// + /// The checkbox passes the new value to the callback but does not actually + /// change state until the parent widget rebuilds the checkbox tile with the + /// new value. + /// + /// If null, the checkbox will be displayed as disabled. + /// + /// The callback provided to [onChanged] should update the state of the parent + /// [StatefulWidget] using the [State.setState] method, so that the parent + /// gets rebuilt; for example: + /// + /// ```dart + /// new CheckboxListTile( + /// value: _throwShotAway, + /// onChanged: (bool newValue) { + /// setState(() { + /// _throwShotAway = newValue; + /// }); + /// }, + /// title: new Text('Throw away your shot'), + /// ), + /// ``` + final ValueChanged onChanged; + + /// The color to use when this checkbox is checked. + /// + /// Defaults to accent color of the current [Theme]. + final Color activeColor; + + /// The primary content of the list tile. + /// + /// Typically a [Text] widget. + final Widget title; + + /// Additional content displayed below the title. + /// + /// Typically a [Text] widget. + final Widget subtitle; + + /// A widget to display on the opposite side of the tile from the checkbox. + /// + /// Typically an [Icon] widget. + final Widget secondary; + + /// Whether this list tile is intended to display three lines of text. + /// + /// If false, the list tile is treated as having one line if the subtitle is + /// null and treated as having two lines if the subtitle is non-null. + final bool isThreeLine; + + /// Whether this list tile is part of a vertically dense list. + /// + /// If this property is null then its value is based on [ListTileTheme.dense]. + final bool dense; + + /// Whether to render icons and text in the [activeColor]. + /// + /// No effort is made to automatically coordinate the [selected] state and the + /// [value] state. To have the list tile appear selected when the checkbox is + /// checked, pass the same value to both. + /// + /// Normally, this property is left to its default value, false. + final bool selected; + + /// Where to place the control relative to the text. + final ListTileControlAffinity controlAffinity; + + @override + Widget build(BuildContext context) { + final Widget control = new Checkbox( + value: value, + onChanged: onChanged, + activeColor: activeColor, + ); + Widget leading, trailing; + switch (controlAffinity) { + case ListTileControlAffinity.leading: + leading = control; + trailing = secondary; + break; + case ListTileControlAffinity.trailing: + case ListTileControlAffinity.platform: + leading = secondary; + trailing = control; + break; + } + return new MergeSemantics( + child: ListTileTheme.merge( + selectedColor: activeColor ?? Theme.of(context).accentColor, + child: new ListTile( + leading: leading, + title: title, + subtitle: subtitle, + trailing: trailing, + isThreeLine: isThreeLine, + dense: dense, + enabled: onChanged != null, + onTap: onChanged != null ? () { onChanged(!value); } : null, + selected: selected, + ), + ), + ); + } +} diff --git a/packages/flutter/lib/src/material/icon_theme.dart b/packages/flutter/lib/src/material/icon_theme.dart index 65c8b66b64..a29a78b916 100644 --- a/packages/flutter/lib/src/material/icon_theme.dart +++ b/packages/flutter/lib/src/material/icon_theme.dart @@ -19,7 +19,7 @@ class IconTheme extends InheritedWidget { const IconTheme({ Key key, @required this.data, - @required Widget child + @required Widget child, }) : assert(data != null), assert(child != null), super(key: key, child: child); @@ -31,7 +31,7 @@ class IconTheme extends InheritedWidget { static Widget merge({ Key key, @required IconThemeData data, - @required Widget child + @required Widget child, }) { return new Builder( builder: (BuildContext context) { diff --git a/packages/flutter/lib/src/material/list_tile.dart b/packages/flutter/lib/src/material/list_tile.dart index a49658da13..607b8a77b8 100644 --- a/packages/flutter/lib/src/material/list_tile.dart +++ b/packages/flutter/lib/src/material/list_tile.dart @@ -35,7 +35,8 @@ enum ListTileStyle { /// The [Drawer] widget specifies a tile theme for its children which sets /// [style] to [ListTileStyle.drawer]. class ListTileTheme extends InheritedWidget { - /// Creates an inherited widget that defines color and style parameters for [ListTile]s. + /// Creates a list tile theme that controls the color and style parameters for + /// [ListTile]s. const ListTileTheme({ Key key, this.dense: false, @@ -46,6 +47,36 @@ class ListTileTheme extends InheritedWidget { Widget child, }) : super(key: key, child: child); + /// Creates a list tile theme that controls the color and style parameters for + /// [ListTile]s, and merges in the current list tile theme, if any. + /// + /// The [child] argument must not be null. + static Widget merge({ + Key key, + bool dense, + ListTileStyle style, + Color selectedColor, + Color iconColor, + Color textColor, + @required Widget child, + }) { + assert(child != null); + return new Builder( + builder: (BuildContext context) { + final ListTileTheme parent = ListTileTheme.of(context); + return new ListTileTheme( + key: key, + dense: dense ?? parent.dense, + style: style ?? parent.style, + selectedColor: selectedColor ?? parent.selectedColor, + iconColor: iconColor ?? parent.iconColor, + textColor: textColor ?? parent.textColor, + child: child, + ); + }, + ); + } + /// If true then [ListTile]s will have the vertically dense layout. final bool dense; @@ -83,6 +114,28 @@ class ListTileTheme extends InheritedWidget { } } +/// Where to place the control in widgets that use [ListTile] to position a +/// control next to a label. +/// +/// See also: +/// +/// * [CheckboxListTile], which combines a [ListTile] with a [Checkbox]. +/// * [RadioListTile], which combines a [ListTile] with a [Radio] button. +enum ListTileControlAffinity { + /// Position the control on the leading edge, and the secondary widget, if + /// any, on the trailing edge. + leading, + + /// Position the control on the trailing edge, and the secondary widget, if + /// any, on the leading edge. + trailing, + + /// Position the control relative to the text in the fashion that is typical + /// for the current platform, and place the secondary widget on the opposite + /// side. + platform, +} + /// A single fixed-height row that typically contains some text as well as /// a leading or trailing icon. /// @@ -114,7 +167,8 @@ class ListTileTheme extends InheritedWidget { /// * [Card], which can be used with [Column] to show a few [ListTile]s. /// * [Divider], which can be used to separate [ListTile]s. /// * [ListTile.divideTiles], a utility for inserting [Divider]s in between [ListTile]s. -/// * [kListTileExtent], which defines the ListTile sizes. +/// * [CheckboxListTile], [RadioListTile], and [SwitchListTile], widgets +/// that combine [ListTile] with other controls. /// * class ListTile extends StatelessWidget { /// Creates a list tile. @@ -314,7 +368,7 @@ class ListTile extends StatelessWidget { margin: const EdgeInsets.only(right: 16.0), width: 40.0, alignment: FractionalOffset.centerLeft, - child: leading + child: leading, ), )); } @@ -335,8 +389,8 @@ class ListTile extends StatelessWidget { style: _subtitleTextStyle(theme, tileTheme), duration: kThemeChangeDuration, child: subtitle, - ) - ] + ), + ], ); } children.add(new Expanded( diff --git a/packages/flutter/lib/src/material/radio.dart b/packages/flutter/lib/src/material/radio.dart index b8f88339bf..13f576226d 100644 --- a/packages/flutter/lib/src/material/radio.dart +++ b/packages/flutter/lib/src/material/radio.dart @@ -17,9 +17,10 @@ const double _kInnerRadius = 5.0; /// A material design radio button. /// -/// 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. +/// 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, when the state /// of the radio button changes, the widget calls the [onChanged] callback. @@ -31,20 +32,25 @@ const double _kInnerRadius = 5.0; /// /// See also: /// +/// * [RadioListTile], which combines this widget with a [ListTile] so that +/// you can give the radio button a label. /// * [Slider], for selecting a value in a range. /// * [Checkbox] and [Switch], for toggling a particular value on or off. /// * class Radio extends StatefulWidget { /// Creates a material design radio button. /// - /// The radio button itself does not maintain any state. Instead, when the state - /// of the radio button changes, the widget calls the [onChanged] callback. - /// Most widget 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. + /// 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. /// - /// * [value] and [groupValue] together determines whether the radio button is selected. - /// * [onChanged] is when the user selects this radio button. + /// 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. const Radio({ Key key, @required this.value, @@ -70,7 +76,7 @@ class Radio extends StatefulWidget { /// /// If null, the radio button will be displayed as disabled. /// - /// The callback provided to onChanged should update the state of the parent + /// The callback provided to [onChanged] should update the state of the parent /// [StatefulWidget] using the [State.setState] method, so that the parent /// gets rebuilt; for example: /// diff --git a/packages/flutter/lib/src/material/radio_list_tile.dart b/packages/flutter/lib/src/material/radio_list_tile.dart new file mode 100644 index 0000000000..09785bd7ea --- /dev/null +++ b/packages/flutter/lib/src/material/radio_list_tile.dart @@ -0,0 +1,229 @@ +// Copyright 2017 The Chromium 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/foundation.dart'; +import 'package:flutter/widgets.dart'; + +import 'list_tile.dart'; +import 'radio.dart'; +import 'theme.dart'; + +/// A [ListTile] with a [Radio]. In other words, a radio button with a label. +/// +/// 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. +/// +/// The [title], [subtitle], [isThreeLine], and [dense] properties are like +/// those of the same name on [ListTile]. +/// +/// The [selected] property on this widget is similar to the [ListTile.selected] +/// property, but the color used is that described by [activeColor], if any, +/// defaulting to the accent color of the current [Theme]. No effort is made to +/// 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]. +/// +/// The radio button is shown on the left by default in left-to-right languages +/// (i.e. the leading edge). This can be changed using [controlAffinity]. The +/// [secondary] widget is placed on the opposite side. This maps to the +/// [ListTile.leading] and [ListTile.trailing] properties of [ListTile]. +/// +/// ## Sample code +/// +/// This widget shows a pair of radio buttons that control the `_character` +/// field. The field is of the type `SingingCharacter`, an enum. +/// +/// ```dart +/// // At the top level: +/// enum SingingCharacter { lafayette, jefferson } +/// +/// // In the State of a stateful widget: +/// SingingCharacter _character = SingingCharacter.lafayette; +/// +/// // In the build function of that State: +/// new Column( +/// children: [ +/// new RadioListTile( +/// title: const Text('Lafayette'), +/// value: SingingCharacter.lafayette, +/// groupValue: _character, +/// onChanged: (SingingCharacter value) { setState(() { _character = value; }); }, +/// ), +/// new RadioListTile( +/// title: const Text('Thomas Jefferson'), +/// value: SingingCharacter.jefferson, +/// groupValue: _character, +/// onChanged: (SingingCharacter value) { setState(() { _character = value; }); }, +/// ), +/// ], +/// ), +/// ``` +/// +/// See also: +/// +/// * [ListTileTheme], which can be used to affect the style of list tiles, +/// including radio list tiles. +/// * [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 { + /// 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. + /// + /// 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. + const RadioListTile({ + Key key, + @required this.value, + @required this.groupValue, + @required this.onChanged, + this.activeColor, + this.title, + this.subtitle, + this.isThreeLine: false, + this.dense, + this.secondary, + this.selected: false, + this.controlAffinity: ListTileControlAffinity.platform, + }) : assert(isThreeLine != null), + assert(!isThreeLine || subtitle != null), + assert(selected != null), + assert(controlAffinity != null), + super(key: key); + + /// The value represented by this radio button. + final T value; + + /// The currently selected value for this group of radio buttons. + /// + /// This radio button is considered selected if its [value] matches the + /// [groupValue]. + final T groupValue; + + /// 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 tile with the new [groupValue]. + /// + /// If null, the radio button will be displayed as disabled. + /// + /// The callback provided to [onChanged] should update the state of the parent + /// [StatefulWidget] using the [State.setState] method, so that the parent + /// gets rebuilt; for example: + /// + /// ```dart + /// new RadioListTile( + /// title: const Text('Lafayette'), + /// value: SingingCharacter.lafayette, + /// groupValue: _character, + /// onChanged: (SingingCharacter newValue) { + /// setState(() { + /// _character = newValue; + /// }); + /// }, + /// ), + /// ``` + final ValueChanged onChanged; + + /// The color to use when this radio button is selected. + /// + /// Defaults to accent color of the current [Theme]. + final Color activeColor; + + /// The primary content of the list tile. + /// + /// Typically a [Text] widget. + final Widget title; + + /// Additional content displayed below the title. + /// + /// Typically a [Text] widget. + final Widget subtitle; + + /// A widget to display on the opposite side of the tile from the radio button. + /// + /// Typically an [Icon] widget. + final Widget secondary; + + /// Whether this list tile is intended to display three lines of text. + /// + /// If false, the list tile is treated as having one line if the subtitle is + /// null and treated as having two lines if the subtitle is non-null. + final bool isThreeLine; + + /// Whether this list tile is part of a vertically dense list. + /// + /// If this property is null then its value is based on [ListTileTheme.dense]. + final bool dense; + + /// Whether to render icons and text in the [activeColor]. + /// + /// 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]. + /// + /// Normally, this property is left to its default value, false. + final bool selected; + + /// Where to place the control relative to the text. + final ListTileControlAffinity controlAffinity; + + /// Whether this radio button is checked. + /// + /// To control this value, set [value] and [groupValue] appropriately. + bool get checked => value == groupValue; + + @override + Widget build(BuildContext context) { + final Widget control = new Radio( + value: value, + groupValue: groupValue, + onChanged: onChanged, + activeColor: activeColor, + ); + Widget leading, trailing; + switch (controlAffinity) { + case ListTileControlAffinity.leading: + case ListTileControlAffinity.platform: + leading = control; + trailing = secondary; + break; + case ListTileControlAffinity.trailing: + leading = secondary; + trailing = control; + break; + } + return new MergeSemantics( + child: ListTileTheme.merge( + selectedColor: activeColor ?? Theme.of(context).accentColor, + child: new ListTile( + leading: leading, + title: title, + subtitle: subtitle, + trailing: trailing, + isThreeLine: isThreeLine, + dense: dense, + enabled: onChanged != null, + onTap: onChanged != null ? () { onChanged(value); } : null, + selected: selected, + ), + ), + ); + } +} diff --git a/packages/flutter/lib/src/material/switch.dart b/packages/flutter/lib/src/material/switch.dart index 9bbd7312c9..b2c3c1a453 100644 --- a/packages/flutter/lib/src/material/switch.dart +++ b/packages/flutter/lib/src/material/switch.dart @@ -28,6 +28,8 @@ import 'toggleable.dart'; /// /// See also: /// +/// * [SwitchListTile], which combines this widget with a [ListTile] so that +/// you can give the switch a label. /// * [Checkbox], another widget with similar semantics. /// * [Radio], for selecting among a set of explicit values. /// * [Slider], for selecting a value in a range. @@ -40,8 +42,10 @@ class Switch extends StatefulWidget { /// that use a switch will listen for the [onChanged] callback and rebuild the /// switch with a new [value] to update the visual appearance of the switch. /// - /// * [value] determines this switch is on or off. - /// * [onChanged] is called when the user toggles with switch on or off. + /// The following arguments are required: + /// + /// * [value] determines whether this switch is on or off. + /// * [onChanged] is called when the user toggles the switch on or off. const Switch({ Key key, @required this.value, @@ -52,9 +56,11 @@ class Switch extends StatefulWidget { }) : super(key: key); /// Whether this switch is on or off. + /// + /// This property must not be null. final bool value; - /// Called when the user toggles with switch on or off. + /// Called when the user toggles the switch on or off. /// /// The switch passes the new value to the callback but does not actually /// change state until the parent widget rebuilds the switch with the new @@ -62,7 +68,7 @@ class Switch extends StatefulWidget { /// /// If null, the switch will be displayed as disabled. /// - /// The callback provided to onChanged should update the state of the parent + /// The callback provided to [onChanged] should update the state of the parent /// [StatefulWidget] using the [State.setState] method, so that the parent /// gets rebuilt; for example: /// diff --git a/packages/flutter/lib/src/material/switch_list_tile.dart b/packages/flutter/lib/src/material/switch_list_tile.dart new file mode 100644 index 0000000000..3f219b4014 --- /dev/null +++ b/packages/flutter/lib/src/material/switch_list_tile.dart @@ -0,0 +1,190 @@ +// Copyright 2017 The Chromium 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/foundation.dart'; +import 'package:flutter/widgets.dart'; + +import 'list_tile.dart'; +import 'switch.dart'; +import 'theme.dart'; + +/// A [ListTile] with a [Switch]. In other words, a switch with a label. +/// +/// The entire list tile is interactive: tapping anywhere in the tile toggles +/// the switch. +/// +/// The [value], [onChanged], [activeColor], [activeThumbImage], and +/// [inactiveThumbImage] properties of this widget are identical to the +/// similarly-named properties on the [Switch] widget. +/// +/// The [title], [subtitle], [isThreeLine], and [dense] properties are like +/// those of the same name on [ListTile]. +/// +/// The [selected] property on this widget is similar to the [ListTile.selected] +/// property, but the color used is that described by [activeColor], if any, +/// defaulting to the accent color of the current [Theme]. No effort is made to +/// coordinate the [selected] state and the [value] state; to have the list tile +/// appear selected when the switch is on, pass the same value to both. +/// +/// The switch is shown on the right by default in left-to-right languages (i.e. +/// in the [ListTile.trailing] slot). The [secondary] widget is placed in the +/// [ListTile.leading] slot. This cannot be changed; there is not sufficient +/// space in a [ListTile]'s [ListTile.leading] slot for a [Switch]. +/// +/// ## Sample code +/// +/// This widget shows a switch that, when toggled, changes the state of a [bool] +/// member field called `_lights`. +/// +/// ```dart +/// new SwitchListTile( +/// title: const Text('Lights'), +/// value: _lights, +/// onChanged: (bool value) { setState(() { _lights = value; }); }, +/// secondary: const Icon(Icons.lightbulb_outline), +/// ) +/// ``` +/// +/// See also: +/// +/// * [ListTileTheme], which can be used to affect the style of list tiles, +/// including switch list tiles. +/// * [CheckboxListTile], a similar widget for checkboxes. +/// * [RadioListTile], a similar widget for radio buttons. +/// * [ListTile] and [Switch], the widgets from which this widget is made. +class SwitchListTile extends StatelessWidget { + /// Creates a combination of a list tile and a switch. + /// + /// The switch tile itself does not maintain any state. Instead, when the + /// state of the switch changes, the widget calls the [onChanged] callback. + /// Most widgets that use a switch will listen for the [onChanged] callback + /// and rebuild the switch tile with a new [value] to update the visual + /// appearance of the switch. + /// + /// The following arguments are required: + /// + /// * [value] determines whether this switch is on or off. + /// * [onChanged] is called when the user toggles the switch on or off. + const SwitchListTile({ + Key key, + @required this.value, + @required this.onChanged, + this.activeColor, + this.activeThumbImage, + this.inactiveThumbImage, + this.title, + this.subtitle, + this.isThreeLine: false, + this.dense, + this.secondary, + this.selected: false, + }) : assert(value != null), + assert(isThreeLine != null), + assert(!isThreeLine || subtitle != null), + assert(selected != null), + super(key: key); + + /// Whether this switch is checked. + /// + /// This property must not be null. + final bool value; + + /// Called when the user toggles the switch on or off. + /// + /// The switch passes the new value to the callback but does not actually + /// change state until the parent widget rebuilds the switch tile with the + /// new value. + /// + /// If null, the switch will be displayed as disabled. + /// + /// The callback provided to [onChanged] should update the state of the parent + /// [StatefulWidget] using the [State.setState] method, so that the parent + /// gets rebuilt; for example: + /// + /// ```dart + /// new SwitchListTile( + /// value: _lights, + /// onChanged: (bool newValue) { + /// setState(() { + /// _lights = newValue; + /// }); + /// }, + /// title: new Text('Lights'), + /// ), + /// ``` + final ValueChanged onChanged; + + /// The color to use when this switch is on. + /// + /// Defaults to accent color of the current [Theme]. + final Color activeColor; + + /// An image to use on the thumb of this switch when the switch is on. + final ImageProvider activeThumbImage; + + /// An image to use on the thumb of this switch when the switch is off. + final ImageProvider inactiveThumbImage; + + /// The primary content of the list tile. + /// + /// Typically a [Text] widget. + final Widget title; + + /// Additional content displayed below the title. + /// + /// Typically a [Text] widget. + final Widget subtitle; + + /// A widget to display on the opposite side of the tile from the switch. + /// + /// Typically an [Icon] widget. + final Widget secondary; + + /// Whether this list tile is intended to display three lines of text. + /// + /// If false, the list tile is treated as having one line if the subtitle is + /// null and treated as having two lines if the subtitle is non-null. + final bool isThreeLine; + + /// Whether this list tile is part of a vertically dense list. + /// + /// If this property is null then its value is based on [ListTileTheme.dense]. + final bool dense; + + /// Whether to render icons and text in the [activeColor]. + /// + /// No effort is made to automatically coordinate the [selected] state and the + /// [value] state. To have the list tile appear selected when the switch is + /// on, pass the same value to both. + /// + /// Normally, this property is left to its default value, false. + final bool selected; + + @override + Widget build(BuildContext context) { + final Widget control = new Switch( + value: value, + onChanged: onChanged, + activeColor: activeColor, + activeThumbImage: activeThumbImage, + inactiveThumbImage: inactiveThumbImage, + ); + return new MergeSemantics( + child: ListTileTheme.merge( + selectedColor: activeColor ?? Theme.of(context).accentColor, + child: new ListTile( + leading: secondary, + title: title, + subtitle: subtitle, + trailing: control, + isThreeLine: isThreeLine, + dense: dense, + enabled: onChanged != null, + onTap: onChanged != null ? () { onChanged(!value); } : null, + selected: selected, + ), + ), + ); + } +} diff --git a/packages/flutter/lib/src/widgets/image.dart b/packages/flutter/lib/src/widgets/image.dart index aa258609f4..2a3dfac9a3 100644 --- a/packages/flutter/lib/src/widgets/image.dart +++ b/packages/flutter/lib/src/widgets/image.dart @@ -13,6 +13,7 @@ import 'framework.dart'; import 'media_query.dart'; export 'package:flutter/services.dart' show + ImageProvider, AssetImage, ExactAssetImage, MemoryImage, diff --git a/packages/flutter/lib/src/widgets/text.dart b/packages/flutter/lib/src/widgets/text.dart index f90186e8cb..5f4097ea44 100644 --- a/packages/flutter/lib/src/widgets/text.dart +++ b/packages/flutter/lib/src/widgets/text.dart @@ -68,7 +68,7 @@ class DefaultTextStyle extends InheritedWidget { softWrap: softWrap ?? parent.softWrap, overflow: overflow ?? parent.overflow, maxLines: maxLines ?? parent.maxLines, - child: child + child: child, ); }, ); diff --git a/packages/flutter/test/material/control_list_tile_test.dart b/packages/flutter/test/material/control_list_tile_test.dart new file mode 100644 index 0000000000..b7697e49a4 --- /dev/null +++ b/packages/flutter/test/material/control_list_tile_test.dart @@ -0,0 +1,118 @@ +// Copyright 2015 The Chromium 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 SemanticsFlags; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; + +import '../widgets/semantics_tester.dart'; + +void main() { + testWidgets('CheckboxListTile control test', (WidgetTester tester) async { + final List log = []; + await tester.pumpWidget(new Material( + child: new CheckboxListTile( + value: true, + onChanged: (bool value) { log.add(value); }, + title: const Text('Hello'), + ), + )); + await tester.tap(find.text('Hello')); + log.add('-'); + await tester.tap(find.byType(Checkbox)); + expect(log, equals([false, '-', false])); + }); + + testWidgets('RadioListTile control test', (WidgetTester tester) async { + final List log = []; + await tester.pumpWidget(new Material( + child: new RadioListTile( + value: true, + groupValue: false, + onChanged: (bool value) { log.add(value); }, + title: const Text('Hello'), + ), + )); + await tester.tap(find.text('Hello')); + log.add('-'); + await tester.tap(find.byType(const Radio(value: false, groupValue: false, onChanged: null).runtimeType)); + expect(log, equals([true, '-', true])); + }); + + testWidgets('SwitchListTile control test', (WidgetTester tester) async { + final List log = []; + await tester.pumpWidget(new Material( + child: new SwitchListTile( + value: true, + onChanged: (bool value) { log.add(value); }, + title: const Text('Hello'), + ), + )); + await tester.tap(find.text('Hello')); + log.add('-'); + await tester.tap(find.byType(Switch)); + expect(log, equals([false, '-', false])); + }); + + testWidgets('SwitchListTile control test', (WidgetTester tester) async { + final SemanticsTester semantics = new SemanticsTester(tester); + await tester.pumpWidget(new Material( + child: new Column( + children: [ + new SwitchListTile( + value: true, + onChanged: (bool value) { }, + title: const Text('AAA'), + secondary: const Text('aaa'), + ), + new CheckboxListTile( + value: true, + onChanged: (bool value) { }, + title: const Text('BBB'), + secondary: const Text('bbb'), + ), + new RadioListTile( + value: true, + groupValue: false, + onChanged: (bool value) { }, + title: const Text('CCC'), + secondary: const Text('ccc'), + ), + ], + ), + )); + // This test verifies that the label and the control get merged. + expect(semantics, hasSemantics(new TestSemantics.root( + children: [ + new TestSemantics.rootChild( + id: 1, + rect: new Rect.fromLTWH(0.0, 0.0, 800.0, 56.0), + transform: null, + flags: SemanticsFlags.hasCheckedState.index | SemanticsFlags.isChecked.index, + actions: SemanticsAction.tap.index, + label: 'aaa\nAAA', + ), + new TestSemantics.rootChild( + id: 6, + rect: new Rect.fromLTWH(0.0, 0.0, 800.0, 56.0), + transform: new Matrix4.translationValues(0.0, 56.0, 0.0), + flags: SemanticsFlags.hasCheckedState.index | SemanticsFlags.isChecked.index, + actions: SemanticsAction.tap.index, + label: 'bbb\nBBB', + ), + new TestSemantics.rootChild( + id: 11, + rect: new Rect.fromLTWH(0.0, 0.0, 800.0, 56.0), + transform: new Matrix4.translationValues(0.0, 112.0, 0.0), + flags: SemanticsFlags.hasCheckedState.index, + actions: SemanticsAction.tap.index, + label: 'CCC\nccc', + ), + ], + ))); + }); + +} diff --git a/packages/flutter/test/widgets/semantics_tester.dart b/packages/flutter/test/widgets/semantics_tester.dart index b7475288ca..eca1c757bd 100644 --- a/packages/flutter/test/widgets/semantics_tester.dart +++ b/packages/flutter/test/widgets/semantics_tester.dart @@ -195,7 +195,7 @@ class SemanticsTester { } class _HasSemantics extends Matcher { - const _HasSemantics(this._semantics); + const _HasSemantics(this._semantics) : assert(_semantics != null); final TestSemantics _semantics; @@ -211,26 +211,27 @@ class _HasSemantics extends Matcher { @override Description describeMismatch(dynamic item, Description mismatchDescription, Map matchState, bool verbose) { + const String help = 'Try dumping the semantics with debugDumpSemanticsTree() from the rendering library to see what the semantics tree looks like.'; final TestSemantics testNode = matchState[TestSemantics]; final SemanticsNode node = matchState[SemanticsNode]; if (node == null) - return mismatchDescription.add('could not find node with id ${testNode.id}'); + return mismatchDescription.add('could not find node with id ${testNode.id}.\n$help'); if (testNode.id != node.id) - return mismatchDescription.add('expected node id ${testNode.id} but found id ${node.id}'); + return mismatchDescription.add('expected node id ${testNode.id} but found id ${node.id}.\n$help'); final SemanticsData data = node.getSemanticsData(); if (testNode.flags != data.flags) - return mismatchDescription.add('expected node id ${testNode.id} to have flags ${testNode.flags} but found flags ${data.flags}'); + return mismatchDescription.add('expected node id ${testNode.id} to have flags ${testNode.flags} but found flags ${data.flags}.\n$help'); if (testNode.actions != data.actions) - return mismatchDescription.add('expected node id ${testNode.id} to have actions ${testNode.actions} but found actions ${data.actions}'); + return mismatchDescription.add('expected node id ${testNode.id} to have actions ${testNode.actions} but found actions ${data.actions}.\n$help'); if (testNode.label != data.label) - return mismatchDescription.add('expected node id ${testNode.id} to have label "${testNode.label}" but found label "${data.label}"'); + return mismatchDescription.add('expected node id ${testNode.id} to have label "${testNode.label}" but found label "${data.label}".\n$help'); if (testNode.rect != data.rect) - return mismatchDescription.add('expected node id ${testNode.id} to have rect ${testNode.rect} but found rect ${data.rect}'); + return mismatchDescription.add('expected node id ${testNode.id} to have rect ${testNode.rect} but found rect ${data.rect}.\n$help'); if (testNode.transform != data.transform) - return mismatchDescription.add('expected node id ${testNode.id} to have transform ${testNode.transform} but found transform:\n${data.transform}'); + return mismatchDescription.add('expected node id ${testNode.id} to have transform ${testNode.transform} but found transform:.\n${data.transform}.\n$help'); final int childrenCount = node.mergeAllDescendantsIntoThisNode ? 0 : node.childrenCount; if (testNode.children.length != childrenCount) - return mismatchDescription.add('expected node id ${testNode.id} to have ${testNode.children.length} but found $childrenCount children'); + return mismatchDescription.add('expected node id ${testNode.id} to have ${testNode.children.length} children but found $childrenCount.\n$help'); return mismatchDescription; } }