74.1 CustomCheckbox
The default Checkbox component in Flutter does not allow for custom sizing. In this section, we will demonstrate how to create a CustomCheckbox component that supports customizable sizes by defining a RenderObject (rather than through composition). The desired effect of the CustomCheckbox is shown in Figure :
Two states: checked and unchecked.
Animation during state transitions.
Customizable appearance.
The definition of CustomCheckbox is as follows:
import 'dart:math'; import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; import 'package:flutter/scheduler.dart'; class CustomCheckbox extends LeafRenderObjectWidget { const CustomCheckbox({ Key? key, this.strokeWidth = 2.0, this.value = false, this.strokeColor = Colors.white, this.fillColor = Colors.blue, this.radius = 2.0, this.onChanged, }) : super(key: key); final double strokeWidth; // Width of the checkmark final Color strokeColor; // Color of the checkmark final Color? fillColor; // Fill color final bool value; // Checked state final double radius; // Corner radius final ValueChanged<bool>? onChanged; // Callback when the checked state changes @override RenderObject createRenderObject(BuildContext context) { return RenderCustomCheckbox( strokeWidth, strokeColor, fillColor ?? Theme.of(context).primaryColor, value, radius, onChanged, ); } @override void updateRenderObject(context, RenderCustomCheckbox renderObject) { if (renderObject.value != value) { renderObject.animationStatus = value ? AnimationStatus.forward : AnimationStatus.reverse; } renderObject ..strokeWidth = strokeWidth ..strokeColor = strokeColor ..fillColor = fillColor ?? Theme.of(context).primaryColor ..radius = radius ..value = value ..onChanged = onChanged; } }
The only thing to note in the code above is that in the updateRenderObject
method, when the checked state changes, we update the animation status in the RenderObject. Specifically, when transitioning from unchecked to checked, we execute the forward animation; when transitioning from checked to unchecked, we execute the reverse animation.
Next, we need to implement RenderCustomCheckbox
:
class RenderCustomCheckbox extends RenderBox { bool value; int pointerId = -1; double strokeWidth; Color strokeColor; Color fillColor; double radius; ValueChanged<bool>? onChanged; // Properties for scheduling animations double progress = 0; // Current animation progress int? _lastTimeStamp; // Last drawn timestamp // Animation duration Duration get duration => const Duration(milliseconds: 150); // Current animation status AnimationStatus _animationStatus = AnimationStatus.completed; set animationStatus(AnimationStatus v) { if (_animationStatus != v) { markNeedsPaint(); } _animationStatus = v; } // Background animation duration ratio (background animation should complete in the first 40% of the time, followed by the checkmark animation) final double bgAnimationInterval = .4; RenderCustomCheckbox(this.strokeWidth, this.strokeColor, this.fillColor, this.value, this.radius, this.onChanged) : progress = value ? 1 : 0; @override void performLayout() {} // Layout @override void paint(PaintingContext context, Offset offset) { Rect rect = offset & size; // Divide drawing into background (rectangle) and foreground (checkmark); first draw the background, then the checkmark _drawBackground(context, rect); _drawCheckMark(context, rect); // Schedule animation _scheduleAnimation(); } // Draw background void _drawBackground(PaintingContext context, Rect rect) {} // Draw "checkmark" void _drawCheckMark(PaintingContext context, Rect rect) { } // Schedule animation void _scheduleAnimation() {} ... // Handle click events }
#1. Implement Layout Algorithm
To allow users to customize width and height, our layout strategy is: if the parent component specifies fixed dimensions, use those; otherwise, default to 25:
@override void performLayout() { size = constraints.constrain( constraints.isTight ? Size.infinite : Size(25, 25), ); }
#2. Draw CustomCheckbox
The key point is drawing the CustomCheckbox. For clarity, we will divide the drawing into background (rectangle) and foreground (checkmark). First, draw the background, then the checkmark. Two points to note:
We are drawing a frame during the animation, so we need to calculate what to draw based on the animation progress (
progress
).When the CustomCheckbox transitions from unchecked to checked, we execute the forward animation; the value of
progress
will gradually change from 0 to 1. Since the background and foreground colors need to contrast, we will draw the foreground after the background. Therefore, we split the animation: the first 40% of the time is for the background, and the remaining 60% is for the checkmark.
#1) Draw Background
Referring to Figure , we will first see how to draw the background:
When the state changes to checked, the rectangle gradually fills from the edges to the center until the Checkbox area is filled. When the state changes to unchecked, the fill gradually dissipates from the center to the edges until only the border remains.
Figure 10-7
The implementation idea is to initially fill the entire background rectangle area with blue, then draw a white rectangle on top, dynamically changing the size of the white rectangle based on animation progress. Fortunately, the Canvas API provides the functionality we need with drawDRRect
, which allows us to specify inner and outer rectangles and draw the non-overlapping portion while setting corner radii. Below is the specific implementation:
void _drawBackground(PaintingContext context, Rect rect) { Color color = value ? fillColor : Colors.grey; var paint = Paint() ..isAntiAlias = true ..style = PaintingStyle.fill // Fill ..strokeWidth ..color = color; // We need to calculate the size of the rectangle for each frame; for this, we can directly use the rectangle interpolation method to determine the inner rectangle final outer = RRect.fromRectXY(rect, radius, radius); var rects = [ rect.inflate(-strokeWidth), Rect.fromCenter(center: rect.center, width: 0, height: 0) ]; // Adjust the inner rectangle size based on the animation progress var rectProgress = Rect.lerp( rects[0], rects[1], // Background animation executes in the first 40% of the time min(progress, bgAnimationInterval) / bgAnimationInterval, )!; final inner = RRect.fromRectXY(rectProgress, 0, 0); // Draw context.canvas.drawDRRect(outer, inner, paint); }
#2) Draw Foreground
The foreground is a checkmark, formed by connecting three points. For simplicity, we will calculate fixed coordinates for the starting point and the middle bend based on the Checkbox size, then dynamically adjust the position of the third point in each frame to achieve the checkmark animation:
// Draw "checkmark" void _drawCheckMark(PaintingContext context, Rect rect) { // Draw the foreground after the background if (progress > bgAnimationInterval) { // Determine the middle bend position final secondOffset = Offset( rect.left + rect.width / 2.5, rect.bottom - rect.height / 4, ); // Position of the third point final lastOffset = Offset( rect.right - rect.width / 6, rect.top + rect.height / 4, ); // Only interpolate the position of the third point final _lastOffset = Offset.lerp( secondOffset, lastOffset, (progress - bgAnimationInterval) / (1 - bgAnimationInterval), )!; // Connect the three points final path = Path() ..moveTo(rect.left + rect.width / 7, rect.top + rect.height / 2) ..lineTo(secondOffset.dx, secondOffset.dy) ..lineTo(_lastOffset.dx, _lastOffset.dy); final paint = Paint() ..isAntiAlias = true ..style = PaintingStyle.stroke ..color = strokeColor ..strokeWidth = strokeWidth; context.canvas.drawPath(path, paint..style = PaintingStyle.stroke); } }
#3. Implement Animation
Finally, we need to make the UI dynamic. Reflecting on the previous chapter about animations, we realize that Flutter's animation framework relies on StatefulWidget, which explicitly or implicitly triggers updates when the state changes. However, our CustomCheckbox, defined through RenderObject, is not based on StatefulWidget, so how can we schedule animations? There are two approaches:
Wrap the CustomCheckbox in a StatefulWidget to reuse the animation methods previously introduced.
Custom animation scheduling.
The first method is familiar, so we won't elaborate further. The second method involves checking if the animation has ended after each frame is drawn. If the animation is still ongoing, we mark the current component as “needs repaint,” and then wait for the next frame:
void _scheduleAnimation() { if (_animationStatus != AnimationStatus.completed) { // This needs to execute before Flutter's current frame ends because we cannot mark the component as needing repaint during the drawing process SchedulerBinding.instance.addPostFrameCallback((Duration timeStamp) { if (_lastTimeStamp != null) { double delta = (timeStamp.inMilliseconds - _lastTimeStamp!) / duration.inMilliseconds; // If it's a reverse animation, the progress value should gradually decrease if (_animationStatus == AnimationStatus.reverse) { delta = -delta; } // Update animation progress progress = progress + delta; if (progress >= 1 || progress <= 0) { // Animation has completed _animationStatus = AnimationStatus.completed; progress = progress.clamp(0, 1); } } // Mark as needing repaint markNeedsPaint(); _lastTimeStamp = timeStamp.inMilliseconds; }); } else { _lastTimeStamp = null; } }
#4. Handle Click Events
According to previous chapters on event handling, for a render object to handle events, it must pass hit testing. We need to add the following code:
// Must be set to true to ensure it passes hit testing @override bool hitTestSelf(Offset position) => true; // This method is only called if the hit test is successful; we trigger the event when the finger is lifted @override void handleEvent(PointerEvent event, covariant BoxHitTestEntry entry) { if (event.down) { pointerId = event.pointer; } else if (pointerId == event.pointer) { // Trigger callback when the finger is lifted onChanged?.call(!value); } }
74.2 Animation Scheduling Abstract: RenderObjectAnimationMixin
As we can see, scheduling animations within a RenderObject can be quite complex. To simplify this process, we abstracted an RenderObjectAnimationMixin
, which can be reused for any other RenderObjects that require animation.
mixin RenderObjectAnimationMixin on RenderObject { double _progress = 0; int? _lastTimeStamp; // Animation duration; subclasses can override this Duration get duration => const Duration(milliseconds: 200); AnimationStatus _animationStatus = AnimationStatus.completed; // Set animation status set animationStatus(AnimationStatus v) { if (_animationStatus != v) { markNeedsPaint(); } _animationStatus = v; } double get progress => _progress; set progress(double v) { _progress = v.clamp(0, 1); } @override void paint(PaintingContext context, Offset offset) { doPaint(context, offset); // Call subclass drawing logic _scheduleAnimation(); } void _scheduleAnimation() { if (_animationStatus != AnimationStatus.completed) { SchedulerBinding.instance.addPostFrameCallback((Duration timeStamp) { if (_lastTimeStamp != null) { double delta = (timeStamp.inMilliseconds - _lastTimeStamp!) / duration.inMilliseconds; // In certain cases, multiple frame callbacks might be added in one frame, resulting in a zero interval between callbacks. // In such cases, we should continue requesting a redraw. if (delta == 0) { markNeedsPaint(); return; } if (_animationStatus == AnimationStatus.reverse) { delta = -delta; } _progress += delta; if (_progress >= 1 || _progress <= 0) { _animationStatus = AnimationStatus.completed; _progress = _progress.clamp(0, 1); } } markNeedsPaint(); _lastTimeStamp = timeStamp.inMilliseconds; }); } else { _lastTimeStamp = null; } } // Subclasses implement drawing logic here void doPaint(PaintingContext context, Offset offset); }
74.3 Complete Source Code of CustomCheckbox
The final complete source code of CustomCheckbox
is as follows:
class CustomCheckbox extends LeafRenderObjectWidget { const CustomCheckbox({ Key? key, this.strokeWidth = 2.0, this.value = false, this.strokeColor = Colors.white, this.fillColor = Colors.blue, this.radius = 2.0, this.onChanged, }) : super(key: key); final double strokeWidth; // Width of the checkmark final Color strokeColor; // Color of the checkmark final Color? fillColor; // Fill color final bool value; // Checked state final double radius; // Corner radius final ValueChanged<bool>? onChanged; // Callback when the checked state changes @override RenderObject createRenderObject(BuildContext context) { return RenderCustomCheckbox( strokeWidth, strokeColor, fillColor ?? Theme.of(context).primaryColor, value, radius, onChanged, ); } @override void updateRenderObject(context, RenderCustomCheckbox renderObject) { if (renderObject.value != value) { renderObject.animationStatus = value ? AnimationStatus.forward : AnimationStatus.reverse; } renderObject ..strokeWidth = strokeWidth ..strokeColor = strokeColor ..fillColor = fillColor ?? Theme.of(context).primaryColor ..radius = radius ..value = value ..onChanged = onChanged; } } class RenderCustomCheckbox extends RenderBox with RenderObjectAnimationMixin { bool value; int pointerId = -1; double strokeWidth; Color strokeColor; Color fillColor; double radius; ValueChanged<bool>? onChanged; RenderCustomCheckbox(this.strokeWidth, this.strokeColor, this.fillColor, this.value, this.radius, this.onChanged) { progress = value ? 1 : 0; } @override bool get isRepaintBoundary => true; // Background animation duration ratio (the background animation must complete in the first 40% of the time, followed by the checkmark animation) final double bgAnimationInterval = .4; @override void doPaint(PaintingContext context, Offset offset) { Rect rect = offset & size; _drawBackground(context, rect); _drawCheckMark(context, rect); } void _drawBackground(PaintingContext context, Rect rect) { Color color = value ? fillColor : Colors.grey; var paint = Paint() ..isAntiAlias = true ..style = PaintingStyle.fill // Fill ..color = color; // We perform interpolation on the rectangle final outer = RRect.fromRectXY(rect, radius, radius); var rects = [ rect.inflate(-strokeWidth), Rect.fromCenter(center: rect.center, width: 0, height: 0) ]; var rectProgress = Rect.lerp( rects[0], rects[1], min(progress, bgAnimationInterval) / bgAnimationInterval, )!; final inner = RRect.fromRectXY(rectProgress, 0, 0); // Draw background context.canvas.drawDRRect(outer, inner, paint); } // Draw "checkmark" void _drawCheckMark(PaintingContext context, Rect rect) { // Draw foreground after the background if (progress > bgAnimationInterval) { // Determine the middle bend position final secondOffset = Offset( rect.left + rect.width / 2.5, rect.bottom - rect.height / 4, ); // Position of the third point final lastOffset = Offset( rect.right - rect.width / 6, rect.top + rect.height / 4, ); // We only interpolate the position of the third point final _lastOffset = Offset.lerp( secondOffset, lastOffset, (progress - bgAnimationInterval) / (1 - bgAnimationInterval), )!; // Connect the three points final path = Path() ..moveTo(rect.left + rect.width / 7, rect.top + rect.height / 2) ..lineTo(secondOffset.dx, secondOffset.dy) ..lineTo(_lastOffset.dx, _lastOffset.dy); final paint = Paint() ..isAntiAlias = true ..style = PaintingStyle.stroke ..color = strokeColor ..strokeWidth = strokeWidth; context.canvas.drawPath(path, paint..style = PaintingStyle.stroke); } } @override void performLayout() { // If the parent specifies fixed dimensions, use them; otherwise, default to 25 size = constraints.constrain( constraints.isTight ? Size.infinite : const Size(25, 25), ); } // Must be set to true to ensure it passes hit testing @override bool hitTestSelf(Offset position) => true; // This method is only called if the hit test is successful @override void handleEvent(PointerEvent event, covariant BoxHitTestEntry entry) { if (event.down) { pointerId = event.pointer; } else if (pointerId == event.pointer) { // Trigger onChange only if the finger is lifted within the component's bounds if (size.contains(event.localPosition)) { onChanged?.call(!value); } } } }
Test Code
The following code creates three checkboxes of different sizes. Clicking any of them will synchronize the state of the other two checkboxes:
class CustomCheckboxTest extends StatefulWidget { const CustomCheckboxTest({Key? key}) : super(key: key); @override State<CustomCheckboxTest> createState() => _CustomCheckboxTestState(); } class _CustomCheckboxTestState extends State<CustomCheckboxTest> { bool _checked = false; @override Widget build(BuildContext context) { return Center( child: Column(mainAxisAlignment: MainAxisAlignment.center, children: [ CustomCheckbox2( value: _checked, onChanged: _onChange, ), Padding( padding: const EdgeInsets.all(18.0), child: SizedBox( width: 16, height: 16, child: CustomCheckbox( strokeWidth: 1, radius: 1, value: _checked, onChanged: _onChange, ), ), ), SizedBox( width: 30, height: 30, child: CustomCheckbox( strokeWidth: 3, radius: 3, value: _checked, onChanged: _onChange, ), ), ], ), ); } void _onChange(value) { setState(() => _checked = value); } }
74.4 Conclusion
This section demonstrated how to implement UI rendering, animation scheduling, and event handling through custom RenderObject. As can be seen, creating components using RenderObject is more complex than using composition, but it brings us closer to the essence of Flutter components.