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

Wos發表於2018-08-09

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

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

它具體包括以下功能:

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

先看東西

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

看不清楚? 不過癮? 下載 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() , 根據提示進行導包

現在, 讓我們看看執行效果

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

可以看到, 上下已經被分為了兩個區域.

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, 到這裡, 縱軸就繪製好了. 現在可以執行起來看一看效果啦, 下一步, 我們將為海拔圖繪製折線.

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

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閉合起來(這裡省了一步,但會自動閉合起來).

最後, 繪製完漸變投影后,

完成這一步, 讓我們執行起來看看效果吧.

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

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並將其繪製到我們計算出的座標上.

讓我們執行起來, 看看效果. 注意控制元件的底邊部分

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

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了.

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

後面我們會根據文章前面部分提到的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 技術實踐 | 掘金技術徵文,徵文活動正在進行中

相關文章