Flutter进阶: 带你实现一个 海拔图 控件(上篇) | 掘金技术征文

4,214 阅读9分钟

转载请标明出处: juejin.cn/post/684490…
本文出自:Wos的主页

要实现的目标: 一个海拔图

它具体包括以下功能:

  • 绘制一个由成千上万个点组成的折线图, 并保持流畅
  • 通过手势操作对图表进行缩放/滚动等功能
  • 用于显示地名的标签, 且标签需要跟随缩放级别显示/隐藏
  • 一个让图表精致生动的动画
  • 一个底部控制Bar
    • 使用滑钮实现单指缩放
    • 海拔图的概览, 展示出大图展示的内容对应于全局的位置
    • 拖动两个滑钮中间的空白区域可以滚动大图

先看东西

看不清楚? 不过瘾? 下载 APK 亲自体验 Flutter 的流畅与强大

说在前面

虽然本文定位为进阶内容, 但实际如果大家对Canvas稍有了解, 还是比较容易理解的. 我也希望自己能够详尽/直白将我思路讲述清楚.

本项目是基于Android环境实现的, 但是... 代码完全使用Flutter(Dart)实现, 因此也可以完美运行在iOS设备上.

以下为我的开发环境:

  • IDE: Android Studio v3.1.3 + Flutter plugin + Dart plugin
  • SDK: Flutter 0.5.1 + Dart 2.0.0-dev
  • 测试机:
    • 坚果Pro2 - OS: Android 7.1.1
    • Smartisan M1 - OS: Android 6.0.1

注意: 为了方便阅读, 本文中的代码和我在Github上的代码略有出入

本文内容来源于我在Flutter学习过程中的理解和实践, 不能作为最佳实践. 如有不妥之处希望大家指出, 谢谢.

如何阅读本文:

这篇文章的篇幅较长, 主要是我将带领大家一步步的实现这样的一个海拔图控件. 虽然不是详尽到每一步的代码都贴出来, 但也是拥有大量内容.

技术较强大佬或不想看这么多的内容, 可以直接去看我的源码, 如有疑问可以回到本文搜索对应的解释, 或在下方评论留言.

除此之外, 建议大家建立一个新的项目, 跟着我一步一步动手把它实现出来.

正篇

1. 海拔图控件的基本布局

1.1. 在lib包下建立一个新的dart文件:altitude_graph 我们的主要工作都将在这个文件中完成.

在这个文件中, 我们先建立一个初始的StatefulWidget: AltitudeGraphView.

然后我们在State的build方法中返回一个基本的架构. 如下:

return Column(
  mainAxisSize: MainAxisSize.max,
  children: <Widget>[
    // 主视图
    Expanded(
      child: SizedBox.expand(
        child: GestureDetector(
          child: CustomPaint(
            painter: AltitudePainter(),
          ),
        ),
      ),
    ),

    // 底部控制Bar
    Container(
      width: double.infinity,
      height: 48.0,
      color: Colors.lightGreen,
    ),
  ],
);

mainAxisSize: MainAxisSize.max 是为了让Column占满父控件

SizedBox.expand 是为了让其子控件GestureDetector占满Column的剩余空间

1.2. AltitudePainter 是我们绘制图表的地方, 我们先创建一个最初的模板

在文件下面空白处, 新建一个class AltitudePainter extends CustomPainter

实现方法并修改为如下:

class AltitudePainter extends CustomPainter{
  Paint linePaint = Paint()..color = Colors.red;

  @override
  void paint(Canvas canvas, Size size) {
    canvas.drawRect(Rect.fromLTWH(0.0, 0.0, size.width, size.height), linePaint);
  }

  @override
  bool shouldRepaint(CustomPainter oldDelegate) {
    return true;
  }
}

bool shouldRepaint(CustomPainter oldDelegate) 告知系统是否需要重绘. 我们暂时先给它返回一个ture 表示一直重绘.

void paint(Canvas canvas, Size size) 当绘制时回调此方法.

上面的代码中, 我们已经创建了一个简单的Paint对象并设置了一个颜色, 然后使用canvas绘制了一个和所给的Size一样大小的矩形.

1.3. 创建控件所需的数据模型, 用于存储用于绘制的数据.

我们在altitude_graph文件的空白处添加一个数据模型类, 它具体如下:

const Color kLabelTextColor = Colors.white;

class AltitudePoint {
  /// 当前点的名字, 例如: xx镇
  String name;

  /// 当前点的级别, 用于根据缩放级别展示不同的地标标签.
  int level;

  /// `point.x`表示当前点距离上一个点的距离. `point.y`表示当前点的海拔
  Offset point;
  
  /// 地标标签的背景色
  Color color;

  /// 用于绘制文字, 存在这里是为了避免每次绘制重复创建.
  TextPainter textPainter;

  AltitudePoint(this.name, this.level, this.point, this.color, {this.textPainter}) {
    if (name == null || name.isEmpty || textPainter != null) return;

    // 向String插入换行符使文字竖向绘制
    var splitMapJoin = name.splitMapJoin('', onNonMatch: (m) {
      return m.isNotEmpty ? "$m\n" : "";
    });
    splitMapJoin = splitMapJoin.substring(0, splitMapJoin.length - 1);

    this.textPainter = TextPainter(
      textDirection: TextDirection.ltr,
      text: TextSpan(
        text: splitMapJoin,
        style: TextStyle(
          color: kLabelTextColor,
          fontSize: 8.0,
        ),
      ),
    )..layout();
  }
}

后面我们将要绘制的海拔图, 就是由成百上千个这样点数据组成的

level 这个属性后面会具体讲解

TextPainter 的开销是非常大的, 应当避免在绘制时创建, 尤其应该避免重复创建. 因此我们在数据创建时就把它们创建出来.

1.4. 让我们看看现在的效果

来到创建项目时自动生成的main.dart文件中, 将无用的代码及注释删除掉.

然后将Scaffoldbody换成我们的AltitudeGraphView() , 根据提示进行导包

现在, 让我们看看运行效果

可以看到, 上下已经被分为了两个区域.

2. 为海拔控件提供演示数据

2.1. 添加海拔数据资源文件

我们想把真实的海拔数据画到图上, 首先需要一个海拔资源文件

海拔数据可以点击这里下载(如没有弹出下载,右键点击网页选择"存储为").

在项目的根目录下创建资源文件夹assets/raw将 json 文件放到里面

接下来打开pubspec.yaml文件. 在flutter: 下注册资源文件. 如下:

flutter:
  assets:
    - assets/raw/CHUANZANGNAN.json

yaml语法是强格式化的, 一定要注意空格

2.2. 将原始海拔数据转成我们所需的数据

这个 json 文件中存的是一个完整的路线信息, 包括海拔等其它很多信息.

我们只需要一部分绘制所需的信息, 因此我们来创建一个数据提供者. 负责加载资源文件并将其转换为AltitudePoint数据集合.

lib包下再新建一个dart文件:altitude_point_data, 然后添加代码如下:

import 'dart:async';
import 'dart:convert';
import 'package:flutter/services.dart' show rootBundle;

import 'package:flutter/material.dart';
import 'package:flutter_altitude_graph/altitude_graph.dart';

const Color START_AND_END = Colors.red;
const Color CITY = Colors.deepOrange;
const Color COUNTY = Colors.blueGrey;
const Color TOWN = Colors.blue;
const Color VILLAGE = Colors.green;
const Color MOUNTAIN = Colors.brown;
const Color TUNNEL = Colors.red;
const Color CAMP_SPOT = Colors.blue;
const Color SCENIC_SPOT = Colors.blueGrey;
const Color CHECK_POINT = Colors.orange;
const Color BRIDGE = Colors.green;
const Color GAS_STATION = Colors.lightGreen;
const Color OTHERS = Colors.deepPurpleAccent;

Future<List<AltitudePoint>> parseGeographyData(String assetPath) {
  return rootBundle
      .loadString(assetPath, cache: false)
      .then((fileContents) => json.decode(fileContents))
      .then((jsonData) {
    List<AltitudePoint> list = List();

    var arrays = jsonData["RECORDS"];

    double mileage = 0.0;

    for (var geo in arrays) {
      var name = geo["NAME"];
      if (name.contains('_')) name = null; // 低级别地名不显示

      int level;
      Color color;
      var altitude = double.parse(geo["ELEVATION"]);

      /// 根据不同的type定义各个点的级别和label的颜色, 这将影响到在不同的缩放级别下, 显示哪些label
      /// level值越大, 优先级越高
      switch (geo["TYPES"]) {
        case 'CITY':
          level = 4;
          color = CITY;
          break;
        case 'MOUNTAIN':
          level = 3;
          color = MOUNTAIN;
          break;
        case 'COUNTY':
          level = 3;
          color = COUNTY;
          break;
        case 'TOWN':
          level = 2;
          color = TOWN;
          break;
        case 'VILLAGE':
          level = 2;
          color = VILLAGE;
          break;
        case 'TUNNEL':
          level = 2;
          color = TUNNEL;
          break;
        case 'BRIDGE':
          level = 2;
          color = BRIDGE;
          break;
        case 'CHECK_POINT':
          level = 1;
          color = CHECK_POINT;
          break;
        case 'CAMP_SPOT':
          level = 1;
          color = CAMP_SPOT;
          break;
        case 'SCENIC_SPOT':
          level = 1;
          color = SCENIC_SPOT;
          break;
        default:
          level = 0;
          color = OTHERS;
          break;
      }

      var altitudePoint = new AltitudePoint(
        name,
        level,
        Offset(mileage, altitude),
        color,
      );

      list.add(altitudePoint);

      /// 累加里程
      /// 原始Json中的distance表示的是当前点距离下一个点的距离, 但是我们这里需要计算的是[当前点距离起点的距离]
      /// 例如: 第一个点就是起点因此距离起点是0公里, 第一个点距离第二个点2公里, 因此第二个点距离起点2公里
      /// 第二个点距离第三个点3公里, 因此第三个点距离起点是5公里, 以此类推...
      double distance = double.parse(geo["F_DISTANCE"]);
      mileage = mileage + distance;
    }

    list.first.level = 5;
    list.first.color = START_AND_END;
    list.last.level = 5;
    list.last.color = START_AND_END;

    return list;
  });
}

这段代码的parseGeographyData方法中, 我们通过 rootBundle 提供的方法将 assetPath 以字符流形式读取为一个字符串, 并生成了一个Json对象.

接下来我们从Json对象中取到海拔路径的Json数组, 并在循环中依次解析出我们所需的数据, 最终生成一个个 AltitudePoint 对象添加到集合中.

在这段代码中, 占篇幅比较大的地方在于 根据海拔路径的点的 TYPES 给这个点设置 level, 并且不同的level对应不同的标签背景色.

第二步只是为了给海拔图控件提供数据, 并不是海拔图控件必要组成部分. 海拔图只关心数据本身而不关心数据从何而来, 也因此, 这里关于level和标签背景color的设置其实是比较随意的.

3. 绘制前的一些准备

3.1. 回到altutide_graph.dart文件, 添加所需的颜色常量

const Color kAxisTextColor = Colors.black;
const Color kVerticalAxisDottedLineColor = Colors.amber;
const Color kAltitudeThumbnailPathColor = Colors.grey;
const Color kAltitudeThumbnailGradualColor = Color(0xFFE0EFFB);
const Color kAltitudePathColor = Color(0xFF003c60);
const List<Color> kAltitudeGradientColors = [Color(0x821E88E5), Color(0x0C1E88E5)];

3.2. 为AltitudeGraphView添加属性及构造

final List<AltitudePoint> altitudePointList;

AltitudeGraphView(this.altitudePointList);

3.3. 删除之前AltitudePainter中的测试内容, 添加以下属性及构造

// ===== Data
/// 海拔数据集合
List<AltitudePoint> _altitudePointList;

/// 最高海拔
double _maxAltitude = 0.0;

/// 最低海拔
double _minAltitude = 0.0;

/// 纵轴最大值
double _maxVerticalAxisValue;

/// 纵轴最小值
double _minVerticalAxisValue;

/// 纵轴点与点之间的间隔
double _verticalAxisInterval;

// ===== Paint
/// 海拔线的画笔
Paint _linePaint;

/// 海拔线填充的画笔
Paint _gradualPaint;

/// 关键点的画笔
Paint _signPointPaint;

/// 纵轴水平虚线的画笔
Paint _levelLinePaint;

/// 文字颜色
Color axisTextColor;

/// 海拔线填充的梯度颜色
List<Color> gradientColors;

AltitudePainter(
this._altitudePointList,
this._maxAltitude,
this._minAltitude,
this._maxVerticalAxisValue,
this._minVerticalAxisValue,
this._verticalAxisInterval, {
this.axisTextColor = kAxisTextColor,
this.gradientColors = kAltitudeGradientColors,
Color pathColor = kAltitudePathColor,
Color axisLineColor = kVerticalAxisDottedLineColor,
})  : _linePaint = Paint()
      ..strokeWidth = 1.0
      ..style = PaintingStyle.stroke
      ..color = pathColor,
    _gradualPaint = Paint()
      ..isAntiAlias = false
      ..style = PaintingStyle.fill,
    _signPointPaint = Paint(),
    _levelLinePaint = Paint()
      ..strokeWidth = 1.0
      ..isAntiAlias = false
      ..color = axisLineColor
      ..style = PaintingStyle.stroke;

在上面的代码中, 我们创建了接下来绘制所需要的部分属性. 主要是海拔数据, 绘制纵轴所需要的数据 以及绘制所需的所有Paint

3.4. 计算绘制纵轴所需的数据

在上面的步骤中, AltitudePainter构造需要一些必要参数. _AltitudeGraphViewStatebuild中也会报红线提示我们.

_AltitudeGraphViewState添加以下属性

// ==== 海拔数据
double _maxAltitude = 0.0;
double _minAltitude = 0.0;
double _maxVerticalAxisValue = 0.0;
double _minVerticalAxisValue = 0.0;
double _verticalAxisInterval = 0.0;

添加以下方法, 计算海拔图数据

/// 遍历数据, 取得 最高海拔值, 最低海拔值, 最高Level, 最低Level.
/// 根据最高海拔值和最低海拔值计算出纵轴最大值和最小值.
_initData() {
  if (widget.altitudePointList?.isEmpty ?? true) return;

  var firstPoint = widget.altitudePointList.first.point;
  _maxAltitude = firstPoint.dy;
  _minAltitude = firstPoint.dy;
  for (AltitudePoint p in widget.altitudePointList) {
    if (p.point.dy > _maxAltitude) {
      _maxAltitude = p.point.dy;
    } else if (p.point.dy < _minAltitude) {
      _minAltitude = p.point.dy;
    }
  }

  var maxDivide = _maxAltitude - _minAltitude;
  if (maxDivide > 1000) {
    _maxVerticalAxisValue = (_maxAltitude / 1000.0).ceil() * 1000.0;
    _minVerticalAxisValue = (_minAltitude / 1000.0).floor() * 1000.0;
  } else if (maxDivide > 100) {
    _maxVerticalAxisValue = (_maxAltitude / 100.0).ceil() * 100.0;
    _minVerticalAxisValue = (_minAltitude / 100.0).floor() * 100.0;
  } else if (maxDivide > 10) {
    _maxVerticalAxisValue = (_maxAltitude / 10.0).ceil() * 10.0;
    _minVerticalAxisValue = (_minAltitude / 10.0).floor() * 10.0;
  }

  _verticalAxisInterval = (_maxVerticalAxisValue - _minVerticalAxisValue) / 5;
  var absVerticalAxisInterval = _verticalAxisInterval.abs();
  if (absVerticalAxisInterval > 1000) {
    _verticalAxisInterval = (_verticalAxisInterval / 1000.0).floor() * 1000.0;
  } else if (absVerticalAxisInterval > 100) {
    _verticalAxisInterval = (_verticalAxisInterval / 100.0).floor() * 100.0;
  } else if (absVerticalAxisInterval > 10) {
    _verticalAxisInterval = (_verticalAxisInterval / 10.0).floor() * 10.0;
  }
}

在这个方法中, 我们首先遍历了widget中的altitudePointList 取得这个海拔路径中的最高海拔最低海拔.

接下来我们根据最高海拔最低海拔计算出了纵轴所需要显示的纵轴最大值纵轴最小值.

纵轴显示的节点应该满足以下三个条件:

  1. 我们希望海拔图展示在一个比较居中得位置
  2. 最好上下留出一些冗余空间, 折线占满整个控件不太好看
  3. 每个节点的值应该是一个"规整的数". 例如0,1000,2000...

为了满足上述三个条件, 我们不能单纯的以最高海拔最低海拔作为纵轴最大值纵轴最小值.

上面代码中, 我用了一种看着比较笨的方法, 对值进行了处理. 如果有更好的算法, 请不吝赐教

得出纵轴最大值纵轴最小值后, 我们再根据这两个值计算出计算出纵轴上每个节点间的间距. 也是需要给处理成一个"规整的数"

最后在initState()didUpdateWidget(AltitudeGraphView oldWidget)生命周期方法内调用该_initData().

3.5. 将数据传给控件

回到main.dart文件

由于刚刚我们在AltitudePoint中创建了一个构造方法并要求调用者传递一个必要参数, 因此现在main.dart内应该有了一个报红

我们在_MyHomePageState中添加一个成员变量List<AltitudePoint> _altitudePointList; 然后将其赋值给AltitudeGraphView的构造

接下来我们创建一个方法, 从资源文件中获取海拔数据:

_loadData() {
  parseGeographyData('assets/raw/CHUANZANGNAN.json').then((list) {
    setState(() {
      _altitudePointList = list;
    });
  });
}

然后我们在_MyHomePageStateinitState这个生命周期方法内调用_loadData()

4. 终于开始绘制啦

4.1. 绘制纵轴背景

首先添加如下代码到AltitudePainterpaint方法

@override
void paint(Canvas canvas, Size size) {
  // 30 是给上下留出的距离, 这样竖轴的最顶端的字就不会被截断, 下方可以用来显示横轴的字
  Size availableSize = Size(size.width, size.height - 30);

  // 向下滚动15的距离给顶部留出空间
  canvas.translate(0.0, 15.0);

  // 绘制竖轴
  _drawVerticalAxis(canvas, availableSize);
}

这段代码中, 参数sizeAltitudePainter的可绘制大小. 我们不直接就用这个尺寸来绘制, 而是创建一个availableSize作为主绘制区域, 并通过canvas.translate()将布局向下滚动使绘制区域居中.

原因是接下来的绘制中, 我们不希望我们要绘制的内容紧贴着控件的边缘, 那样会导致最上面及最下面的虚线和字紧贴着控件的边缘, 甚至文字被截断.

接下来实现_drawVerticalAxis(canvas, availableSize)方法

void _drawVerticalAxis(Canvas canvas, Size size) {
  var nodeCount = (_maxVerticalAxisValue - _minVerticalAxisValue) / _verticalAxisInterval;

  var interval = size.height / nodeCount;

  canvas.save();
  for (int i = 0; i <= nodeCount; i++) {
    var label = (_maxVerticalAxisValue - (_verticalAxisInterval * i)).toInt();
    drawVerticalAxisLine(canvas, size, label.toString(), i * interval);
  }
  canvas.restore();
}

这段代码中, 首先根据最大值 - 最小值得出有效值再 / 间隔 得到 节点的数量. 例如: _maxVerticalAxisValue=3500,_minVerticalAxisValue=3000,_verticalAxisInterval为100,则nodeCount=5

然后用绘制区域的高度 / 除以节点数量得出在屏幕上每个节点之间的间隔

接下来一个for循环依次绘制个纵轴节点

需要注意i <= nodeCount. 之所以用<=是为了 无论绘制几个节点, 都会绘制最下面的一个节点.

实现_drawVerticalAxisLine(Canvas canvas, Size size, String text, double height)绘制单个纵轴节点

/// 绘制数轴的一行
void _drawVerticalAxisLine(Canvas canvas, Size size, String text, double height) {
  var tp = _newVerticalAxisTextPainter(text)..layout();

  // 绘制虚线
  // 虚线的宽度 = 可用宽度 - 文字宽度 - 文字宽度的左右边距
  var dottedLineWidth = size.width - 25.0;
  canvas.drawPath(_newDottedLine(dottedLineWidth, height, 2.0, 2.0), _levelLinePaint);

  // 绘制虚线右边的Text
  // Text的绘制起始点 = 可用宽度 - 文字宽度 - 左边距
  var textLeft = size.width - tp.width - 3;
  tp.paint(canvas, Offset(textLeft, height - tp.height / 2));
}

/// 生成虚线的Path
Path _newDottedLine(double width, double y, double cutWidth, double interval) {
  var path = Path();
  var d = width / (cutWidth + interval);
  path.moveTo(0.0, y);
  for (int i = 0; i < d; i++) {
    path.relativeLineTo(cutWidth, 0.0);
    path.relativeMoveTo(interval, 0.0);
  }
  return path;
}

TextPainter textPainter = TextPainter(
  textDirection: TextDirection.ltr,
  maxLines: 1,
);

/// 生成纵轴文字的TextPainter
TextPainter _newVerticalAxisTextPainter(String text) {
  return textPainter
    ..text = TextSpan(
      text: text,
      style: TextStyle(
        color: axisTextColor,
        fontSize: 8.0,
      ),
    );
}

由于我没有找到在Flutter下画虚线的方法, 所以用N个小段拼起来形成一条虚线.

前面说过TextPainter的开销比较大, 所以这里只创建一个作为成员变量

但实际上我并不知道TextPainter的开销来源于哪里(猜测是layout()方法), 经过我没那么严谨的测试, 把一个TextPainter对象作为成员变量, 和每次调用_newVerticalAxisTextPainter(String text)都重新创建一个其实并没有什么区别. 如果有大佬知道请不吝赐教.

ok, 到这里, 纵轴就绘制好了. 现在可以运行起来看一看效果啦, 下一步, 我们将为海拔图绘制折线.

4.2. 绘制海拔图的折线部分

AltitudePainter方法中paint加入以下代码

// 50 是给左右留出间距, 避免标签上的文字被截断, 同时避免线图覆盖竖轴的字
Size pathSize = Size(availableSize.width - 50, availableSize.height);

// 绘制线图
canvas.save();
// 剪裁绘制的窗口, 节省绘制的开销. -24 是为了避免覆盖纵轴
canvas.clipRect(Rect.fromPoints(Offset.zero, Offset(size.width - 24, size.height)));
// _offset.dx通常都是向左偏移的量 +15 是为了避免关键点 Label 的文字被截断
canvas.translate(15.0, 0.0);
_drawLines(canvas, pathSize);
canvas.restore();

接下来具体的来实现_drawLines(Canvas canvas, Size size)

/// 绘制海拔图连线部分
/// 绘制海拔图连线部分
void _drawLines(Canvas canvas, Size size) {
  var pointList = _altitudePointList;
  if (pointList == null || pointList.isEmpty) return;

  double ratioX = size.width / pointList.last.point.dx;
  double ratioY = (_maxVerticalAxisValue - _minVerticalAxisValue);
  
  var path = Path();

  var calculateDy = (double dy) {
    return size.height - (dy - _minVerticalAxisValue) * ratioY;
  };

  var firstPoint = pointList.first.point;
  path.moveTo(firstPoint.dx * ratioX, calculateDy(firstPoint.dy));
  for (var p in pointList) {
    path.lineTo(p.point.dx * ratioX, calculateDy(p.point.dy));
  }

  // 绘制线条下面的渐变部分
  double gradientTop = size.height - ratioY * (_maxAltitude - _minVerticalAxisValue);
  _gradualPaint.shader = ui.Gradient.linear(Offset(0.0, gradientTop), Offset(0.0, size.height), gradientColors);
  _drawGradualShadow(path, size, canvas);

  // 先绘制渐变再绘制线,避免线被遮挡住
  canvas.save();
  canvas.drawPath(path, _linePaint);
  canvas.restore();
}

上面代码需要导包 import 'dart:ui' as ui;

首先计算出海拔图映射到屏幕上的比例, 例如终点是2000公里, 映射到400(理论像素)宽的屏幕上ratioX就是0.2. 同理最大海拔差为1000映射到500高的屏幕上时ratioY就是0.2

接下来我们声明了一个Path对象, 用于存储接下来要绘制的折线的路径信息

然后是一个用来计算y轴绘制点的内部方法calculateDy. 以下是该方法的分步讲解:

  1. (dy - _minVerticalAxisValue) 这段代码中dy是海拔的高度, 海拔以0为起始点, 而我们在绘制时是以_minVerticalAxisValue作为起始点的, 因此需要相减得到相对海拔高度.
  2. 相对海拔高度* ratioY 得到海拔映射到屏幕的高度
  3. size.height -海拔映射到屏幕的高度 是因为绘制的坐标y轴向下为正数, 海拔越高越处于屏幕向下的位置, 因此需要用size.height相减使海拔越高越处于屏幕向上的位置.

接下来调用path.moveTo将画笔的起始位置挪到第一个坐标点. 然后通过for循环将所有的海拔路径点都映射为屏幕上的坐标点.

得到路径数据后, 先不着急绘制折线, 而是先绘制我们效果图中看到的折线下面的渐变投影.

为此, 我们需要先实现_drawGradualShadow(path, size, canvas)方法

void _drawGradualShadow(Path path, Size size, Canvas canvas) {
  var gradualPath = Path.from(path);
  gradualPath.lineTo(gradualPath.getBounds().width, size.height);
  gradualPath.relativeLineTo(-gradualPath.getBounds().width, 0.0);

  canvas.drawPath(gradualPath, _gradualPaint);
}

回到上面, 我们首先需要给渐变设定一个范围, 范围影响到渐变的效果. 由于我们的渐变是由上至下的, 因此渐变的范围只需要考虑y轴, 不需要考虑x轴. 最终我们的y轴范围=从最高海拔映射到屏幕上的y轴坐标点到绘制区域的最底端

然后在_drawGradualShadow方法中, 我们通过刚才生成的Path生成一个新的Path. 接下来的gradualPath.lineTogradualPath.relativeLineTo是为了使gradualPath闭合起来(这里省了一步,但会自动闭合起来).

最后, 绘制完渐变投影后,

完成这一步, 让我们运行起来看看效果吧.

4.3. 绘制海拔图的横轴部分

AltitudePainterpaint中添加以下代码:

// 高度 +2 是为了将横轴文字置于底部并加一个 marginTop.
double hAxisTransY = availableSize.height + 2;

canvas.save();
// 剪裁绘制窗口, 减少绘制时的开销.
canvas.clipRect(Rect.fromPoints(Offset(0.0, hAxisTransY), Offset(size.width, size.height)));
// x偏移和线图对应上, y偏移将绘制点挪到底部
canvas.translate(15.0, hAxisTransY);
_drawHorizontalAxis(canvas, availableSize.width, pathSize.width);
canvas.restore();

首先计算了一下横轴的绘制区域相对于视图顶部的间距.

接着我们剪裁了绘制区域, 然后向右下偏移, 使绘制的起始点和折线对齐且和上方保持一点点间距

然后调用_drawHorizontalAxis方法进行具体的绘制.

这里我们将控件的宽度(_drawHorizontalAxis)以及折线部分的绘制区域的宽度(pathSize.width)传递给该方法.

接下来我们来实现_drawHorizontalAxis(Canvas canvas, double viewportWidth, double totalWidth)

void _drawHorizontalAxis(Canvas canvas, double viewportWidth, double totalWidth) {
  Offset lastPoint = _altitudePointList?.last?.point;
  if (lastPoint == null) return;

  double ratio = viewportWidth / totalWidth;
  double intervalAtDistance = lastPoint.dx * ratio / 6.0;
  int intervalAtHAxis;
  if (intervalAtDistance >= 100.0) {
    intervalAtHAxis = (intervalAtDistance / 100.0).ceil() * 100;
  } else if (intervalAtDistance >= 10) {
    intervalAtHAxis = (intervalAtDistance / 10.0).ceil() * 10;
  } else {
    intervalAtHAxis = (intervalAtDistance / 5.0).ceil() * 5;
  }
  double hAxisIntervalScale = intervalAtHAxis.toDouble() / intervalAtDistance;
  double intervalAtScreen = viewportWidth / 6.0 * hAxisIntervalScale;

  double count = totalWidth / intervalAtScreen;
  for (int i = 0; i <= count; i++) {
    _drawHorizontalAxisLine(
      canvas,
      "${i * intervalAtHAxis}",
      i * intervalAtScreen,
    );
  }
}

viewportWidth参数是为了计算横轴的每个节点在屏幕上的跨距是多少. totalWidth是折线部分的宽度也是横轴的总宽度, 用于计算横轴上节点的数量

第一步我们计算出总宽度映射到控件宽度上的比例

然后我们用这个比例和终点相乘得到缩放后的大小, 后面的 6.0 是横轴在屏幕上最多同时显示6个节点, 想设置为几都行

现在得到的intervalAtDistance是一个不规整的数, 我们也像处理纵轴的节点一样, 将其变成规整intervalAtHAxis. 这一步使得 假设intervalAtDistance为100+ ~ 200 则都显示为 200.

hAxisIntervalScale 表示一个缩放比. 例如, 虽然 101 和200 都显示为200, 但是它们在屏幕上的跨距是不一样的. 用这个缩放比和节点在屏幕上的跨距(viewportWidth / 6.0)相乘得到最终的节点在屏幕上的跨距

接下来, 通过totalWidth / intervalAtScreen得到横轴上的总节点数量. 然后进行for循环, 依次将横轴上的每一个节点绘制出来

接下来我们来实现_drawHorizontalAxisLine(Canvas canvas, String text, double width)

/// 绘制数轴的一行
void _drawHorizontalAxisLine(Canvas canvas, String text, double width) {
  var tp = _newVerticalAxisTextPainter(text)..layout();
  var textLeft = width + tp.width / -2;
  tp.paint(canvas, Offset(textLeft, 0.0));
}

这一步十分简单, 向绘制纵轴文字时一样, 获取到TextPainter并将其绘制到我们计算出的坐标上.

让我们运行起来, 看看效果. 注意控件的底边部分

4.4. 绘制关键点

我们来新建一个方法_drawLabel用于绘制关键点:

void _drawLabel(Canvas canvas, double height, List<AltitudePoint> pointList, double ratioX, double ratioY) {
  // 绘制关键点及文字
  canvas.save();
  canvas.translate(0.0, height);
  for (var p in pointList) {
    if (p.name == null || p.name.isEmpty) continue;

    // 将海拔的值换算成在屏幕上的值
    double yInScreen = (p.point.dy - _minVerticalAxisValue) * ratioY;

    // ==== 绘制关键点
    _signPointPaint.color = p.color;
    canvas.drawCircle(Offset(p.point.dx * ratioX, -yInScreen), 2.0, _signPointPaint);

    // ==== 绘制文字及背景
    var tp = p.textPainter;
    var left = p.point.dx * ratioX - tp.width / 2;

    // 如果label接近顶端, 调换方向, 避免label看不见
    double bgTop = yInScreen + tp.height + 8;
    double bgBottom = yInScreen + 4;
    double textTop = yInScreen + tp.height + 6;
    if (height - bgTop < 0) {
      bgTop = yInScreen - tp.height - 8;
      bgBottom = yInScreen - 4;
      textTop = yInScreen - 6;
    }
    // 绘制文字的背景框
    canvas.drawRRect(
        RRect.fromLTRBXY(
          left - 2,
          -bgTop,
          left + tp.width + 2,
          -bgBottom,
          tp.width / 2.0,
          tp.width / 2.0,
        ),
        _signPointPaint);

    // 绘制文字
    tp.paint(canvas, Offset(left, -textTop));
  }

  canvas.restore();
}

我们在参数上就要求double ratioXdouble ratioY是因为之前我们已经在_drawLines方法中计算过了海拔图映射到屏幕上的比例. 因此我们只需要_drawLines方法体的末尾调用该方法就好了

void _drawLines(Canvas canvas, Size size) {
  ...
  _drawLabel(canvas, size.height, pointList, ratioX, ratioY);
}

首先, 我们将canvas滚动到最底部. 这样省的接下来的y轴坐标计算都需要height - xxx

然后for循环, 过滤需要进行绘制的关键点对其进行绘制

for 内: 首先将没有name的点过滤掉. 然后和绘制折线时一样, 计算出海拔映射到屏幕上时的高度yInScreen

然后我们先绘制这个关键点上的"点", 我们用canvas.drawCircle画了一个圆点. 它的left是当前点的距离映射到屏幕上位置(通过p.point.dx * ratioX获得), 而top就是刚刚计算出的-yInScreen. 之所以是负值是因为此前我们将canvas滚动到了最底部.

接下来绘制关键点上的Label, 这一步比较麻烦一点, 需要计算出label的左上右下四个点的位置. 另外要考虑到如果label超过了控件顶边(默认我们是让label处于"点"的上方的), 需要将原本向上的label变为向下.

bgTop表示Label距离顶边的距离 通过在原本的点的位置基础上再偏移一个文字的高度+边距(8是距离"点"的margin(4)+上下的padding组成)

bgBottom表示Label距离底边的距离, 它默认位于"点"的上方4理论像素的位置

textTop文字需要在背景框之内, 所以+6 比背景框低一点这样最终的绘制效果就会显得文字和背景框之间有一点间距

if (height - bgTop < 0)表示如果背景框高于顶边, 将绘制方向变为向下

下面就是调用canvas.drawRRect画一个圆角矩形, 矩形的角度为文字宽度/2.0

最后绘制文字.

现在重新运行程序, 就能看到密密麻麻的Label了.

后面我们会根据文章前面部分提到的level以及缩放的级别展示不同的Label

以上就是 带你实现一个 海拔图 控件(上篇) 的主要内容了

本来我是想一篇文章给写完的, 但是写到这里, 我发现篇幅已经很长了, 而内容还有一大半... 所以我打算分成上下两篇(也许是三篇)进行讲解.

那么 尽请期待下篇喽

下集预告:

  1. 根据手势操作, 实现缩放/平移
  2. 实现惯性滑动
  3. 根据level以及缩放的级别展示不同的Label
  4. 实现底部海拔图概览
  5. 实现底部控制Bar对主视图进行缩放和平移
  6. 实现弹出/收起动画
  7. 使用Picture对绘制进行优化

说在后面

这篇文章是我发表的第一篇技术文章.

在此之前的很长一段时间, 我都单纯只是开源/技术社区的受益者.

一直以来, 我都认为自己能力有限且过去用到的技术相对比较完善, 不太需要我去写一些比较基础的, 尤其充斥着大量重复的内容.

Flutter 目前尚在初始阶段, 很多人都才刚刚了解/接触到 Flutter, 甚至更多的人都还在观望状态, 因此还有大量的技术/资源/教程的空白需要填充.

因此种种, 我将我的心得和结果分享出来反馈给社区.

这个项目我做了断断续续将近一个月, 一边学习一边摸索/试验, 最终效果我个人还是很满意的.

海拔图控件是目前比较少有的开源库类型, 也不常用. 但希望大家能从本次分享中对Flutter有更多的认识并有所收获.

最后, 欢迎并感谢大家给我的项目✨Star✨

本库暂时没有发布到 pub.dartlang

我试过很多次尝试将项目发布到 pub.dartlang 但是每一次都卡在账号验证成功之后...本地终端收不到远程服务器的回传. 即使我挂了ss全局代理+命令行终端代理也依然不行. 如果有大佬知道这是什么问题, 请不吝赐教, 万分感谢.

如果想要依赖本库, 可以直接将源码拷贝到你的项目中

本项目源码

我的Github主页


掘金 Flutter 技术实践

从 0 到 1:我的 Flutter 技术实践 | 掘金技术征文,征文活动正在进行中