Skip to content

Theming for Material Selection Controls #64365

@jpc-ae

Description

@jpc-ae

In line with the Material Design Theme System Updates, the current Checkbox, Radio button and Switch selection control widgets do not yet have theme components. These controls are grouped together under Selection Controls in the Material Design specs

Introducing theming may end up a breaking change for these widgets, as their naming has not changed and some of the colors (notably the checkbox check) may need to be fixed. This will therefore likely need to be an opt-in feature at the beginning.

Proposal

Introducing theming for these widgets requires several new components:

  • A SelectionControlStyle based on the one used for buttons (simplified)
  • CheckboxTheme, RadioTheme, SwitchTheme and their associated Data classes
  • A useSelectionControlThemes option for turning on the themes until any breaking changes can be adopted
  • Additional options to tie the widgets to the themes

Much of the work here will be based on what was done for the new material buttons, but simplified to line up with selection controls. The challenge of having a single SelectionControlStyle for these, is that they are structurally distinct (one has a contrasting check overlay, one has an inside dot, one has a slide and the potential for a loading icon)

Another consideration is whether to add indeterminate as a material state so it can be themed separately from the selected state.

A sample of my idea for the underlying SelectionControlStyle is below, suggestions welcome:

Show code
import 'dart:ui' show lerpDouble;

import 'package:flutter/foundation.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter/widgets.dart';

import 'material_state.dart';
import 'theme_data.dart';

/// The visual properties that most selection controls have in common.
///
/// Checkboxes, radio buttons, switches and their themes have a SelectionControlStyle
/// property which defines the visual properties whose default values are to be
/// overridden. The default values are defined by the individual selection control
/// widgets and are typically based on the overall theme's [ThemeData.colorScheme].
///
/// All of the SelectionControlStyle properties are null by default.
///
/// Many of the SelectionControlStyle properties are [MaterialStateProperty] objects
/// which resolve to different values depending on the selection control's state. For
/// example, the [Color] properties are defined with `MaterialStateProperty<Color>`
/// and can resolve to different colors depending on if the control is selected,
/// hovered, focused, disabled, etc.
///
/// These properties can override the default value for just one state or all of
/// them. For example to create a [Checkbox] whose color is the color scheme’s primary
/// color with 50% opacity, but only when the checkbox is indeterminate, one could
/// write:
///
/// ```dart
/// Checkbox(
///   style: SelectionControlStyle(
///     color: MaterialStateProperty.resolveWith<Color>(
///       (Set<MaterialState> states) {
///         if (states.contains(MaterialState.indeterminate))
///           return Theme.of(context).colorScheme.primary.withOpacity(0.5);
///         return null; // Use the component's default.
///       },
///     ),
///   ),
/// )
///```
///
/// In this case the color for all other button states would fallback to the
/// checkbox’s default values. To unconditionally set the checkbox's [color]
/// for all states one could write:
///
/// ```dart
/// Checkbox(
///   style: SelectionControlStyle(
///     color: MaterialStateProperty.all<Color>(Colors.green),
///   ),
/// )
///```
///
/// Configuring a SelectionControlStyle directly makes it possible to very
/// precisely control the selection control’s visual attributes for all states.
/// This level of control is typically required when a custom
/// “branded” look and feel is desirable.  However, in many cases it’s
/// useful to make relatively sweeping changes based on a few initial
/// parameters with simple values. The selection control styleFrom() methods
/// enable such sweeping changes. See:
/// [Checkbox.styleFrom], [Radio.styleFrom], [Switch.styleFrom].
///
/// For example, to override the default color for a [Checkbox] with all
/// of the standard opacity adjustments for the pressed, focused, and
/// hovered states, one could write:
///
/// ```dart
/// Checkbox(
///   style: Checkbox.styleFrom(secondary: Colors.green),
/// )
///```
///
/// To configure all of the application's checkboxes in the same
/// way, specify the overall theme's `checkboxTheme`:
/// ```dart
/// MaterialApp(
///   theme: ThemeData(
///     checkboxTheme: CheckboxThemeData(
///       style: Checkbox.styleFrom(secondary: Colors.green),
///     ),
///   ),
///   home: MyAppHome(),
/// )
///```
/// See also:
///
///  * [CheckboxTheme], the theme for [Checkbox]es.
///  * [RadioTheme], the theme for [Radio] buttons.
///  * [SwitchTheme], the theme for [Switch]es.
@immutable
class SelectionControlStyle with Diagnosticable {
  /// Create a [SelectionControlStyle].
  const SelectionControlStyle({
    this.color,
    this.overlayColor,
    this.padding,
    this.minimumSize,
    this.mouseCursor,
    this.visualDensity,
    this.tapTargetSize,
    this.enableFeedback,
  });

  /// The main color used for the selection control.
  final MaterialStateProperty<Color> color;

  /// The highlight color that's typically used to indicate that
  /// the control is focused, hovered, or pressed.
  final MaterialStateProperty<Color> overlayColor;

  /// The padding between the control's boundary and its child.
  final MaterialStateProperty<EdgeInsetsGeometry> padding;

  /// The minimum size of the control itself.
  ///
  /// The size of the rectangle the control lies within may be larger
  /// per [tapTargetSize].
  final MaterialStateProperty<Size> minimumSize;

  /// The cursor for a mouse pointer when it enters or is hovering over
  /// this control's [InkWell].
  final MaterialStateProperty<MouseCursor> mouseCursor;

  /// Defines how compact the control's layout will be.
  ///
  /// {@macro flutter.material.themeData.visualDensity}
  ///
  /// See also:
  ///
  ///  * [ThemeData.visualDensity], which specifies the [visualDensity] for all widgets
  ///    within a [Theme].
  final VisualDensity visualDensity;

  /// Configures the minimum size of the area within which the control may be pressed.
  ///
  /// If the [tapTargetSize] is larger than [minimumSize], the control will include
  /// a transparent margin that responds to taps.
  ///
  /// Always defaults to [ThemeData.materialTapTargetSize].
  final MaterialTapTargetSize tapTargetSize;

  /// Whether detected gestures should provide acoustic and/or haptic feedback.
  ///
  /// For example, on Android a tap will produce a clicking sound and a
  /// long-press will produce a short vibration, when feedback is enabled.
  ///
  /// Typically the component default value is true.
  ///
  /// See also:
  ///
  ///  * [Feedback] for providing platform-specific feedback to certain actions.
  final bool enableFeedback;

  /// Returns a copy of this SelectionControlStyle with the given fields replaced
  /// with the new values.
  SelectionControlStyle copyWith({
    MaterialStateProperty<Color> color,
    MaterialStateProperty<Color> overlayColor,
    MaterialStateProperty<EdgeInsetsGeometry> padding,
    MaterialStateProperty<Size> minimumSize,
    MaterialStateProperty<MouseCursor> mouseCursor,
    VisualDensity visualDensity,
    MaterialTapTargetSize tapTargetSize,
    bool enableFeedback,
  }) {
    return SelectionControlStyle(
      color: color ?? this.color,
      overlayColor: overlayColor ?? this.overlayColor,
      padding: padding ?? this.padding,
      minimumSize: minimumSize ?? this.minimumSize,
      mouseCursor: mouseCursor ?? this.mouseCursor,
      visualDensity: visualDensity ?? this.visualDensity,
      tapTargetSize: tapTargetSize ?? this.tapTargetSize,
      enableFeedback: enableFeedback ?? this.enableFeedback,
    );
  }

  /// Returns a copy of this SelectionControlStyle where the non-null fields in [style]
  /// have replaced the corresponding null fields in this SelectionControlStyle.
  ///
  /// In other words, [style] is used to fill in unspecified (null) fields in
  /// this SelectionControlStyle.
  SelectionControlStyle merge(SelectionControlStyle style) {
    if (style == null)
      return this;
    return copyWith(
      color: color ?? style.color,
      overlayColor: overlayColor ?? style.overlayColor,
      padding: padding ?? style.padding,
      minimumSize: minimumSize ?? style.minimumSize,
      mouseCursor: mouseCursor ?? style.mouseCursor,
      visualDensity: visualDensity ?? style.visualDensity,
      tapTargetSize: tapTargetSize ?? style.tapTargetSize,
      enableFeedback: enableFeedback ?? style.enableFeedback,
    );
  }

  @override
  int get hashCode {
    return hashValues(
      color,
      overlayColor,
      padding,
      minimumSize,
      mouseCursor,
      visualDensity,
      tapTargetSize,
      enableFeedback,
    );
  }

  @override
  bool operator ==(Object other) {
    if (identical(this, other))
      return true;
    if (other.runtimeType != runtimeType)
      return false;
    return other is SelectionControlStyle
        && other.color == color
        && other.overlayColor == overlayColor
        && other.padding == padding
        && other.minimumSize == minimumSize
        && other.mouseCursor == mouseCursor
        && other.visualDensity == visualDensity
        && other.tapTargetSize == tapTargetSize
        && other.enableFeedback == enableFeedback;
  }

  @override
  void debugFillProperties(DiagnosticPropertiesBuilder properties) {
    super.debugFillProperties(properties);
    properties.add(DiagnosticsProperty<MaterialStateProperty<Color>>('color', color, defaultValue: null));
    properties.add(DiagnosticsProperty<MaterialStateProperty<Color>>('overlayColor', overlayColor, defaultValue: null));
    properties.add(DiagnosticsProperty<MaterialStateProperty<EdgeInsetsGeometry>>('padding', padding, defaultValue: null));
    properties.add(DiagnosticsProperty<MaterialStateProperty<Size>>('minimumSize', minimumSize, defaultValue: null));
    properties.add(DiagnosticsProperty<MaterialStateProperty<MouseCursor>>('mouseCursor', mouseCursor, defaultValue: null));
    properties.add(DiagnosticsProperty<VisualDensity>('visualDensity', visualDensity, defaultValue: null));
    properties.add(EnumProperty<MaterialTapTargetSize>('tapTargetSize', tapTargetSize, defaultValue: null));
    properties.add(DiagnosticsProperty<bool>('enableFeedback', enableFeedback, defaultValue: null));
  }

  /// Linearly interpolate between two [SelectionControlStyle]s.
  static SelectionControlStyle lerp(SelectionControlStyle a, SelectionControlStyle b, double t) {
    assert (t != null);
    if (a == null && b == null)
      return null;
    return SelectionControlStyle(
      color: _lerpProperties<Color>(a?.color, b?.color, t, Color.lerp),
      overlayColor: _lerpProperties<Color>(a?.overlayColor, b?.overlayColor, t, Color.lerp),
      padding:  _lerpProperties<EdgeInsetsGeometry>(a?.padding, b?.padding, t, EdgeInsetsGeometry.lerp),
      minimumSize: _lerpProperties<Size>(a?.minimumSize, b?.minimumSize, t, Size.lerp),
      mouseCursor: t < 0.5 ? a.mouseCursor : b.mouseCursor,
      visualDensity: t < 0.5 ? a.visualDensity : b.visualDensity,
      tapTargetSize: t < 0.5 ? a.tapTargetSize : b.tapTargetSize,
      enableFeedback: t < 0.5 ? a.enableFeedback : b.enableFeedback,
    );
  }

  static MaterialStateProperty<T> _lerpProperties<T>(MaterialStateProperty<T> a, MaterialStateProperty<T> b, double t, T Function(T, T, double) lerpFunction ) {
    // Avoid creating a _LerpProperties object for a common case.
    if (a == null && b == null)
      return null;
    return _LerpProperties<T>(a, b, t, lerpFunction);
  }
}

class _LerpProperties<T> implements MaterialStateProperty<T> {
  const _LerpProperties(this.a, this.b, this.t, this.lerpFunction);

  final MaterialStateProperty<T> a;
  final MaterialStateProperty<T> b;
  final double t;
  final T Function(T, T, double) lerpFunction;

  @override
  T resolve(Set<MaterialState> states) {
    final T resolvedA = a?.resolve(states);
    final T resolvedB = b?.resolve(states);
    return lerpFunction(resolvedA, resolvedB, t);
  }
}

Metadata

Metadata

Assignees

No one assigned

    Labels

    c: proposalA detailed proposal for a change to Flutterf: material designflutter/packages/flutter/material repository.frameworkflutter/packages/flutter repository. See also f: labels.

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions