Flutter進階: 帶你實現一個 海拔圖 控件(上篇) | 掘金技術徵文

轉載請標明出處: juejin.im/post/5b533f…
本文出自:Wos的主頁node

要實現的目標: 一個海拔圖

它具體包括如下功能:git

  • 繪製一個由成千上萬個點組成的折線圖, 並保持流暢
  • 經過手勢操做對圖表進行縮放/滾動等功能
  • 用於顯示地名的標籤, 且標籤須要跟隨縮放級別顯示/隱藏
  • 一個讓圖表精緻生動的動畫
  • 一個底部控制Bar
    • 使用滑鈕實現單指縮放
    • 海拔圖的概覽, 展現出大圖展現的內容對應於全局的位置
    • 拖動兩個滑鈕中間的空白區域能夠滾動大圖

先看東西

看不清楚? 不過癮? 下載 APK 親自體驗 Flutter 的流暢與強大github

說在前面

雖然本文定位爲進階內容, 但實際若是你們對Canvas稍有了解, 仍是比較容易理解的. 我也但願本身可以詳盡/直白將我思路講述清楚.算法

本項目是基於Android環境實現的, 可是... 代碼徹底使用Flutter(Dart)實現, 所以也能夠完美運行在iOS設備上.json

如下爲個人開發環境:

  • 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上的代碼略有出入canvas

本文內容來源於我在Flutter學習過程當中的理解和實踐, 不能做爲最佳實踐. 若有不妥之處但願你們指出, 謝謝.數組

如何閱讀本文:

這篇文章的篇幅較長, 主要是我將帶領你們一步步的實現這樣的一個海拔圖控件. 雖然不是詳盡到每一步的代碼都貼出來, 但也是擁有大量內容.bash

技術較強大佬或不想看這麼多的內容, 能夠直接去看個人源碼, 若有疑問能夠回到本文搜索對應的解釋, 或在下方評論留言.服務器

除此以外, 建議你們創建一個新的項目, 跟着我一步一步動手把它實現出來.markdown

正篇

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 技術實踐 | 掘金技術徵文,徵文活動正在進行中

相關文章
相關標籤/搜索