* This article has been published exclusively by guolin_blog, an official wechat account

preface

Recently, I made a weather-related app with Flutter. As a new student, I did not have a deep understanding of Flutter, but I was inspired by the programming ideas in the development process. Dart has excellent language features, a single-threaded model, asynchronous IO, initializer lists, functions as objects, chain calls, etc. The design idea of Flutter is very advanced. Well, I’ll leave it at that, but here’s one experience I came across during development regarding custom views and touch event handling. Take a look at the effect:

There are two main functions, one is to draw line chart to add text and pictures, the other is to click events, click different time points pop-up dialog box will show different time.

Drawing process

Fluttert provides a custom control API that is very similar to android’s canvas and Paint, with some minor changes, but should be easy to get started with. Here we should use three related classes:

StatefulWidget CustomPaint Custompainter

The StatefulWidget class is the required base class for flutter. It encapsulates our custom view as a separate stateful control and can pass in parameters to refresh the UI, which I won’t go into detail here.

CustomPaint class is a custom view must master classes, it inherits from SingleChildRenderObjectWidget, the official definition of him is to provide a canvas, when asked to draw, it invokes the first painter to paint their own content, Then draw the sub-view, and finally call foregroundPainter to draw the foreground, which is very similar to recyclerView drawing process.

The Custompainter class is a brush tool, which is the only one we’ll cover here. The void paint(Canvas Canvas, Size Size) method must be overridden to draw the desired effect. The two parameters here are relatively simple, one is the canvas, size is the location and size.

The coordinate system of Canvas is the same as that of Android. The upper left corner is the origin, the right direction is the positive direction of x axis, and the downward direction is the positive direction of Y axis. It is much easier to draw after mastering this point.

Let’s cut the crap and get to work.

Build StatefulWidget

Start by creating a class that inherits the StatefulWidget and passes in the following variables as build parameters:

 final List<HourlyForecast> hourlyList;// List of weather data
 final String imagePath;// Image path
 final EdgeInsetsGeometry padding;//padding
 final Size size;/ / size
 final void Function(int index) onTapUp;// Click the callback method of the event

Copy the code

Because I want to use these variables in the initialization list, I make them final, which means I don’t want to change them either. Note that the last variable is a function that takes the position index of the click, which is also a dart language feature that allows functions to be objects.

HourlyForecast is an entity class that is returned from the interface of a mild weather with the main data as follows:

class HourlyForecast {
  String time; // Forecast time in the format YYYY-MM-DD HH: MM 2013-12-30 13:00
  String tmp; 2 / / temperature
  String cond_code; // Weather code 101
  String cond_txt; // The weather code is cloudy
  String wind_deg; // Wind direction 360 Angle 290
  String wind_dir; // The wind blows northwest
  String wind_sc; / / wind 3-4
  String wind_spd; // Wind speed, km/h 15
  String hum; // Relative humidity 30
  String pres; // Atmospheric pressure 1030
  String dew; // The dew point temperature is 12
  String cloud; / / cloud cover 23
  bool isDay;

  HourlyForecast.formJson(Map<String.dynamic> json)
      : time = json['time'],
        tmp = json['tmp'],
        cond_code = json['cond_code'],
        cond_txt = json['cond_txt'],
        wind_deg = json['wind_deg'],
        wind_dir = json['wind_dir'],
        wind_sc = json['wind_sc'],
        wind_spd = json['wind_spd'],
        hum = json['hum'],
        pres = json['pres'],
        dew = json['dew'],
        cloud = json['cloud'] {
    isDay = DateTime.parse(time).hour > 6 && DateTime.parse(time).hour < 18;
  }

  String getHourTime() {
    return time.split(' ') [1]; }}Copy the code

The HourlyForecast. FormJson (Map

JSON) method is a simple JSON parsing method commonly used in DART, which can be directly exported from Map data in the convert package as entity classes.
,>

With the widgets defined, we also need to define a State to manage the State of the widgets. Take a look at the build method:

  @override
  Widget build(BuildContext context) {
    return GestureDetector(
      onTapUp: (TapUpDetails detail) {
        print('onTapUp');
        onTap(context, detail);
      },
      child: CustomSingleChildLayout(
        delegate: _SakaLayoutDelegate(widget.size, widget.padding),
        child: CustomPaint(
          painter: _HourlyForecastPaint(context, widget.hourlyList,
              widget.padding.deflateSize(widget.size), areaListCallback,
              imagePath: widget.imagePath,
              iconDay: iconDay,
              iconDayRect: iconDayRect,
              iconNight: iconNight,
              iconNightRect: iconNightRect),
        ),
      ),
    );
  }
Copy the code

The outermost layer is a GestureDectecor. This is the easiest way to handle a click event in a flutter, but note that OnTapUp only gets the global position of the click. We need to convert this to the relative coordinate position of the control.

Build CustomPaint

The real thing is the CustomSingleChildLayout control in this GestureDectector, which is a very simple but very useful class that can only load one control, And entrust himself and child controls SingleChildLayoutDelegate to locate the child controls in the positions of the parent control.

class _SakaLayoutDelegate extends SingleChildLayoutDelegate {
  final Size size;
  final EdgeInsetsGeometry padding;

  _SakaLayoutDelegate(this.size, this.padding)
      : assert(size ! =null),
        assert(padding ! =null);

  @override
  Size getSize(BoxConstraints constraints) {
    return size;
  }

  @override
  bool shouldRelayout(_SakaLayoutDelegate oldDelegate) {
    return this.size ! = oldDelegate.size; }@override
  BoxConstraints getConstraintsForChild(BoxConstraints constraints) {
    return BoxConstraints.tight(padding.deflateSize(size));
  }

  @override
  Offset getPositionForChild(Size size, Size childSize) {
    return Offset((size.width - childSize.width) / 2,
        (size.height - childSize.height) / 2); }}Copy the code

This is the main code in the class. GetSize returns the size of the parent control, where I use the parameter passed in from the StatefulWidget directly as the size of the parent control.

ShouldRelayout is the condition of relayout, here I directly judge that it is relayout when the size changes, this judgment method has met my needs.

GetPositionForChild returns the position of the child within the parent. I want the child to be centered, so I return an offset of half the size.

So we’ve got the size of the parent control and the padding of the child control positioned in this way. ,

The Painter variable in CustomPaint must be set. This is the main implementation method for painting, which is the CustomPainter class we will cover later.

CustomPaint size variable can’t be empty, the default is 0, so we adopted SingleChildLayoutDelegate above to set the size of CustomPaint, otherwise he will not display.

Tectonic CustomPainter

Let’s look at how to override the CustomPainter method:

@override
  void paint(Canvas canvas, Size size) {
    var rect = Offset.zero & size;
    canvas.clipRect(rect);// Cut the canvas
    drawPoint(canvas);// Draw points and polylines and corresponding numbers, ICONS, etc
  }
Copy the code

In the first line, we find a RECT. This rect is the area we need to draw. We need to crop the canvas only in the area, otherwise the brush will draw outside of this area. This rect check uses the Offset operator overload function, which produces a rect with Offset in the upper left corner and size the size of size. Very nifty operator overload I’ve only seen in C++.

Here’s a simple comparison:

canvas.drawCircle(size.center(Offset.zero), 150, p);
Copy the code

This is a circle with a radius of 200 drawn in the center of the canvas. You can see that it’s out of the canvas, but the circle is still there.

    var rect = Offset.zero & size;
    canvas.clipRect(rect);
    canvas.drawCircle(size.center(Offset.zero), 150, p);
Copy the code

Take a look at the main drawing methods:

void drawPoint(Canvas canvas) {
    canvas.save();
    canvas.translate(increaseX / 2.0.0);
    canvas.drawPoints(ui.PointMode.polygon, points, p);
    canvas.drawPoints(ui.PointMode.points, points, pointP);
    for (int i = 0; i < tempTextList.length; i++) {
      Offset point = points[i];
      canvas.drawParagraph(
          tempTextList[i], point - Offset(this.tempTextSize, 20.0));
      canvas.drawParagraph(hourTextList[i], Offset(point.dx - 15.0.0));
      canvas.drawImageRect(
          tempList[i].isDay ? iconDay : iconNight,
          tempList[i].isDay ? iconDayRect : iconNightRect,
          Offset(point.dx - iconSize.width / 2.this.hourTextSize + 10.0) &
              iconSize,
          p);
    }
    canvas.restore();
  }
Copy the code

Because there are several weather data, the horizontal length of the drawable area needs to be evenly divided according to the number of weather data, and each weather data occupies a certain range. When drawing points and icon text, we need to draw in the middle of this range, so we move the canvas coordinate system to the right half of the value of this range, and then draw on the canvas. When we are done, we restore the canvas, and the points are displayed in the middle position. Points can be drawn in three ways, as defined in the enumeration type PointMode: points, lines, and Polygons. These three methods are easier to use than in Java:

  1. Points are just drawing ordinary points
  2. Lines draw a line segment between two points, 0,1 in list draws a line segment, 2,3 draws a line segment, but there is no line segment between 1,2.
  3. Polygons connect all the points into a line
Rendering text

There are two ways to draw text. One is to construct TextPainter and use void paint(Canvas Canvas, Offset Offset) to draw text. The other option is to call the void drawParagraph(Paragraph Paragraph, Offset Offset) method, which I chose here. The second parameter, offset, is the position to draw. It’s easier. Look at the first parameter Paragraph, which is the main way we define text.

A Paragraph comes from the dart.ui library, is an engine created class, and cannot be inherited, and it is officially recommended that you use ParagraphBuilder to construct a Paragraph.

 ui.ParagraphBuilder paragraphBuilder = ui.ParagraphBuilder(
        ui.ParagraphStyle(
          textAlign: TextAlign.center,
          fontSize: 10.0,
          textDirection: TextDirection.ltr,
          maxLines: 1,
        ),
      )
        ..pushStyle(
          ui.TextStyle(
              color: Colors.black87, textBaseline: ui.TextBaseline.alphabetic),
        )
        ..addText(tmp.toInt().toString());
 ui.Paragraph paragraph = paragraphBuilder.build()
        ..layout(ui.ParagraphConstraints(width: 20.0));
Copy the code

The Builder allows you to pass in only one ParagraphStyle parameter, which has parameters in its constructor that are common for building Text.

TextAlign textAlign, // Text position
TextDirection textDirection,// Text direction
FontWeight fontWeight,// Text weight
FontStyle fontStyle,// Text style
int maxLines,// Maximum number of rows
String fontFamily,/ / font
double fontSize,// Text size
double lineHeight,// The maximum height of the text
String ellipsis,// thumbnail display
Locale locale,/ / localization
Copy the code

The above example uses only a few of the parameters used. After construction, the chain call calls void pushStyle(TextStyle Style) to set some temporary styles that can be undone by calling void pop(). AddText to complete the construction of a paragraph by using void addText(String text) and finally calling the build method.

Draw pictures

Drawing pictures is also a bit tricky. Void drawImageRect(Image Image, Rect SRC, Rect DST, Paint Paint) void drawImageRect(Image Image, Rect SRC, Rect DST, Paint)

This Image is also a class in Dart.ui, again created by the engine, as opposed to the Image in the widget. The official recommended drawing process is as follows:

  1. You can get ImageStream in a variety of ways[AssetImage]or[NetworkImage], and finally basically passImageStream resolve(ImageConfiguration configuration)To invoke.
  2. Add listener for ImageStream creationvoid addListener(ImageListener listener, { ImageErrorListener onError })After each callback, a new CustomPainter needs to be created to draw the new image.
  3. Call a series of methods like drawImage in the canvas

Let’s rewrite this in the StatefulWidget:

@override
  void didChangeDependencies() {
    super.didChangeDependencies();
    AssetImage('images/day.png').resolve(createLocalImageConfiguration(context)) .. addListener((ImageInfo image,bool synchronousCall) {
        iconDay = image.image;
        iconDayRect = Rect.fromLTWH(
            0.0.0.0, iconDay.width.toDouble(), iconDay.height.toDouble());
        setState(() {});
      });
    ImageStream night = AssetImage('images/night.png')
        .resolve(createLocalImageConfiguration(context));
    night.addListener((ImageInfo image, bool synchronousCall) {
      iconNight = image.image;
      iconNightRect = Rect.fromLTWH(
          0.0.0.0, iconNight.width.toDouble(), iconNight.height.toDouble());
      setState(() {});
    });
  }
Copy the code

Pass the obtained image to the global variables iconNight and iconNightDay, and then use these variables in the build method mentioned earlier:

 @override
Widget build(BuildContext context) {
  return GestureDetector(
    onTapUp: (TapUpDetails detail) {
      print('onTapUp');
      onTap(context, detail);
    },
    child: CustomSingleChildLayout(
      delegate: _SakaLayoutDelegate(widget.size, widget.padding),
      child: CustomPaint(
        painter: _HourlyForecastPaint(context, widget.hourlyList,
            widget.padding.deflateSize(widget.size), areaListCallback,
            imagePath: widget.imagePath,
            iconDay: iconDay,
            iconDayRect: iconDayRect,
            iconNight: iconNight,
            iconNightRect: iconNightRect),
      ),
    ),
  );
}
Copy the code

Finally finished:

Handling click events

The main point of handling click events is to pay attention to the transformation of global coordinates and coordinates within the control.

Final void Function(List

xList) areaListCallback; This function is used directly in the constructor:

if (this. areaListCallback == null) {
      return;
}
areaListCallback(points.map((f) => f.dx + increaseX).toList());
Copy the code

The points parameter is the starting position of each region divided according to the number of weather. Here we use the map function to convert these points into the maximum X-axis position of the region. This function is passed back to the State class in the StatefulWidget.

  void areaListCallback(List<double> xList) {
    print(xList);
    this.xList = xList;
  }
Copy the code

OnTap function when clicked:

  void onTap(BuildContext context, TapUpDetails detail) {
   if (widget.onTapUp == null) return;
   RenderBox renderBox = context.findRenderObject();
   Offset localPosition = renderBox.globalToLocal(detail.globalPosition);
   widget.onTapUp(getIndex(localPosition));
 }
 int getIndex(Offset globalOffset) {
   int i = - 1;
   double relativePositionX =
       globalOffset.dx - widget.padding.collapsedSize.width / 2;
   for (double a in xList) {
     i++;
     if (relativePositionX >= 0 && relativePositionX <= a) {
       break; }}return i;
 }
Copy the code

Void onTap(BuildContext context, TapUpDetails detail) TapUpDetails a quantity obtained from a global location that needs to be converted to local coordinates. Context.findrenderobject () is used to find the RenderBox of the current control. Renderbox.globaltolocal (detail.globalPosition) is used to convert the global coordinate system to the current coordinate system. So when a region is clicked it calls getIndex to find the index and pass the value to the onTap method.