diff --git a/packages/material_ui/lib/src/checkbox.dart b/packages/material_ui/lib/src/checkbox.dart index 59660b5991e4..2c9ac346ce3f 100644 --- a/packages/material_ui/lib/src/checkbox.dart +++ b/packages/material_ui/lib/src/checkbox.dart @@ -11,6 +11,7 @@ library; import 'package:cupertino_ui/cupertino_ui.dart'; +import 'package:flutter/foundation.dart' show clampDouble; import 'checkbox_theme.dart'; import 'color_scheme.dart'; @@ -113,6 +114,7 @@ class Checkbox extends StatefulWidget { this.side, this.isError = false, this.semanticLabel, + this.markInsets, }) : _checkboxType = _CheckboxType.material, assert(tristate || value != null); @@ -127,9 +129,9 @@ class Checkbox extends StatefulWidget { /// /// If a [CupertinoCheckbox] is created, the following parameters are ignored: /// [fillColor], [hoverColor], [overlayColor], [splashRadius], - /// [materialTapTargetSize], [visualDensity], [isError]. However, [shape] and - /// [side] will still affect the [CupertinoCheckbox] and should be handled if - /// native fidelity is important. + /// [materialTapTargetSize], [visualDensity], [isError], [markInsets]. However, + /// [shape] and [side] will still affect the [CupertinoCheckbox] and should be + /// handled if native fidelity is important. /// /// The target platform is based on the current [Theme]: [ThemeData.platform]. const Checkbox.adaptive({ @@ -153,6 +155,7 @@ class Checkbox extends StatefulWidget { this.side, this.isError = false, this.semanticLabel, + this.markInsets, }) : _checkboxType = _CheckboxType.adaptive, assert(tristate || value != null); @@ -418,6 +421,27 @@ class Checkbox extends StatefulWidget { /// {@endtemplate} final String? semanticLabel; + /// {@template flutter.material.checkbox.markInsets} + /// The insets applied around the check mark when the checkbox is checked or + /// in its indeterminate (tristate) state. + /// + /// This insets the check mark inward; the size of the checkbox box itself + /// is not affected. The check mark and its stroke shrink to fit the + /// remaining space, so the mark stays proportional. + /// + /// On iOS and macOS, when using [Checkbox.adaptive], this property has no + /// effect, because the checkbox delegates to [CupertinoCheckbox], which does + /// not support insets. + /// + /// Must be non-negative. If the horizontal or vertical insets are + /// greater than or equal to the checkbox size, the check mark or indeterminate + /// dash is not painted. + /// + /// If null, [CheckboxThemeData.markInsets] is used. If that is also null, + /// it defaults to [EdgeInsets.zero] and the check mark renders at full size. + /// {@endtemplate} + final EdgeInsets? markInsets; + /// The width of a checkbox widget. static const double width = 18.0; @@ -629,6 +653,8 @@ class _CheckboxState extends State with TickerProviderStateMixin, Togg final double effectiveSplashRadius = widget.splashRadius ?? checkboxTheme.splashRadius ?? defaults.splashRadius!; + final EdgeInsets markInsets = widget.markInsets ?? checkboxTheme.markInsets ?? EdgeInsets.zero; + return Semantics( label: widget.semanticLabel, checked: widget.value ?? false, @@ -658,6 +684,7 @@ class _CheckboxState extends State with TickerProviderStateMixin, Togg ..previousValue = _previousValue ..shape = widget.shape ?? checkboxTheme.shape ?? defaults.shape! ..activeSide = activeSide + ..markInsets = markInsets ..inactiveSide = inactiveSide, ), ); @@ -708,6 +735,16 @@ class _CheckboxPainter extends ToggleablePainter { notifyListeners(); } + EdgeInsets get markInsets => _markInsets; + EdgeInsets _markInsets = EdgeInsets.zero; + set markInsets(EdgeInsets value) { + if (_markInsets == value) { + return; + } + _markInsets = value; + notifyListeners(); // triggers repaint when it changes + } + BorderSide get activeSide => _activeSide!; BorderSide? _activeSide; set activeSide(BorderSide value) { @@ -769,9 +806,20 @@ class _CheckboxPainter extends ToggleablePainter { // As t goes from 0.0 to 1.0, animate the two check mark strokes from the // short side to the long side. final path = Path(); - const start = Offset(_kEdgeSize * 0.15, _kEdgeSize * 0.45); - const mid = Offset(_kEdgeSize * 0.4, _kEdgeSize * 0.7); - const end = Offset(_kEdgeSize * 0.85, _kEdgeSize * 0.25); + final double innerW = clampDouble(_kEdgeSize - _markInsets.horizontal, 0.0, _kEdgeSize); + final double innerH = clampDouble(_kEdgeSize - _markInsets.vertical, 0.0, _kEdgeSize); + if (innerW <= 0 || innerH <= 0) { + return; + } // no room — paint nothing + + // Shrink the stroke proportionally so a smaller mark doesn't look chunky. + final double scale = (innerW < innerH ? innerW : innerH) / _kEdgeSize; + paint.strokeWidth = _kStrokeWidth * scale; + + final start = Offset(_markInsets.left + innerW * 0.15, _markInsets.top + innerH * 0.45); + final mid = Offset(_markInsets.left + innerW * 0.40, _markInsets.top + innerH * 0.70); + final end = Offset(_markInsets.left + innerW * 0.85, _markInsets.top + innerH * 0.25); + if (t < 0.5) { final double strokeT = t * 2.0; final Offset drawMid = Offset.lerp(start, mid, strokeT)!; @@ -791,9 +839,19 @@ class _CheckboxPainter extends ToggleablePainter { assert(t >= 0.0 && t <= 1.0); // As t goes from 0.0 to 1.0, animate the horizontal line from the // mid point outwards. - const start = Offset(_kEdgeSize * 0.2, _kEdgeSize * 0.5); - const mid = Offset(_kEdgeSize * 0.5, _kEdgeSize * 0.5); - const end = Offset(_kEdgeSize * 0.8, _kEdgeSize * 0.5); + final double innerW = clampDouble(_kEdgeSize - _markInsets.horizontal, 0.0, _kEdgeSize); + final double innerH = clampDouble(_kEdgeSize - _markInsets.vertical, 0.0, _kEdgeSize); + if (innerW <= 0 || innerH <= 0) { + return; + } // nothing to draw + + final double scale = (innerW < innerH ? innerW : innerH) / _kEdgeSize; + paint.strokeWidth = _kStrokeWidth * scale; // shrink stroke too + + final start = Offset(_markInsets.left + innerW * 0.2, _markInsets.top + innerH * 0.5); + final mid = Offset(_markInsets.left + innerW * 0.5, _markInsets.top + innerH * 0.5); + final end = Offset(_markInsets.left + innerW * 0.8, _markInsets.top + innerH * 0.5); + final Offset drawStart = Offset.lerp(start, mid, 1.0 - t)!; final Offset drawEnd = Offset.lerp(mid, end, t)!; canvas.drawLine(origin + drawStart, origin + drawEnd, paint); diff --git a/packages/material_ui/lib/src/checkbox_theme.dart b/packages/material_ui/lib/src/checkbox_theme.dart index e5c6a7a099ed..fdce88141ec9 100644 --- a/packages/material_ui/lib/src/checkbox_theme.dart +++ b/packages/material_ui/lib/src/checkbox_theme.dart @@ -48,6 +48,7 @@ class CheckboxThemeData with Diagnosticable { this.visualDensity, this.shape, this.side, + this.markInsets, }); /// {@macro material_ui.checkbox.mouseCursor} @@ -102,6 +103,9 @@ class CheckboxThemeData with Diagnosticable { /// If specified, overrides the default value of [Checkbox.side]. final BorderSide? side; + /// {@macro flutter.material.checkbox.markInsets} + final EdgeInsets? markInsets; + /// Creates a copy of this object but with the given fields replaced with the /// new values. CheckboxThemeData copyWith({ @@ -114,6 +118,7 @@ class CheckboxThemeData with Diagnosticable { VisualDensity? visualDensity, OutlinedBorder? shape, BorderSide? side, + EdgeInsets? markInsets, }) { return CheckboxThemeData( mouseCursor: mouseCursor ?? this.mouseCursor, @@ -125,6 +130,7 @@ class CheckboxThemeData with Diagnosticable { visualDensity: visualDensity ?? this.visualDensity, shape: shape ?? this.shape, side: side ?? this.side, + markInsets: markInsets ?? this.markInsets, ); } @@ -150,6 +156,7 @@ class CheckboxThemeData with Diagnosticable { visualDensity: t < 0.5 ? a?.visualDensity : b?.visualDensity, shape: ShapeBorder.lerp(a?.shape, b?.shape, t) as OutlinedBorder?, side: _lerpSides(a?.side, b?.side, t), + markInsets: EdgeInsets.lerp(a?.markInsets, b?.markInsets, t), ); } @@ -164,6 +171,7 @@ class CheckboxThemeData with Diagnosticable { visualDensity, shape, side, + markInsets, ); @override @@ -183,7 +191,8 @@ class CheckboxThemeData with Diagnosticable { other.materialTapTargetSize == materialTapTargetSize && other.visualDensity == visualDensity && other.shape == shape && - other.side == side; + other.side == side && + other.markInsets == markInsets; } @override @@ -226,6 +235,7 @@ class CheckboxThemeData with Diagnosticable { ); properties.add(DiagnosticsProperty('shape', shape, defaultValue: null)); properties.add(DiagnosticsProperty('side', side, defaultValue: null)); + properties.add(DiagnosticsProperty('markInsets', markInsets, defaultValue: null)); } // Special case because BorderSide.lerp() doesn't support null arguments diff --git a/packages/material_ui/pending_changelogs/change_2026_08_30_checkbox_mark_insets.yaml b/packages/material_ui/pending_changelogs/change_2026_08_30_checkbox_mark_insets.yaml new file mode 100644 index 000000000000..dc39552ad20f --- /dev/null +++ b/packages/material_ui/pending_changelogs/change_2026_08_30_checkbox_mark_insets.yaml @@ -0,0 +1,5 @@ + changelog: | + - Adds `Checkbox.markInsets` and `CheckboxThemeData.markInsets`, which inset the + check mark (and the indeterminate dash) within the checkbox without changing the + size of the box itself. The mark and its stroke shrink proportionally. + version: minor \ No newline at end of file diff --git a/packages/material_ui/test/checkbox_test.dart b/packages/material_ui/test/checkbox_test.dart index ba1dd7808dff..8ecc60e018f1 100644 --- a/packages/material_ui/test/checkbox_test.dart +++ b/packages/material_ui/test/checkbox_test.dart @@ -2484,6 +2484,98 @@ void main() { ); expect(tester.takeException(), isNull); }); + + testWidgets('Checkbox.markInsets defaults to null', (WidgetTester tester) async { + await tester.pumpWidget(_padFrame(true)); + expect(tester.widget(find.byType(Checkbox)).markInsets, isNull); + }); + + testWidgets('Checkbox with no markInsets paints the check at full stroke width', ( + WidgetTester tester, + ) async { + await tester.pumpWidget(_padFrame(true)); + await tester.pumpAndSettle(); + // default: scale == 1.0, so strokeWidth stays at _kStrokeWidth (2.0) + expect(_checkboxRenderer(tester), paints..path(strokeWidth: 2.0)); + }); + + testWidgets('Checkbox.markInsets shrinks the check mark and its stroke', ( + WidgetTester tester, + ) async { + await tester.pumpWidget(_padFrame(true, markInsets: const EdgeInsets.all(4.5))); + await tester.pumpAndSettle(); + // inner = 18 - 9 = 9, scale = 9/18 = 0.5, strokeWidth = 2.0 * 0.5 = 1.0 + expect(_checkboxRenderer(tester), paints..path(strokeWidth: 1.0)); + }); + + testWidgets('Checkbox.markInsets shrinks the indeterminate dash', (WidgetTester tester) async { + await tester.pumpWidget(_padFrame(null, tristate: true, markInsets: const EdgeInsets.all(4.5))); + await tester.pumpAndSettle(); + expect(_checkboxRenderer(tester), paints..line(strokeWidth: 1.0)); + }); + + testWidgets('Checkbox.markInsets larger than the box collapses the mark without crashing', ( + WidgetTester tester, + ) async { + await tester.pumpWidget(_padFrame(true, markInsets: const EdgeInsets.all(9.0))); + await tester.pumpAndSettle(); + expect(tester.takeException(), isNull); // no exception thrown + expect(_checkboxRenderer(tester), isNot(paints..path())); // mark collapsed away + }); + + testWidgets('Checkbox.markInsets falls back to CheckboxThemeData.markInsets', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(checkboxTheme: const CheckboxThemeData(markInsets: EdgeInsets.all(4.5))), + home: Material( + child: Center(child: Checkbox(value: true, onChanged: (bool? v) {})), + ), + ), + ); + await tester.pumpAndSettle(); + expect(_checkboxRenderer(tester), paints..path(strokeWidth: 1.0)); // theme markInsets applied + }); + + testWidgets('Checkbox.markInsets overrides CheckboxThemeData.markInsets', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(checkboxTheme: const CheckboxThemeData(markInsets: EdgeInsets.zero)), + home: Material( + child: Center( + child: Checkbox( + value: true, + onChanged: (bool? v) {}, + markInsets: const EdgeInsets.all(4.5), + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + expect(_checkboxRenderer(tester), paints..path(strokeWidth: 1.0)); // widget value wins + }); +} + +RenderBox _checkboxRenderer(WidgetTester tester) => + tester.renderObject(find.byType(Checkbox)); + +Widget _padFrame(bool? value, {EdgeInsets? markInsets, bool tristate = false}) { + return MaterialApp( + home: Material( + child: Center( + child: Checkbox( + value: value, + tristate: tristate, + markInsets: markInsets, + onChanged: (bool? v) {}, + ), + ), + ), + ); } class _SelectedGrabMouseCursor extends WidgetStateMouseCursor {