preface

Last month to participate in the Nuggets creators training camp, found that a brother in the training camp through CSS3 to achieve a beautiful dial, with CSS3 to make a beautiful dial, suggest beginners to watch, the effect is really good very beautiful, with the UI design drawing almost, I was wondering if I could achieve the same effect in Flutter, so I used the Canvas of Flutter during my free weekend to apply the same effect.

The final result is good, as follows:

implementation

Using Canvas in Flutter is more about inheriting the CustomPainter class to implement the paint method, and then using the custom CustomPainter in the CustomPaint. For example, the DialPainter created here uses the following:

  @override
  Widget build(BuildContext context) {
    double width = MediaQuery.of(context).size.width;
    return Container(
      color: const Color.fromARGB(255.35.36.38), /// Set the background
      child: Center(
        child: CustomPaint( 
          size: Size(width, width),
          painter: DialPainter(),
        ),
      ),
    );
  }

class DialPainter extends CustomPainter{
  @override
  void paint(Canvas canvas, Size size) {
  }

  @override
  bool shouldRepaint(covariant CustomPainter oldDelegate) {
    return true; }}Copy the code

After that all the core code for drawing is implemented in Paint in DialPainter, shouldRepaint indicates whether the parent control should be redrawn when it is rerendered, setting this to true means that it should be redrawn every time.

Next, look at the specific implementation code, we will achieve the effect of the whole dial is divided into three parts: panel, scale, pointer. Main knowledge points include Paint, Canvas, Path, and TextPainter.

Initialize the

Before you start drawing, initialize the brush and length units.

Brush Paint is used multiple times throughout the effect implementation. To avoid creating multiple brush instances, create a Paint member variable and then modify its property values to accommodate different effects.

  late final Paint _paint = _initPaint();

  Paint _initPaint() {
    returnPaint() .. isAntiAlias =true
      ..color = Colors.white;
  }
Copy the code

The anti-aliasing and default color of the brush are set through the initialization code.

In order to facilitate the subsequent use of length, width, radius and other lengths, create the corresponding member variable, and in order to adapt to different dial width and height, ensure the consistent display effect, in the drawing do not directly use the value, but use the proportional length:

/// Width of the canvas
late double width;
/// Height of the canvas
late double height;
/// Radius of the dial
late double radius;
/// Proportional unit length
late double unit ;

@override
void paint(Canvas canvas, Size size) {
  initSize(size);
}

void initSize(Size size) {
  width = size.width;
  height = size.height;
  radius = min(width, height) / 2;
  unit = radius / 15;
}
Copy the code

The radius takes the minimum width and height, and then divides it by 2. The unit length unit is the radius divided by 15.

For details about screen adaptation of Flutter, see: Frame Construction of Flutter application (2) Screen adaptation

panel

First draw a circle with a linear gradient:

/// Draw a circle with a linear gradient
var gradient = ui.Gradient.linear(
  Offset(width/2, height/2 - radius,),
  Offset(width/2, height/2 + radius),
  [const Color(0xFFF9F9F9), const Color(0xFF666666)]);

_paint.shader = gradient;
_paint.color = Colors.white;
canvas.drawCircle(Offset(width/2, height/2), radius, _paint);
Copy the code

Use gradient. linear to create a linear Gradient color and set it to Paint. Shader.

Then add a layer of radial gradient on it to increase the three-dimensional feel of the dial:

/// Draw a layer of radial gradient circles
var radialGradient = ui.Gradient.radial(Offset(width/2, height/2), radius, [
  const Color.fromARGB(216.246.248.249),
  const Color.fromARGB(216.229.235.238),
  const Color.fromARGB(216.205.212.217),
  const Color.fromARGB(216.245.247.249)], [0.0.92.0.93.1.0]);

_paint.shader = radialGradient;
canvas.drawCircle(Offset(width/2, height/2), radius -  0.3 * unit, _paint);
Copy the code

Use gradient.radial to create a radial Gradient color as follows:

Finally, add a border and shadow inside the dial for contrast:

/// Draw a border
var shadowRadius = radius -  0.8* unit; _paint .. color =const Color.fromARGB(33.0.0.0)
  ..shader = null. style = PaintingStyle.stroke .. strokeWidth =0.1 * unit;
canvas.drawCircle(Offset(width/2, height/2), shadowRadius - 0.2 * unit, _paint);

///Draw the shadow
Path path = Path();
path.moveTo(width/2, height/2);
var rect = Rect.fromLTRB(width/2 - shadowRadius, height/2 - shadowRadius, width/2+shadowRadius, height /2 +shadowRadius);
path.addOval(rect);
canvas.drawShadow(path, const Color.fromARGB(51.0.0.0), 1 * unit, true);
Copy the code

The final dial effect is as follows:

calibration

After the panel is drawn, the next step is to draw the scale line and the scale value.

Scale line

The code is as follows:

double dialCanvasRadius = radius -  0.8 * unit;
canvas.save();
canvas.translate(width/2, height/2);

var y = 0.0;
var x1 = 0.0;
var x2 = 0.0;

_paint.shader = null;
_paint.color = const Color(0xFF929394);
for( int i = 0; i < 60; i++){
  x1 =  dialCanvasRadius - (i % 5= =0 ? 0.85 * unit : 1 * unit);
  x2 = dialCanvasRadius - (i % 5= =0 ? 2 * unit : 1.67 * unit);
  _paint.strokeWidth = i % 5= =0 ? 0.38 * unit : 0.2 * unit;
  canvas.drawLine(Offset(x1, y), Offset(x2, y), _paint);
  canvas.rotate(2*pi/60);
}
canvas.restore();
Copy the code

There are 60 scales on the dial, among which 12 are hour scales and the rest are minute scales. The cycle is 60 times, and whether it is hour scales is determined by I % 5 == 0, so as to use different X and Y coordinates to achieve different lengths and widths.

In order to avoid calculating the coordinates of the points on the circle, the canvas is rotated. The canvas defaults to the top left corner of the rotation point, so move the rotation point to the center of the dial with Canvas.translate (width/2, height/2), then rotate the canvas 2* PI /60 for each scale, i.e. 6 degrees. Because the canvas has been shifted, the coordinates drawn are based on the center of the circle, i.e. the dot has moved to the center of the circle.

The final calibration effect is shown as follows:

Scale value

After drawing the scale, it is necessary to give the scale value. Here, only the four scale values of 3, 6, 9 and 12 are displayed. The code is as follows:

double dialCanvasRadius = radius -  0.8 * unit;
var textPainter = TextPainter(
  text: const TextSpan(
    text:
    "3",
    style: TextStyle(color: Colors.black, fontSize: 20, fontWeight: FontWeight.bold, height: 1.0)),
  textDirection: TextDirection.rtl,
  textWidthBasis: TextWidthBasis.longestLine,
  maxLines: 1,
)..layout();

var offset = 2.25 * unit;
var points = [
  Offset(width / 2 + dialCanvasRadius - offset - textPainter.width , height / 2 - textPainter.height / 2),
  Offset(width / 2 - textPainter.width /2, height / 2 + dialCanvasRadius - offset - textPainter.height),
  Offset(width / 2 - dialCanvasRadius + offset, height / 2 - textPainter.height / 2),
  Offset(width / 2 - textPainter.width, height / 2 - dialCanvasRadius + offset),
];
for(int i = 0; i< 4; i++){

  textPainter = TextPainter(
    text: TextSpan(
      text:
      "${(i + 1) * 3}",
      style: const TextStyle(color: Colors.black, fontSize: 20, fontWeight: FontWeight.bold, height: 1.0)),
    textDirection: TextDirection.rtl,
    textWidthBasis: TextWidthBasis.longestLine,
    maxLines: 1,
  )..layout();

  textPainter.paint(canvas, points[i]);
}
Copy the code

TextPainter object is used to draw the text. First, a TextPainter object is created to measure the width and height of the text. Since only four scale values are displayed here, the coordinates corresponding to the scale values to be drawn are calculated directly here, and then the displayed scale values are drawn in the corresponding positions. The results are as follows:

Pointer to the

Next is the drawing of the pointer, which is divided into three parts: hour hand, minute hand, second hand. We also need to draw the center point before drawing the pointer:

var radialGradient =
  ui.Gradient.radial(Offset(width / 2, height / 2), radius, [
    const Color.fromARGB(255.200.200.200),
    const Color.fromARGB(255.190.190.190),
    const Color.fromARGB(255.130.130.130)], [0.0.9.1.0]);

/// At the bottom of the background_paint .. shader = radialGradient .. style = PaintingStyle.fill; canvas.drawCircle( Offset(width/2, height/2), 2 * unit, _paint);


/// At the top of the dot_paint .. shader =null. style = PaintingStyle.fill .. color =const Color(0xFF121314);
canvas.drawCircle(Offset(width/2, height/2), 0.8 * unit, _paint);
Copy the code

The code is very simple, draw two circles in the center, a large circle with radial gradient at the bottom and a small circle with dark color at the top, as shown:

The hour hand

The hour hand is divided into three parts: the rectangle connecting the center, the semi-arc connecting the rectangle and the final arrow, as shown in the figure:

The code implementation is as follows:

double hourHalfHeight = 0.4 * unit;
double hourRectRight =   7 * unit;

Path hourPath = Path();
/// Add rectangular hour hand body
hourPath.moveTo(0 - hourHalfHeight, 0 - hourHalfHeight);
hourPath.lineTo(hourRectRight, 0 - hourHalfHeight);
hourPath.lineTo(hourRectRight, 0 + hourHalfHeight);
hourPath.lineTo(0 - hourHalfHeight, 0 + hourHalfHeight);

/// The clockwise arrow has a curved tail
double offsetTop = 0.5 * unit;
double arcWidth = 1.5 * unit;
double arrowWidth = 2.17 * unit;
double offset = 0.42 * unit;
var rect = Rect.fromLTWH(hourRectRight - offset, 0 - hourHalfHeight - offsetTop, arcWidth, hourHalfHeight * 2 + offsetTop * 2);
hourPath.addArc(rect, pi/2, pi);
/// Clockwise arrow
hourPath.moveTo(hourRectRight - offset + arcWidth/2.0 - hourHalfHeight - offsetTop);
hourPath.lineTo(hourRectRight - offset + arcWidth/2 + arrowWidth, 0);
hourPath.lineTo(hourRectRight - offset + arcWidth/2.0 + hourHalfHeight + offsetTop);
hourPath.close();

canvas.save();
canvas.translate(width/2, height/2);
///draw
_paint.color = const Color(0xFF232425);
canvas.drawPath(hourPath, _paint);
canvas.restore();
Copy the code

This is done by adding a rectangle to the Path through Path, then an arc that is offset to the left by a certain number of units to prevent bad docking, and then a triangle that is the arrow shape. All coordinates here are calculated based on the point being at the center of the disk, so you need to translate the canvas to move the point to the center of the disk. Canvas. Translate (width/2, height/2) Finally, draw using Canvas. drawPath. The effect is as follows:

Minute hand

Drawing a minute hand is relatively easy, as the minute hand is a rounded rectangle, using the drawRRect method of the canvas:

double hourHalfHeight = 0.4 * unit;
double minutesLeft = 1.33 * unit;
double minutesTop = -hourHalfHeight;
double minutesRight = 11* unit;
double minutesBottom = hourHalfHeight;

canvas.save();
canvas.translate(width/2, height/2);

/// Draw the minute hand
var rRect = RRect.fromLTRBR(minutesLeft, minutesTop, minutesRight, minutesBottom, Radius.circular(0.42 * unit));
_paint.color = const Color(0xFF343536);
canvas.drawRRect(rRect, _paint);

canvas.restore();
Copy the code

The realization idea is also to move the canvas to the circle point, and then calculate the coordinates to draw. It needs to be noted here that the tail of the minute hand is beyond the center of the big circle point, so the left here needs to shift to the left by a certain unit:

Here, the hour hand is hidden to see the effect of the minute hand

The second hand

The second hand is divided into four parts: tail arc, tail rounded rectangle, thin needle, central dot:

Implementation code:

double hourHalfHeight = 0.4 * unit;
double secondsLeft = 4.5 * unit;
double secondsTop = -hourHalfHeight;
double secondsRight = 12.5 * unit;
double secondsBottom = hourHalfHeight;

Path secondsPath = Path();
secondsPath.moveTo(secondsLeft, secondsTop);

/// The tail arc
var rect = Rect.fromLTWH(secondsLeft, secondsTop, 2.5 * unit, hourHalfHeight * 2);
secondsPath.addArc(rect, pi/2, pi);

/// Rounded tail rectangle
var rRect = RRect.fromLTRBR(secondsLeft + 1 * unit, secondsTop, - 2 * unit, secondsBottom, Radius.circular(0.25 * unit));
secondsPath.addRRect(rRect);

/// Pointer to the
secondsPath.moveTo(- 2 * unit, - 0.125 * unit);
secondsPath.lineTo(secondsRight, 0);
secondsPath.lineTo(2 - * unit, 0.125 * unit);

/// The center circle
var ovalRect = Rect.fromLTWH(- 0.67 * unit, - 0.67 * unit, 1.33 * unit, 1.33 * unit);
secondsPath.addOval(ovalRect);

canvas.save();
canvas.translate(width/2, height/2);

/// Draw the shadow
canvas.drawShadow(secondsPath, const Color(0xFFcc0000), 0.17 * unit, true);

/// Draw the second hand
_paint.color = const Color(0xFFcc0000);
canvas.drawPath(secondsPath, _paint);

canvas.restore();
Copy the code

The idea is the same as the implementation of the hour hand, using the Path to combine the arc, rounded rectangle, triangle, center circle, calculate the coordinates again with the center of the disk as the point, all the same need to use translate to move the canvas dot after drawing. Effect:

Also, in order to better see the effect of the second hand, the hour hand and minute hand are hidden

move

After the above drawing, we will all the elements of the dial are drawn out, but the most important is not moving up, moving up the key is to let the hour hand, minute hand, second hand offset a certain Angle, since it is offset Angle naturally thought of rotating canvas to achieve, similar to drawing scale.

Rotate the canvas at a certain Angle before drawing the hour hand, minute hand and second hand respectively:

/// The hour hand
canvas.save();
canvas.translate(width/2, height/2);
canvas.rotate(2*pi/4);
_paint.color = const Color(0xFF232425);
canvas.drawPath(hourPath, _paint);
canvas.restore();

///Minute hand
canvas.save();
canvas.translate(width/2, height/2);
canvas.rotate(2*pi/4*2);
var rRect = RRect.fromLTRBR(minutesLeft, minutesTop, minutesRight, minutesBottom, Radius.circular(0.42 * unit));
_paint.color = const Color(0xFF343536);
canvas.drawRRect(rRect, _paint);
canvas.restore();

///The second hand
canvas.save();
canvas.translate(width/2, height/2);
canvas.rotate(2*pi/4*3);
canvas.drawShadow(secondsPath, const Color(0xFFcc0000), 0.17 * unit, true);
_paint.color = const Color(0xFFcc0000);
canvas.drawPath(secondsPath, _paint);
canvas.restore();

Copy the code

Rotate the canvas 90°, 180° and 270° before the hour hand, minute hand and second hand respectively, and the effect is as follows:

We achieved the desired effect by rotating the canvas. The next step was to rotate the pointer by the appropriate Angle according to the time. You can get the current time object with datetime.now (), which in turn gets the current hour, minute, and second. Then calculate the corresponding Angle according to the corresponding value:

 var date = DateTime.now();

/// The hour hand
canvas.rotate(2*pi/60*((date.hour - 3 + date.minute / 60 + date.second/60/60) * 5 ));

/// Minute hand
canvas.rotate(2*pi/60 * (date.minute - 15 + date.second / 60));

/// The second hand
canvas.rotate(2*pi/60 * (date.second - 15));
Copy the code

First, divide 360 degrees into 60 parts and hour hour into 5 parts. Since the Angle starts from the center point on the right side, the hours obtained need to be reduced by 3, plus the proportion of minutes and seconds in hours. In the same way, the Angle of minute and second is calculated respectively, and finally the hour hand, minute hand and second hand are displayed according to the current time.

After the Angle calculation is correct, the whole dial needs to be refreshed, that is, once every second. When refreshing, the position of hour hand, minute hand and second hand will be redrawn by the current time to achieve dynamic effect. Here, the setState implementation of the parent layout is called by Timer every second.

  @override
  void initState() {
    super.initState();

    Timer.periodic(const Duration(seconds: 1), (timer) {
      setState(() {});
    });
  }
Copy the code

Finally, the dynamic effect of the dial that started to show is realized.

This article has been published to the public account loongwind