Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
76 changes: 67 additions & 9 deletions packages/material_ui/lib/src/checkbox.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -113,6 +114,7 @@ class Checkbox extends StatefulWidget {
this.side,
this.isError = false,
this.semanticLabel,
this.markInsets,
}) : _checkboxType = _CheckboxType.material,
assert(tristate || value != null);
Comment on lines 118 to 119

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Since the documentation for markInsets states that it "Must be non-negative", we should enforce this constraint with an assertion in the constructor to catch invalid values early in development.

  }) : _checkboxType = _CheckboxType.material,
       assert(tristate || value != null),
       assert(markInsets == null || (markInsets.left >= 0.0 && markInsets.top >= 0.0 && markInsets.right >= 0.0 && markInsets.bottom >= 0.0), 'markInsets must be non-negative.');


Expand All @@ -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({
Expand All @@ -153,6 +155,7 @@ class Checkbox extends StatefulWidget {
this.side,
this.isError = false,
this.semanticLabel,
this.markInsets,
}) : _checkboxType = _CheckboxType.adaptive,
assert(tristate || value != null);
Comment on lines 159 to 160

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Similarly, we should add the non-negative assertion for markInsets in the Checkbox.adaptive constructor.

  }) : _checkboxType = _CheckboxType.adaptive,
       assert(tristate || value != null),
       assert(markInsets == null || (markInsets.left >= 0.0 && markInsets.top >= 0.0 && markInsets.right >= 0.0 && markInsets.bottom >= 0.0), 'markInsets must be non-negative.');


Expand Down Expand Up @@ -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;

Expand Down Expand Up @@ -629,6 +653,8 @@ class _CheckboxState extends State<Checkbox> 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,
Expand Down Expand Up @@ -658,6 +684,7 @@ class _CheckboxState extends State<Checkbox> with TickerProviderStateMixin, Togg
..previousValue = _previousValue
..shape = widget.shape ?? checkboxTheme.shape ?? defaults.shape!
..activeSide = activeSide
..markInsets = markInsets
..inactiveSide = inactiveSide,
),
);
Expand Down Expand Up @@ -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
}
Comment on lines +740 to +746

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

To ensure that invalid negative insets provided via CheckboxThemeData (which doesn't have constructor assertions) are also caught before they cause rendering anomalies, we should add an assertion in the _CheckboxPainter.markInsets setter.

  set markInsets(EdgeInsets value) {
    if (_markInsets == value) {
      return;
    }
    assert(value.left >= 0.0 && value.top >= 0.0 && value.right >= 0.0 && value.bottom >= 0.0, 'markInsets must be non-negative.');
    _markInsets = value;
    notifyListeners(); // triggers repaint when it changes
  }


BorderSide get activeSide => _activeSide!;
BorderSide? _activeSide;
set activeSide(BorderSide value) {
Expand Down Expand Up @@ -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)!;
Expand All @@ -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);
Expand Down
12 changes: 11 additions & 1 deletion packages/material_ui/lib/src/checkbox_theme.dart
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ class CheckboxThemeData with Diagnosticable {
this.visualDensity,
this.shape,
this.side,
this.markInsets,
});

/// {@macro material_ui.checkbox.mouseCursor}
Expand Down Expand Up @@ -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({
Expand All @@ -114,6 +118,7 @@ class CheckboxThemeData with Diagnosticable {
VisualDensity? visualDensity,
OutlinedBorder? shape,
BorderSide? side,
EdgeInsets? markInsets,
}) {
return CheckboxThemeData(
mouseCursor: mouseCursor ?? this.mouseCursor,
Expand All @@ -125,6 +130,7 @@ class CheckboxThemeData with Diagnosticable {
visualDensity: visualDensity ?? this.visualDensity,
shape: shape ?? this.shape,
side: side ?? this.side,
markInsets: markInsets ?? this.markInsets,
);
}

Expand All @@ -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),
);
}

Expand All @@ -164,6 +171,7 @@ class CheckboxThemeData with Diagnosticable {
visualDensity,
shape,
side,
markInsets,
);

@override
Expand All @@ -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
Expand Down Expand Up @@ -226,6 +235,7 @@ class CheckboxThemeData with Diagnosticable {
);
properties.add(DiagnosticsProperty<OutlinedBorder>('shape', shape, defaultValue: null));
properties.add(DiagnosticsProperty<BorderSide>('side', side, defaultValue: null));
properties.add(DiagnosticsProperty<EdgeInsets>('markInsets', markInsets, defaultValue: null));
}

// Special case because BorderSide.lerp() doesn't support null arguments
Expand Down
Original file line number Diff line number Diff line change
@@ -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
92 changes: 92 additions & 0 deletions packages/material_ui/test/checkbox_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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<Checkbox>(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<RenderBox>(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 {
Expand Down