diff --git a/packages/flutter/lib/cupertino.dart b/packages/flutter/lib/cupertino.dart
index af3de265c0..bdbf063a29 100644
--- a/packages/flutter/lib/cupertino.dart
+++ b/packages/flutter/lib/cupertino.dart
@@ -27,6 +27,7 @@ export 'src/cupertino/adaptive_text_selection_toolbar.dart';
export 'src/cupertino/app.dart';
export 'src/cupertino/bottom_tab_bar.dart';
export 'src/cupertino/button.dart';
+export 'src/cupertino/checkbox.dart';
export 'src/cupertino/colors.dart';
export 'src/cupertino/constants.dart';
export 'src/cupertino/context_menu.dart';
diff --git a/packages/flutter/lib/src/cupertino/checkbox.dart b/packages/flutter/lib/src/cupertino/checkbox.dart
new file mode 100644
index 0000000000..9d37c7af9c
--- /dev/null
+++ b/packages/flutter/lib/src/cupertino/checkbox.dart
@@ -0,0 +1,396 @@
+// 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/widgets.dart';
+
+import 'colors.dart';
+import 'constants.dart';
+import 'toggleable.dart';
+
+// Examples can assume:
+// bool _throwShotAway = false;
+// late StateSetter setState;
+
+// The relative values needed to transform a color to it's equivilant focus
+// outline color.
+const double _kCupertinoFocusColorOpacity = 0.80;
+const double _kCupertinoFocusColorBrightness = 0.69;
+const double _kCupertinoFocusColorSaturation = 0.835;
+
+/// A macOS style checkbox.
+///
+/// The checkbox 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 with a new [value] to update the visual appearance of
+/// the checkbox.
+///
+/// The checkbox can optionally display three values - true, false, and null -
+/// if [tristate] is true. When [value] is null a dash is displayed. By default
+/// [tristate] is false and the checkbox's [value] must be true or false.
+///
+/// In the Apple Human Interface Guidelines (HIG), checkboxes are encouraged for
+/// use on macOS, but is silent about their use on iOS. If a multi-selection
+/// component is needed on iOS, the HIG encourages the developer to use switches
+/// ([CupertinoSwitch] in Flutter) instead, or to find a creative custom
+/// solution.
+///
+/// See also:
+///
+/// * [Checkbox], the Material Design equivalent.
+/// * [CupertinoSwitch], a widget with semantics similar to [CupertinoCheckbox].
+/// * [CupertinoSlider], for selecting a value in a range.
+/// *
+class CupertinoCheckbox extends StatefulWidget {
+ /// Creates a macOS-styled checkbox.
+ ///
+ /// The checkbox 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 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. The [value]
+ /// can only be null if [tristate] is true.
+ /// * [onChanged], which is called when the value of the checkbox should
+ /// change. It can be set to null to disable the checkbox.
+ ///
+ /// The values of [tristate] and [autofocus] must not be null.
+ const CupertinoCheckbox({
+ super.key,
+ required this.value,
+ this.tristate = false,
+ required this.onChanged,
+ this.activeColor,
+ this.inactiveColor,
+ this.checkColor,
+ this.focusColor,
+ this.focusNode,
+ this.autofocus = false,
+ this.side,
+ this.shape,
+ }) : assert(tristate || value != null);
+
+ /// Whether this checkbox is checked.
+ ///
+ /// When [tristate] is true, a value of null corresponds to the mixed state.
+ /// When [tristate] is false, this value must not be null. This is asserted in
+ /// debug mode.
+ 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 with the new
+ /// value.
+ ///
+ /// If this callback is null, the checkbox will be displayed as disabled
+ /// and will not respond to input gestures.
+ ///
+ /// When the checkbox is tapped, if [tristate] is false (the default) then
+ /// the [onChanged] callback will be applied to `!value`. If [tristate] is
+ /// true this callback cycle from false to true to null and back to false
+ /// again.
+ ///
+ /// 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
+ /// CupertinoCheckbox(
+ /// value: _throwShotAway,
+ /// onChanged: (bool? newValue) {
+ /// setState(() {
+ /// _throwShotAway = newValue!;
+ /// });
+ /// },
+ /// )
+ /// ```
+ final ValueChanged? onChanged;
+
+ /// The color to use when this checkbox is checked.
+ ///
+ /// Defaults to [CupertinoColors.activeBlue].
+ final Color? activeColor;
+
+ /// The color used if the checkbox is inactive.
+ ///
+ /// By default, [CupertinoColors.inactiveGray] is used.
+ final Color? inactiveColor;
+
+ /// The color to use for the check icon when this checkbox is checked.
+ ///
+ /// If null, then the value of [CupertinoColors.white] is used.
+ final Color? checkColor;
+
+ /// If true, the checkbox's [value] can be true, false, or null.
+ ///
+ /// [CupertinoCheckbox] displays a dash when its value is null.
+ ///
+ /// When a tri-state checkbox ([tristate] is true) is tapped, its [onChanged]
+ /// callback will be applied to true if the current value is false, to null if
+ /// value is true, and to false if value is null (i.e. it cycles through false
+ /// => true => null => false when tapped).
+ ///
+ /// If tristate is false (the default), [value] must not be null, and
+ /// [onChanged] will only toggle between true and false.
+ final bool tristate;
+
+ /// The color for the checkbox's border shadow when it has the input focus.
+ ///
+ /// If null, then a paler form of the [activeColor] will be used.
+ final Color? focusColor;
+
+ /// {@macro flutter.widgets.Focus.focusNode}
+ final FocusNode? focusNode;
+
+ /// {@macro flutter.widgets.Focus.autofocus}
+ final bool autofocus;
+
+ /// The color and width of the checkbox's border.
+ ///
+ /// If this property is null, then the side defaults to a one pixel wide
+ /// black, solid border.
+ final BorderSide? side;
+
+ /// The shape of the checkbox.
+ ///
+ /// If this property is null then the shape defaults to a
+ /// [RoundedRectangleBorder] with a circular corner radius of 4.0.
+ final OutlinedBorder? shape;
+
+ /// The width of a checkbox widget.
+ static const double width = 18.0;
+
+ @override
+ State createState() => _CupertinoCheckboxState();
+}
+
+class _CupertinoCheckboxState extends State with TickerProviderStateMixin, ToggleableStateMixin {
+ final _CheckboxPainter _painter = _CheckboxPainter();
+ bool? _previousValue;
+
+ bool focused = false;
+
+ @override
+ void initState() {
+ super.initState();
+ _previousValue = widget.value;
+ }
+
+ @override
+ void didUpdateWidget(CupertinoCheckbox oldWidget) {
+ super.didUpdateWidget(oldWidget);
+ if (oldWidget.value != widget.value) {
+ _previousValue = oldWidget.value;
+ }
+ }
+
+ @override
+ void dispose() {
+ _painter.dispose();
+ super.dispose();
+ }
+
+ @override
+ ValueChanged? get onChanged => widget.onChanged;
+
+ @override
+ bool get tristate => widget.tristate;
+
+ @override
+ bool? get value => widget.value;
+
+ void onFocusChange(bool value) {
+ if (focused != value) {
+ focused = value;
+ }
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ final Color effectiveActiveColor = widget.activeColor
+ ?? CupertinoColors.activeBlue;
+ final Color? inactiveColor = widget.inactiveColor;
+ final Color effectiveInactiveColor = inactiveColor
+ ?? CupertinoColors.inactiveGray;
+
+ final Color effectiveFocusOverlayColor = widget.focusColor
+ ?? HSLColor
+ .fromColor(effectiveActiveColor.withOpacity(_kCupertinoFocusColorOpacity))
+ .withLightness(_kCupertinoFocusColorBrightness)
+ .withSaturation(_kCupertinoFocusColorSaturation)
+ .toColor();
+
+ final Color effectiveCheckColor = widget.checkColor
+ ?? CupertinoColors.white;
+
+ return Semantics(
+ checked: widget.value ?? false,
+ mixed: widget.tristate ? widget.value == null : null,
+ child: buildToggleable(
+ focusNode: widget.focusNode,
+ autofocus: widget.autofocus,
+ onFocusChange: onFocusChange,
+ size: const Size.square(kMinInteractiveDimensionCupertino),
+ painter: _painter
+ ..focusColor = effectiveFocusOverlayColor
+ ..isFocused = focused
+ ..downPosition = downPosition
+ ..activeColor = effectiveActiveColor
+ ..inactiveColor = effectiveInactiveColor
+ ..checkColor = effectiveCheckColor
+ ..value = value
+ ..previousValue = _previousValue
+ ..isActive = widget.onChanged != null
+ ..shape = widget.shape ?? RoundedRectangleBorder(
+ borderRadius: BorderRadius.circular(4.0),
+ )
+ ..side = widget.side,
+ ),
+ );
+ }
+}
+
+class _CheckboxPainter extends ToggleablePainter {
+ Color get checkColor => _checkColor!;
+ Color? _checkColor;
+ set checkColor(Color value) {
+ if (_checkColor == value) {
+ return;
+ }
+ _checkColor = value;
+ notifyListeners();
+ }
+
+ bool? get value => _value;
+ bool? _value;
+ set value(bool? value) {
+ if (_value == value) {
+ return;
+ }
+ _value = value;
+ notifyListeners();
+ }
+
+ bool? get previousValue => _previousValue;
+ bool? _previousValue;
+ set previousValue(bool? value) {
+ if (_previousValue == value) {
+ return;
+ }
+ _previousValue = value;
+ notifyListeners();
+ }
+
+ OutlinedBorder get shape => _shape!;
+ OutlinedBorder? _shape;
+ set shape(OutlinedBorder value) {
+ if (_shape == value) {
+ return;
+ }
+ _shape = value;
+ notifyListeners();
+ }
+
+ BorderSide? get side => _side;
+ BorderSide? _side;
+ set side(BorderSide? value) {
+ if (_side == value) {
+ return;
+ }
+ _side = value;
+ notifyListeners();
+ }
+
+ Rect _outerRectAt(Offset origin) {
+ const double size = CupertinoCheckbox.width;
+ final Rect rect = Rect.fromLTWH(origin.dx, origin.dy, size, size);
+ return rect;
+ }
+
+ // The checkbox's border color if value == false, or its fill color when
+ // value == true or null.
+ Color _colorAt(bool value) {
+ return value && isActive ? activeColor : inactiveColor;
+ }
+
+ // White stroke used to paint the check and dash.
+ Paint _createStrokePaint() {
+ return Paint()
+ ..color = checkColor
+ ..style = PaintingStyle.stroke
+ ..strokeWidth = 2.5
+ ..strokeCap = StrokeCap.round;
+ }
+
+ void _drawBox(Canvas canvas, Rect outer, Paint paint, BorderSide? side, bool fill) {
+ if (fill) {
+ canvas.drawPath(shape.getOuterPath(outer), paint);
+ }
+ if (side != null) {
+ shape.copyWith(side: side).paint(canvas, outer);
+ }
+ }
+
+ void _drawCheck(Canvas canvas, Offset origin, Paint paint) {
+ final Path path = Path();
+ // The ratios for the offsets below were found from looking at the checkbox
+ // examples on in the HIG docs. The distance from the needed point to the
+ // edge was measured, then devided by the total width.
+ const Offset start = Offset(CupertinoCheckbox.width * 0.25, CupertinoCheckbox.width * 0.52);
+ const Offset mid = Offset(CupertinoCheckbox.width * 0.46, CupertinoCheckbox.width * 0.75);
+ const Offset end = Offset(CupertinoCheckbox.width * 0.72, CupertinoCheckbox.width * 0.29);
+ path.moveTo(origin.dx + start.dx, origin.dy + start.dy);
+ path.lineTo(origin.dx + mid.dx, origin.dy + mid.dy);
+ canvas.drawPath(path, paint);
+ path.moveTo(origin.dx + mid.dx, origin.dy + mid.dy);
+ path.lineTo(origin.dx + end.dx, origin.dy + end.dy);
+ canvas.drawPath(path, paint);
+ }
+
+ void _drawDash(Canvas canvas, Offset origin, Paint paint) {
+ // From measuring the checkbox example in the HIG docs, the dash was found
+ // to be half the total width, centered in the middle.
+ const Offset start = Offset(CupertinoCheckbox.width * 0.25, CupertinoCheckbox.width * 0.5);
+ const Offset end = Offset(CupertinoCheckbox.width * 0.75, CupertinoCheckbox.width * 0.5);
+ canvas.drawLine(origin + start, origin + end, paint);
+ }
+
+ @override
+ void paint(Canvas canvas, Size size) {
+ final Paint strokePaint = _createStrokePaint();
+ final Offset origin = size / 2.0 - const Size.square(CupertinoCheckbox.width) / 2.0 as Offset;
+
+ final Rect outer = _outerRectAt(origin);
+ final Paint paint = Paint()..color = _colorAt(value ?? true);
+
+ if (value == false) {
+
+ final BorderSide border = side ?? BorderSide(color: paint.color);
+ _drawBox(canvas, outer, paint, border, false);
+ } else {
+
+ _drawBox(canvas, outer, paint, side, true);
+ if (value ?? false) {
+ _drawCheck(canvas, origin, strokePaint);
+ } else {
+ _drawDash(canvas, origin, strokePaint);
+ }
+ }
+
+ if (isFocused) {
+ final Rect focusOuter = outer.inflate(1);
+
+ final Paint borderPaint = Paint()
+ ..color = focusColor
+ ..style = PaintingStyle.stroke
+ ..strokeWidth = 3.5;
+
+ _drawBox(canvas, focusOuter, borderPaint, side, true);
+ }
+ }
+}
diff --git a/packages/flutter/lib/src/cupertino/toggleable.dart b/packages/flutter/lib/src/cupertino/toggleable.dart
new file mode 100644
index 0000000000..78a80ccc01
--- /dev/null
+++ b/packages/flutter/lib/src/cupertino/toggleable.dart
@@ -0,0 +1,247 @@
+// 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/foundation.dart';
+import 'package:flutter/rendering.dart';
+import 'package:flutter/widgets.dart';
+
+/// A mixin for [StatefulWidget]s that implements iOS-themed toggleable
+/// controls (e.g.[CupertinoCheckbox]es).
+///
+/// This mixin implements the logic for toggling the control when tapped.
+/// It does not have any opinion about the visual representation of the
+/// toggleable widget. The visuals are defined by a [CustomPainter] passed to
+/// the [buildToggleable]. [State] objects using this mixin should call that
+/// method from their [build] method.
+///
+/// This mixin is used to implement the Cupertino components for
+/// [CupertinoCheckbox] controls.
+@optionalTypeArgs
+mixin ToggleableStateMixin on TickerProviderStateMixin {
+
+ /// Whether the [value] of this control can be changed by user interaction.
+ ///
+ /// The control is considered interactive if the [onChanged] callback is
+ /// non-null. If the callback is null, then the control is disabled and
+ /// non-interactive. A disabled checkbox, for example, is displayed using a
+ /// grey color and its value cannot be changed.
+ bool get isInteractive => onChanged != null;
+
+ /// Called when the control changes value.
+ ///
+ /// If the control is tapped, [onChanged] is called immediately with the new
+ /// value.
+ ///
+ /// The control is considered interactive (see [isInteractive]) if this
+ /// callback is non-null. If the callback is null, then the control is
+ /// disabled and non-interactive. A disabled checkbox, for example, is
+ /// displayed using a grey color and its value cannot be changed.
+ ValueChanged? get onChanged;
+
+ /// The [value] accessor returns false if this control is "inactive" (not
+ /// checked, off, or unselected).
+ ///
+ /// If [value] is true then the control "active" (checked, on, or selected). If
+ /// tristate is true and value is null, then the control is considered to be
+ /// in its third or "indeterminate" state..
+ bool? get value;
+
+ /// If true, [value] can be true, false, or null, otherwise [value] must
+ /// be true or false.
+ ///
+ /// When [tristate] is true and [value] is null, then the control is
+ /// considered to be in its third or "indeterminate" state.
+ bool get tristate;
+
+ /// The most recent [Offset] at which a pointer touched the Toggleable.
+ ///
+ /// This is null if currently no pointer is touching the Toggleable or if
+ /// [isInteractive] is false.
+ Offset? get downPosition => _downPosition;
+ Offset? _downPosition;
+
+ void _handleTapDown(TapDownDetails details) {
+ if (isInteractive) {
+ setState(() {
+ _downPosition = details.localPosition;
+ });
+ }
+ }
+
+ void _handleTap([Intent? _]) {
+ if (!isInteractive) {
+ return;
+ }
+ switch (value) {
+ case false:
+ onChanged!(true);
+ break;
+ case true:
+ onChanged!(tristate ? null : false);
+ break;
+ case null:
+ onChanged!(false);
+ break;
+ }
+ context.findRenderObject()!.sendSemanticsEvent(const TapSemanticEvent());
+ }
+
+ void _handleTapEnd([TapUpDetails? _]) {
+ if (_downPosition != null) {
+ setState(() { _downPosition = null; });
+ }
+ }
+
+ bool _focused = false;
+ void _handleFocusHighlightChanged(bool focused) {
+ if (focused != _focused) {
+ setState(() { _focused = focused; });
+ }
+ }
+
+ late final Map> _actionMap = >{
+ ActivateIntent: CallbackAction(onInvoke: _handleTap),
+ };
+
+ /// Typically wraps a `painter` that draws the actual visuals of the
+ /// Toggleable with logic to toggle it.
+ ///
+ /// Consider providing a subclass of [ToggleablePainter] as a `painter`.
+ ///
+ /// This method must be called from the [build] method of the [State] class
+ /// that uses this mixin. The returned [Widget] must be returned from the
+ /// build method - potentially after wrapping it in other widgets.
+ Widget buildToggleable({
+ FocusNode? focusNode,
+ Function(bool)? onFocusChange,
+ bool autofocus = false,
+ required Size size,
+ required CustomPainter painter,
+ }) {
+ return FocusableActionDetector(
+ focusNode: focusNode,
+ autofocus: autofocus,
+ onFocusChange: onFocusChange,
+ enabled: isInteractive,
+ actions: _actionMap,
+ onShowFocusHighlight: _handleFocusHighlightChanged,
+ child: GestureDetector(
+ excludeFromSemantics: !isInteractive,
+ onTapDown: isInteractive ? _handleTapDown : null,
+ onTap: isInteractive ? _handleTap : null,
+ onTapUp: isInteractive ? _handleTapEnd : null,
+ onTapCancel: isInteractive ? _handleTapEnd : null,
+ child: Semantics(
+ enabled: isInteractive,
+ child: CustomPaint(
+ size: size,
+ painter: painter,
+ ),
+ ),
+ ),
+ );
+ }
+}
+
+/// A base class for a [CustomPainter] that may be passed to
+/// [ToggleableStateMixin.buildToggleable] to draw the visual representation of
+/// a Toggleable.
+///
+/// Subclasses must implement the [paint] method to draw the actual visuals of
+/// the Toggleable.
+abstract class ToggleablePainter extends ChangeNotifier implements CustomPainter {
+ /// The color that should be used in the active state (i.e., when
+ /// [ToggleableStateMixin.value] is true).
+ ///
+ /// For example, a checkbox should use this color when checked.
+ Color get activeColor => _activeColor!;
+ Color? _activeColor;
+ set activeColor(Color value) {
+ if (_activeColor == value) {
+ return;
+ }
+ _activeColor = value;
+ notifyListeners();
+ }
+
+ /// The color that should be used in the inactive state (i.e., when
+ /// [ToggleableStateMixin.value] is false).
+ ///
+ /// For example, a checkbox should use this color when unchecked.
+ Color get inactiveColor => _inactiveColor!;
+ Color? _inactiveColor;
+ set inactiveColor(Color value) {
+ if (_inactiveColor == value) {
+ return;
+ }
+ _inactiveColor = value;
+ notifyListeners();
+ }
+
+ /// The color that should be used for the reaction when [isFocused] is true.
+ ///
+ /// Used when the toggleable needs to change the reaction color/transparency,
+ /// when it has focus.
+ Color get focusColor => _focusColor!;
+ Color? _focusColor;
+ set focusColor(Color value) {
+ if (value == _focusColor) {
+ return;
+ }
+ _focusColor = value;
+ notifyListeners();
+ }
+
+ /// The [Offset] within the Toggleable at which a pointer touched the Toggleable.
+ ///
+ /// This is null if currently no pointer is touching the Toggleable.
+ ///
+ /// Usually set to [ToggleableStateMixin.downPosition].
+ Offset? get downPosition => _downPosition;
+ Offset? _downPosition;
+ set downPosition(Offset? value) {
+ if (value == _downPosition) {
+ return;
+ }
+ _downPosition = value;
+ notifyListeners();
+ }
+
+ /// True if this toggleable has the input focus.
+ bool get isFocused => _isFocused!;
+ bool? _isFocused;
+ set isFocused(bool? value) {
+ if (value == _isFocused) {
+ return;
+ }
+ _isFocused = value;
+ notifyListeners();
+ }
+
+ /// Determines whether the toggleable shows as active.
+ bool get isActive => _isActive!;
+ bool? _isActive;
+ set isActive(bool? value) {
+ if (value == _isActive) {
+ return;
+ }
+ _isActive = value;
+ notifyListeners();
+ }
+
+ @override
+ bool shouldRepaint(covariant CustomPainter oldDelegate) => true;
+
+ @override
+ bool? hitTest(Offset position) => null;
+
+ @override
+ SemanticsBuilderCallback? get semanticsBuilder => null;
+
+ @override
+ bool shouldRebuildSemantics(covariant CustomPainter oldDelegate) => false;
+
+ @override
+ String toString() => describeIdentity(this);
+}
diff --git a/packages/flutter/test/cupertino/checkbox_test.dart b/packages/flutter/test/cupertino/checkbox_test.dart
new file mode 100644
index 0000000000..6fc2a2f6f3
--- /dev/null
+++ b/packages/flutter/test/cupertino/checkbox_test.dart
@@ -0,0 +1,414 @@
+// 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/cupertino.dart';
+import 'package:flutter/foundation.dart';
+import 'package:flutter/rendering.dart';
+import 'package:flutter/services.dart';
+import 'package:flutter_test/flutter_test.dart';
+
+import '../rendering/mock_canvas.dart';
+import '../widgets/semantics_tester.dart';
+
+void main() {
+ setUp(() {
+ debugResetSemanticsIdCounter();
+ });
+
+ testWidgets('CupertinoCheckbox semantics', (WidgetTester tester) async {
+ final SemanticsHandle handle = tester.ensureSemantics();
+
+ await tester.pumpWidget(
+ CupertinoApp (
+ home: Center(
+ child: CupertinoCheckbox(
+ value: false,
+ onChanged: (bool? b) { },
+ ),
+ )
+ ),
+ );
+
+ expect(tester.getSemantics(find.byType(Focus).last), matchesSemantics(
+ hasCheckedState: true,
+ hasEnabledState: true,
+ isEnabled: true,
+ hasTapAction: true,
+ isFocusable: true,
+ ));
+
+ await tester.pumpWidget(
+ CupertinoApp (
+ home: Center(
+ child: CupertinoCheckbox(
+ value: true,
+ onChanged: (bool? b) { },
+ ),
+ )
+ ),
+ );
+
+ expect(tester.getSemantics(find.byType(Focus).last), matchesSemantics(
+ hasCheckedState: true,
+ hasEnabledState: true,
+ isChecked: true,
+ isEnabled: true,
+ hasTapAction: true,
+ isFocusable: true,
+ ));
+
+ await tester.pumpWidget(
+ const CupertinoApp (
+ home: Center(
+ child: CupertinoCheckbox(
+ value: false,
+ onChanged: null,
+ ),
+ )
+ ),
+ );
+
+ expect(tester.getSemantics(find.byType(CupertinoCheckbox)), matchesSemantics(
+ hasCheckedState: true,
+ hasEnabledState: true,
+ // isFocusable is delayed by 1 frame.
+ isFocusable: true,
+ ));
+
+ await tester.pump();
+ // isFocusable should be false now after the 1 frame delay.
+ expect(tester.getSemantics(find.byType(CupertinoCheckbox)), matchesSemantics(
+ hasCheckedState: true,
+ hasEnabledState: true,
+ ));
+
+ await tester.pumpWidget(
+ const CupertinoApp (
+ home: Center(
+ child: CupertinoCheckbox(
+ value: true,
+ onChanged: null,
+ ),
+ )
+ ),
+ );
+
+ expect(tester.getSemantics(find.byType(CupertinoCheckbox)), matchesSemantics(
+ hasCheckedState: true,
+ hasEnabledState: true,
+ isChecked: true,
+ ));
+
+ await tester.pumpWidget(
+ const CupertinoApp (
+ home: Center(
+ child: CupertinoCheckbox(
+ value: null,
+ tristate: true,
+ onChanged: null,
+ ),
+ )
+ ),
+ );
+
+ expect(tester.getSemantics(find.byType(CupertinoCheckbox)), matchesSemantics(
+ hasCheckedState: true,
+ hasEnabledState: true,
+ isCheckStateMixed: true,
+ ));
+
+ await tester.pumpWidget(
+ const CupertinoApp (
+ home: Center(
+ child: CupertinoCheckbox(
+ value: true,
+ tristate: true,
+ onChanged: null,
+ ),
+ )
+ ),
+ );
+
+ expect(tester.getSemantics(find.byType(CupertinoCheckbox)), matchesSemantics(
+ hasCheckedState: true,
+ hasEnabledState: true,
+ isChecked: true,
+ ));
+
+ await tester.pumpWidget(
+ const CupertinoApp (
+ home: Center(
+ child: CupertinoCheckbox(
+ value: false,
+ tristate: true,
+ onChanged: null,
+ ),
+ )
+ ),
+ );
+
+ expect(tester.getSemantics(find.byType(CupertinoCheckbox)), matchesSemantics(
+ hasCheckedState: true,
+ hasEnabledState: true,
+ ));
+
+ handle.dispose();
+ });
+
+ testWidgets('Can wrap CupertinoCheckbox with Semantics', (WidgetTester tester) async {
+ final SemanticsHandle handle = tester.ensureSemantics();
+
+ await tester.pumpWidget(
+ CupertinoApp(
+ home: Semantics(
+ label: 'foo',
+ textDirection: TextDirection.ltr,
+ child: CupertinoCheckbox(
+ value: false,
+ onChanged: (bool? b) { },
+ ),
+ ),
+ ),
+ );
+
+ expect(tester.getSemantics(find.byType(Focus).last), matchesSemantics(
+ label: 'foo',
+ textDirection: TextDirection.ltr,
+ hasCheckedState: true,
+ hasEnabledState: true,
+ isEnabled: true,
+ hasTapAction: true,
+ isFocusable: true,
+ ));
+ handle.dispose();
+ });
+
+ testWidgets('CupertinoCheckbox tristate: true', (WidgetTester tester) async {
+ bool? checkBoxValue;
+
+ await tester.pumpWidget(
+ CupertinoApp(
+ home: StatefulBuilder(
+ builder: (BuildContext context, StateSetter setState) {
+ return CupertinoCheckbox(
+ tristate: true,
+ value: checkBoxValue,
+ onChanged: (bool? value) {
+ setState(() {
+ checkBoxValue = value;
+ });
+ },
+ );
+ },
+ ),
+ ),
+ );
+
+ expect(tester.widget(find.byType(CupertinoCheckbox)).value, null);
+
+ await tester.tap(find.byType(CupertinoCheckbox));
+ await tester.pumpAndSettle();
+ expect(checkBoxValue, false);
+
+ await tester.tap(find.byType(CupertinoCheckbox));
+ await tester.pumpAndSettle();
+ expect(checkBoxValue, true);
+
+ await tester.tap(find.byType(CupertinoCheckbox));
+ await tester.pumpAndSettle();
+ expect(checkBoxValue, null);
+
+ checkBoxValue = true;
+ await tester.pumpAndSettle();
+ expect(checkBoxValue, true);
+
+ checkBoxValue = null;
+ await tester.pumpAndSettle();
+ expect(checkBoxValue, null);
+ });
+
+ testWidgets('has semantics for tristate', (WidgetTester tester) async {
+ final SemanticsTester semantics = SemanticsTester(tester);
+ await tester.pumpWidget(
+ CupertinoApp(
+ home: CupertinoCheckbox(
+ tristate: true,
+ value: null,
+ onChanged: (bool? newValue) { },
+ ),
+ ),
+ );
+
+ expect(semantics.nodesWith(
+ flags: [
+ SemanticsFlag.hasCheckedState,
+ SemanticsFlag.hasEnabledState,
+ SemanticsFlag.isEnabled,
+ SemanticsFlag.isFocusable,
+ SemanticsFlag.isCheckStateMixed,
+ ],
+ actions: [SemanticsAction.tap],
+ ), hasLength(1));
+
+ await tester.pumpWidget(
+ CupertinoApp(
+ home: CupertinoCheckbox(
+ tristate: true,
+ value: true,
+ onChanged: (bool? newValue) { },
+ ),
+ ),
+ );
+
+ expect(semantics.nodesWith(
+ flags: [
+ SemanticsFlag.hasCheckedState,
+ SemanticsFlag.hasEnabledState,
+ SemanticsFlag.isEnabled,
+ SemanticsFlag.isChecked,
+ SemanticsFlag.isFocusable,
+ ],
+ actions: [SemanticsAction.tap],
+ ), hasLength(1));
+
+ await tester.pumpWidget(
+ CupertinoApp(
+ home: CupertinoCheckbox(
+ tristate: true,
+ value: false,
+ onChanged: (bool? newValue) { },
+ ),
+ ),
+ );
+
+ expect(semantics.nodesWith(
+ flags: [
+ SemanticsFlag.hasCheckedState,
+ SemanticsFlag.hasEnabledState,
+ SemanticsFlag.isEnabled,
+ SemanticsFlag.isFocusable,
+ ],
+ actions: [SemanticsAction.tap],
+ ), hasLength(1));
+
+ semantics.dispose();
+ });
+
+ testWidgets('has semantic events', (WidgetTester tester) async {
+ dynamic semanticEvent;
+ bool? checkboxValue = false;
+ tester.binding.defaultBinaryMessenger.setMockDecodedMessageHandler(SystemChannels.accessibility, (dynamic message) async {
+ semanticEvent = message;
+ });
+ final SemanticsTester semanticsTester = SemanticsTester(tester);
+
+ await tester.pumpWidget(
+ CupertinoApp(
+ home: StatefulBuilder(
+ builder: (BuildContext context, StateSetter setState) {
+ return CupertinoCheckbox(
+ value: checkboxValue,
+ onChanged: (bool? value) {
+ setState(() {
+ checkboxValue = value;
+ });
+ },
+ );
+ },
+ ),
+ ),
+ );
+
+ await tester.tap(find.byType(CupertinoCheckbox));
+ final RenderObject object = tester.firstRenderObject(find.byType(CupertinoCheckbox));
+
+ expect(checkboxValue, true);
+ expect(semanticEvent, {
+ 'type': 'tap',
+ 'nodeId': object.debugSemantics!.id,
+ 'data': {},
+ });
+ expect(object.debugSemantics!.getSemanticsData().hasAction(SemanticsAction.tap), true);
+
+ tester.binding.defaultBinaryMessenger.setMockDecodedMessageHandler(SystemChannels.accessibility, null);
+ semanticsTester.dispose();
+ });
+
+ testWidgets('Checkbox can be toggled by keyboard shortcuts', (WidgetTester tester) async {
+ tester.binding.focusManager.highlightStrategy = FocusHighlightStrategy.alwaysTraditional;
+ bool? value = true;
+ Widget buildApp({bool enabled = true}) {
+ return CupertinoApp(
+ home: Center(
+ child: StatefulBuilder(builder: (BuildContext context, StateSetter setState) {
+ return CupertinoCheckbox(
+ value: value,
+ onChanged: enabled ? (bool? newValue) {
+ setState(() {
+ value = newValue;
+ });
+ } : null,
+ autofocus: true,
+ );
+ }),
+ ),
+ );
+ }
+ await tester.pumpWidget(buildApp());
+ await tester.pumpAndSettle();
+ await tester.sendKeyEvent(LogicalKeyboardKey.enter);
+ await tester.pumpAndSettle();
+ // On web, switches don't respond to the enter key.
+ expect(value, kIsWeb ? isTrue : isFalse);
+ await tester.sendKeyEvent(LogicalKeyboardKey.enter);
+ await tester.pumpAndSettle();
+ expect(value, isTrue);
+ await tester.sendKeyEvent(LogicalKeyboardKey.space);
+ await tester.pumpAndSettle();
+ expect(value, isFalse);
+ await tester.sendKeyEvent(LogicalKeyboardKey.space);
+ await tester.pumpAndSettle();
+ expect(value, isTrue);
+ });
+
+ testWidgets('Checkbox respects shape and side', (WidgetTester tester) async {
+ const RoundedRectangleBorder roundedRectangleBorder =
+ RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(5)));
+
+ const BorderSide side = BorderSide(
+ width: 4,
+ color: Color(0xfff44336),
+ );
+
+ Widget buildApp() {
+ return CupertinoApp(
+ home: Center(
+ child: StatefulBuilder(builder: (BuildContext context, StateSetter setState) {
+ return CupertinoCheckbox(
+ value: false,
+ onChanged: (bool? newValue) {},
+ shape: roundedRectangleBorder,
+ side: side,
+ );
+ }),
+ ),
+ );
+ }
+
+ await tester.pumpWidget(buildApp());
+ await tester.pumpAndSettle();
+
+ expect(tester.widget(find.byType(CupertinoCheckbox)).shape, roundedRectangleBorder);
+ expect(tester.widget(find.byType(CupertinoCheckbox)).side, side);
+ expect(
+ find.byType(CupertinoCheckbox),
+ paints
+ ..drrect(
+ color: const Color(0xfff44336),
+ outer: RRect.fromLTRBR(13.0, 13.0, 31.0, 31.0, const Radius.circular(5)),
+ inner: RRect.fromLTRBR(17.0, 17.0, 27.0, 27.0, const Radius.circular(1)),
+ ),
+ );
+ });
+}