【開源 UI 元件】Flutter 圖表範圍選擇器

語言: CN / TW / HK

theme: cyanosis

攜手創作,共同成長!這是我參與「掘金日新計劃 · 8 月更文挑戰」的第 4 天,點選檢視活動詳情


前言

最近有一個小需求:圖表支援區域性顯示,如下底部的區域選擇器支援 - 左右拖動調節中間區域 - 拖拽中間區域,可以進行移動 - 圖表資料根據中間區域的佔比進行顯示部分資料


這樣當圖表的資料量過大,不宜全部展示時,可選擇的區域性展示就是個不錯的解決方案。由於一般的圖表庫沒有提供該功能,這裡自己通過繪製來實現以下,操作效果如下所示:


1. 使用 chart_range_selector

目前這個範圍選擇器已經發布到 pub 上了,名字是 chart_range_selector。大家可以通過依賴進行新增

dependencies: chart_range_selector: ^1.0.0

這個庫本身是作為獨立 UI 元件存在的,在拖拽過程中改變區域範圍時,會觸發回撥。使用者可以通過監聽來獲取當前區域的範圍。這裡的區域起止是以分率的形式給出的,也就是最左側是 0 最右側是 1 。如下的區域範圍是 0.26 ~ 0.72

```dart ChartRangeSelector( height: 30, initStart: 0.4, initEnd: 0.6, onChartRangeChange: _onChartRangeChange, ),

void _onChartRangeChange(double start, double end) { print("start:$start, end:$end"); } ```


封裝的元件名為: ChartRangeSelector ,提供瞭如下的一些配置引數:

image.png

| 配置項 | 型別 | 簡述 | | --- |--- |--- | | initStart | double | 範圍啟始值 0~1 | | initEnd | double | 範圍終止值 0~1 | | height | double | 高度值 | | onChartRangeChange | OnChartRangeChange | 範圍變化回撥 | | bgStorkColor | Color | 背景線條顏色 | | bgFillColor | Color | 背景填充顏色 | | rangeColor | Color | 區域顏色 | | rangeActiveColor | Color | 區域啟用顏色 | | dragBoxColor | Color | 左右拖拽塊顏色 | | dragBoxActiveColor | Color | 左右拖拽塊啟用顏色 |


2. ChartRangeSelector 實現思路分析

這個元件整體上是通過 ChartRangeSelectorPainter 繪製出來的,其實這些圖形都是挺規整的,繪製來說並不是什麼難事。重點在於事件的處理,拖拽不同的部位需要處理不同的邏輯,還涉及對拖拽部位的校驗、高亮示意,對這塊的整合還是需要一定的功力的。

image.png

程式碼中通過 RangeData 可監聽物件為繪製提供必要的資料,其中 minGap 用於控制範圍的最小值,保證範圍不會過小。另外定義了 OperationType 列舉表示操作,其中有四個元素,none 表示沒有拖拽的普通狀態;dragHead 表示拖動起始塊,dragTail 表示拖動終止塊,dragZone 表示拖動範圍區域。

```dart enum OperationType{ none, dragHead, dragTail, dragZone }

class RangeData extends ChangeNotifier { double start; double end; double minGap; OperationType operationType=OperationType.none;

RangeData({this.start = 0, this.end = 1,this.minGap=0.1});

//暫略相關方法... } ```


在元件構建中,通過 LayoutBuilder 獲取元件的約束資訊,從而獲得約束區域寬度最大值,也就是說元件區域的寬度值由使用者自行約束,該元件並不強制指定。使用 SizedBox 限定畫板的高度,通過 CustomPaint 元件使用 ChartRangeSelectorPainter 進行繪製。使用 GestureDetector 元件進行手勢互動監聽,這就是該元件整體上實現的思路。


3.核心程式碼實現分析

可以看出,這個元件的核心就是 繪製 + 手勢互動 。其中繪製比較簡單,就是根據 RangeData 資料和顏色配置畫些方塊而已,稍微困難一點的是對左右控制柄位置的計算。另外,三個可拖拽物的啟用狀態是通過 RangeData#operationType 進行判斷的。


也就是說所有問題的焦點都集中在 手勢互動 中對 RangeData 資料的更新。如下是處理按下的邏輯,當觸電橫座標左右 10 邏輯畫素之內,表示啟用頭部。如下 tag1 處通過 dragHead 方法更新 operationType 並觸發通知,這樣畫板繪製時就會啟用頭部塊,右側和中間的啟用同理。

dart ---->[RangeData#dragHead]---- void dragHead(){ operationType=OperationType.dragHead; notifyListeners(); }

dart void _onPanDown(DragDownDetails details, double width) { double start = width * rangeData.start; double x = details.localPosition.dx; double end = width * rangeData.end; if (x >= start - 10 && x <= end + 10) { if ((start - details.localPosition.dx).abs() < 10) { rangeData.dragHead(); // tag1 return; } if ((end - details.localPosition.dx).abs() < 10) { rangeData.dragTail(); return; } rangeData.dragZone(); } }


對於拖手勢的處理,是比較複雜的。如下根據 operationType 進行不同的邏輯處理,比如當 dragHead 時,觸發 RangeData#moveHead 方法移動 start 值。這裡將具體地邏輯封裝在 RangeData 類中。可以使程式碼更加簡潔明瞭,每個操作都有 bool 返回值用於校驗區域也沒有發生變化,比如拖拽到 0 時,繼續拖拽是會觸發事件的,此時返回 false,避免無意義的 onChartRangeChange 回撥觸發。

void _onUpdate(DragUpdateDetails details, double width) { bool changed = false; if (rangeData.operationType == OperationType.dragHead) { changed = rangeData.moveHead(details.delta.dx / width); } if (rangeData.operationType == OperationType.dragTail) { changed = rangeData.moveTail(details.delta.dx / width); } if (rangeData.operationType == OperationType.dragZone) { changed = rangeData.move(details.delta.dx / width); } if (changed) widget.onChartRangeChange.call(rangeData.start, rangeData.end); }

如下是 RangeData#moveHead 的處理邏輯,_recordStart 用於記錄起始值,如果移動後未改變,返回 false。表示不執行通知和觸發回撥。

---->[RangeData#moveHead]---- bool moveHead(double ds) { start += ds; start = start.clamp(0, end - minGap); if (start == _recordStart) return false; _recordStart = start; notifyListeners(); return true; }


4. 結合圖表使用

下面是結合 charts_flutter 圖示庫實現的範圍顯示案例。其中核心點是 domainAxis 可以通過 NumericAxisSpec 來顯示某個範圍的資料,而 ChartRangeSelector 提供拽的互動操作來更新這個範圍,可謂相輔相成。

``` class RangeChartDemo extends StatefulWidget { const RangeChartDemo({Key? key}) : super(key: key);

@override State createState() => _RangeChartDemoState(); }

class _RangeChartDemoState extends State { List data = [];

int start = 0; int end = 0;

@override void initState() { super.initState(); data = randomDayData(count: 96); start = 0; end = (0.8 * data.length).toInt(); }

Random random = Random();

List randomDayData({int count = 1440}) { return List.generate(count, (index) { int value = 50 + random.nextInt(200); return ChartData(index, value); }); }

@override Widget build(BuildContext context) {

List<charts.Series<ChartData, int>> seriesList = [
  charts.Series<ChartData, int>(
    id: 'something',
    colorFn: (_, __) => charts.MaterialPalette.blue.shadeDefault,
    domainFn: (ChartData sales, _) => sales.index,
    measureFn: (ChartData sales, _) => sales.value,
    data: data,
  )
];

return Column(
  children: [
    Expanded(
      child: charts.LineChart(seriesList,
          animate: false,
          primaryMeasureAxis: const charts.NumericAxisSpec(
              tickProviderSpec: charts.BasicNumericTickProviderSpec(desiredTickCount: 5),),
          domainAxis: charts.NumericAxisSpec(
            viewport: charts.NumericExtents(start, end),
          )),
    ),
    const SizedBox(
      height: 10,
    ),
    SizedBox(
      width: 400,
      child: ChartRangeSelector(
          height: 30,
          initEnd: 0.5,
          initStart: 0.3,
          onChartRangeChange: (start, end) {
            this.start = (start * data.length).toInt();
            this.end = (end * data.length).toInt();
            setState(() {});
          }),
    ),
  ],
);

} }

class ChartData { final int index; final int value;

ChartData(this.index, this.value); } ```

本文就介紹到這裡,更多的實現細節感興趣的可以研究一下原始碼。謝謝觀看 ~