Flutter (74): Self-drawn component: CustomCheckbox

Time: Column:Mobile & Frontend views:289

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 :

Flutter (74): Self-drawn component: CustomCheckbox

  • 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:

  1. We are drawing a frame during the animation, so we need to calculate what to draw based on the animation progress (progress).

  2. 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:

Flutter (74): Self-drawn component: CustomCheckbox

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:

  1. Wrap the CustomCheckbox in a StatefulWidget to reuse the animation methods previously introduced.

  2. 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.