Gravid Banner

Sliding Timer Widget in Flutter

I’ve been experimenting a bit with Flutter animations and wanted to create a timer widget which displays the time in seconds, and updates by rolling up or down.

Extending Custom Painter

After a bit of research I decided to extend the CustomPainter object to draw the number elements for the timer.

class NumberPainter extends CustomPainter {
  Paint _paint = Paint();
  
  @override
  void paint(Canvas canvas, Size size) {
    // Draw my number square here
  }
  
  @override
  bool shouldRepaint(NumberPainter oldDelegate) {
    return true;
  }
Dart

The first step is to pass in some parameters to the custom painter which we will use to calculate the dimension, position, colour and contents of the square.

  final Color color;      // Color of the square
  final double magnitude; // Size of the square
  final String oldChar;   // Previous number
  final String newChar;   // Current number
  final double delta;     // Animation offset
  double? _radius, _inset, _width, _fontSize;
  
  NumberPainter({
    required double this.magnitude,
    required Color this.color,
    required String this.oldChar,
    required String this.newChar,
    required double this.slide}) {
    _inset = magnitude * .05; // margin
    _radius = magnitude * .2; // corner radius
    _width = magnitude * .9;  // square width less margins
    _fontSize = _width! * .7; // font size
  }
Dart

Next we can create a method to draw the number square, using fromLTWH to set the dimensions and RRect to render it as a rounded square. The method also takes a delta as a parameter to allow it to offset the square for the animation.

The textPainter sets the text attributes and then uses the offset to place the number in the centre of the square. textPainter.width and height return the dimensions of the actual character being rendered.

  void _roundSquare (x, y, delta, width, c, text, canvas) {
    _paint = _paint..color = c;
    var r = Rect.fromLTWH(x, y + delta, _width!, _width!);
    canvas.drawRRect(RRect.fromRectAndRadius(r, Radius.circular(_radius!)), _paint);

    var textStyle = TextStyle(
      color: Colors.black,
      fontSize: _fontSize,
    );
    var textSpan = TextSpan(
      text: text,
      style: textStyle,
    );
    final textPainter = TextPainter(
      text: textSpan,
      textDirection: TextDirection.ltr,
    );
    textPainter.layout(
      minWidth: 0,
      maxWidth: width,
    );

    final textX = (width - textPainter.width) / 2;
    final textY = ((width - textPainter.height) / 2) + delta;
    final offset = Offset(textX, textY);

    textPainter.paint(canvas, offset);
  }
Dart

Now we need to draw the number square by overriding custom painter’s paint method. The clip rectangle ensues that anything outside of the drawing area is neatly hidden. The routine draws two squares, one for the number that is sliding out and one for the number which is sliding in. delta2 tests for the animation direction so that the second square is drawn above the first if sliding down or below it if sliding up

@override
  void paint(Canvas canvas, Size size) {
    final rect = Offset.zero & size;
    canvas.clipRect(rect);

    double delta2 = (delta >= 0) ? delta - magnitude : delta + magnitude;
    _roundSquare(_inset!, _inset!, delta, size.width, color, oldChar, canvas);
    _roundSquare(_inset!, _inset!, delta2 , size.width, color, newChar, canvas);
  }
Dart

Including our CustomPainter in a Widget

To use NumberPainter we now need to include it in a widget. Once again the widget receives a number of parameters to tell it the size colour and contents of the text box.

import 'package:flutter/material.dart';
import "NumberPainter.dart";

class NumberSlideWidget extends StatefulWidget {
  final double magnitude; // The size of the boxes
  final Color color; // The color of the boxes
  final String contents; // The string content of the boxes

  const NumberSlideWidget({
    Key? key,
    required double this.magnitude,
    required Color this.color,
    required String this.contents,
  }) : super(key: key);
  @override
  _NumberSlideWidgetState createState() => _NumberSlideWidgetState();
}
Dart

The easiest way to animate the widget state is to extend TickerProviderStateMixin which gives us a ticker to run the animation. I’ve also declared an Animation, AnimationController and a Tween. The _hold variable is used to hang on to the old number in the numberSlider until the animation is completed, and the tweening variable, initialised here to false, will be set to true whilst an animation is in progress.

class _NumberSlideWidgetState extends State<NumberSlideWidget>
    with TickerProviderStateMixin {
  String _hold = "";

  Animation<double>? animation;
  AnimationController? controller;
  Tween<double>? _slideTween;
  bool tweening = false;
Dart

The widget Scaffold declares two states. One for when the animation is in progress and one for when it is stationary. It uses the tweening variable defined above to determine which one to call. It also uses the parameters defined in the StatefulWidget to set the size and colour.

The two calls are very similar apart from the oldChar parameter, which is set to the _hold value to show the previous number whilst animating, and the delta parameter which is set to animation.value whilst the animation is running and zero when it is stationary.

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: Center(
          child: Container(
            width: widget.magnitude,
            height: widget.magnitude,
            child: (tweening)          // switch based on tweening
            ? CustomPaint(
                foregroundPainter: NumberPainter(
                    magnitude: widget.magnitude,
                    color: widget.color,
                    oldChar: _hold,
                    newChar: widget.contents,
                    delta: animation!.value),
              )
            : CustomPaint(
                foregroundPainter: NumberPainter(
                    magnitude: widget.magnitude,
                    color: widget.color,
                    oldChar: widget.contents,
                    newChar: widget.contents,
                    delta: 0),
              ),
        )
      ),
    );
  }
Dart

Defining the Animation

In order to set the animation.value we need to define and run the animation. To do this we can override the initState() method.

The very first time initState is called, the contents of the number square are stored in the _hold variable. The _slideTween variable uses tween to determine the start and end values of animation.Value (and all the steps in between). Here we’ve started at zero and ended at the widget size. This will move our box exactly one square up (if positive) or down (if negative as shown below). The animation duration determines how quickly this happens. It’s set here to 500 milliseconds – the smaller the number the faster the boxes will scroll.

Now we attach the tween to the controller and set two callbacks. addListener is called on every tick of the ticker and sets the state. This updates the animation.Value based on our tween range to ensure a nice smooth animation. The addStatusListener allows us to check when the animation is finished. At this point we set the tweening variable to false – to let the scaffold know to use the stationary CustomPaint call. We must also update _hold to the current number being displayed (widget.contents) and reset the animation controller so that it is ready for the next animation cycle.

  @override
  void initState() {
    super.initState();
    if (_hold == "") _hold = widget.contents;
    _slideTween = Tween(begin: 0, end: -widget.magnitude);

    controller = AnimationController(
      vsync: this,
      duration: Duration(milliseconds: 500),
    );

    animation = _slideTween!.animate(controller!)
      ..addListener(() {
        setState(() {});
      })
      ..addStatusListener((status) {
        if (status == AnimationStatus.completed) {
          tweening = false;
          _hold = widget.contents;
          controller!.reset();
        }
      });
  }
Dart

The final thing that we need to do is actually start the animation running. This is done by adding a few lines into the build function we declared earlier. As we have already seen, the _hold variable is set to the current number of the timer stored in widget.contents. When a new number is set _hold is no longer equal to widget.contents and it is time to start the animation running. We can do this by calling controller.forward(). We also need to set tweening to true to let the scaffold know that the animation is running. In my code below I also check that tweening is not already set to prevent unnecessarily calling forward again. This doesn’t appear to be needed – the code works equally well either way but it does seem neater to me.

  Widget build(BuildContext context) {
    if (widget.contents != _hold && !tweening) {
      controller!.forward();
      tweening = true;
    }
    
    return Scaffold(
Dart

At the end of all of this, we now have a single animated timer square. In my next blog I’ll look at how to turn this into a useful multi-digit timer.

Flutter Engineering (Ad)


Comments

Leave a Reply

Your email address will not be published. Required fields are marked *