UI 開源組件Flutter圖表范圍選擇器使用詳解

前言

最近有一個小需求:圖表支持局部顯示,如下底部的區域選擇器支持

  • 左右拖動調節中間區域
  • 拖拽中間區域,可以進行移動
  • 圖表數據根據中間區域的占比進行顯示部分數據

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

1. 使用 chart_range_selector

目前這個范圍選擇器已經發佈到 pub 上瞭,名字是 chart_range_selector。大傢可以通過依賴進行添加

dependencies:
  chart_range_selector: ^1.0.0

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

ChartRangeSelector(
  height: 30,
  initStart: 0.4,
  initEnd: 0.6,
  onChartRangeChange: _onChartRangeChange,
),
void _onChartRangeChange(double start, double end) {
  print("start:$start, end:$end");
}

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

配置項 類型 簡述
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 繪制出來的,其實這些圖形都是挺規整的,繪制來說並不是什麼難事。

重點在於事件的處理,拖拽不同的部位需要處理不同的邏輯,還涉及對拖拽部位的校驗、高亮示意,對這塊的整合還是需要一定的功力的。

代碼中通過 RangeData 可監聽對象為繪制提供必要的數據,其中 minGap 用於控制范圍的最小值,保證范圍不會過小。

另外定義瞭 OperationType 枚舉表示操作,其中有四個元素,none 表示沒有拖拽的普通狀態;

dragHead 表示拖動起始塊,dragTail 表示拖動終止塊,dragZone 表示拖動范圍區域。

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 並觸發通知,這樣畫板繪制時就會激活頭部塊,右側和中間的激活同理。

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

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<RangeChartDemo> createState() => _RangeChartDemoState();
}
class _RangeChartDemoState extends State<RangeChartDemo> {
  List<ChartData> 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<ChartData> 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);
}

以上就是UI 開源組件Flutter圖表范圍選擇器使用詳解的詳細內容,更多關於Flutter圖表范圍選擇器的資料請關註WalkonNet其它相關文章!

推薦閱讀: