[譯] 用 Flutter 打造一個圓形滑塊(Slider)

你是否也曾想要經過爲滑塊添加雙重滑塊或修改其佈局來讓它看起來不那麼無聊?html

在這篇文章中我會展現如何經過整合 GestureDetector 以及 Canvas 來在 Flutter 中構建一個圓形滑塊。前端

若是你對構建它的過程不感興趣,僅僅是爲了獲取此部件並使用它,那麼你可使用我在 pub.dartlang.org/packages/fl… 發佈的程序包。android

爲何要用圓形滑塊?

大多數狀況下你並不會須要它。但想象一下:若是你想要用戶選定一個時間段,或者只是想要一個比直線形狀更有趣一點的常規滑塊的場景時,就可使用圓形滑塊。ios

用什麼來構建它?

咱們要準備的第一件事就是建立一個真正的滑塊。爲此,咱們要用一個完美的圓形做爲背景,在它的基礎上再畫一個根據用戶交互能夠動態顯示的圓。爲了實現咱們的想法,咱們將用到一個名爲 CustomPaint 的特殊部件,它提供一個容許讓咱們自由創做的畫布(Canvas)。git

當滑塊渲染完成之後,咱們但願用戶可以和它進行交互,所以咱們選擇使用 GestureDetector 封裝它來捕獲點擊及拖動事件。github

完整流程是:canvas

  • 繪製滑塊
  • 當用戶經過點擊其中一個滑塊並拖動它來與圓形滑塊交互時識別此事件。
  • 將事件的附加信息向下傳遞給畫布(Canvas),在這裏咱們將從新繪製頂部圓形。
  • 將新值一路向上傳遞給相應的 Handler,以便讓用戶觀察到變化。(例如,更新滑塊中心的文字顯示)。

(只需關注上圖黃色部分)後端

來畫幾個圓吧

咱們要作的第一件事就是畫兩個圓。一個靜態樣式(無需改變),另外一個則是動態的樣式(響應用戶交互),我使用兩個 Painter 來分別繪製它們。bash

兩個 Painter 都繼承自 CustomPainter —— 一個由 Flutter 提供並實現 paint()shouldRepaint() 方法的類。第一個方法用來繪製咱們想要繪製的形狀,第二個方法在有變化時進行從新繪製的時候調用。對於 BasePainter 而言咱們永遠不會須要重繪,所以它的返回值老是 false。而對於 SliderPainter 來講它老是返回 true,由於每次更改都意味着用戶移動了滑塊,必須更新所選擇的項。ide

import 'package:flutter/material.dart';

class BasePainter extends CustomPainter {
  Color baseColor;

  Offset center;
  double radius;

  BasePainter({@required this.baseColor});

  @override
  void paint(Canvas canvas, Size size) {
    Paint paint = Paint()
        ..color = baseColor
        ..strokeCap = StrokeCap.round
        ..style = PaintingStyle.stroke
        ..strokeWidth = 12.0;

    center = Offset(size.width / 2, size.height / 2);
    radius = min(size.width / 2, size.height / 2);

    canvas.drawCircle(center, radius, paint);
  }

  @override
  bool shouldRepaint(CustomPainter oldDelegate) {
    return false;
  }
}
複製代碼

能夠看到,paint() 方法得到一個 Canvas 和一個 Size 參數。Canvas 提供一組方法可讓咱們繪製任何形狀:圓形、直線、圓弧、矩形等等。Size 參數便是畫布的尺寸,由畫布適配的部件尺寸決定。咱們還須要一個 Paint,容許咱們定製樣式、顏色以及其餘東西。

如今 BasePainter 的功能用法已經不言自明,然而 SliderPainter 卻有一點兒不尋常,如今咱們不只要繪製一個圓弧而非圓,還須要繪製 Handler。

import 'dart:math';

import 'package:flutter/material.dart';
import 'package:flutter_circular_slider/src/utils.dart';

class SliderPainter extends CustomPainter {
  double startAngle;
  double endAngle;
  double sweepAngle;
  Color selectionColor;

  Offset initHandler;
  Offset endHandler;
  Offset center;
  double radius;

  SliderPainter(
      {@required this.startAngle,
      @required this.endAngle,
      @required this.sweepAngle,
      @required this.selectionColor});

  @override
  void paint(Canvas canvas, Size size) {
    if (startAngle == 0.0 && endAngle == 0.0) return;

    Paint progress = _getPaint(color: selectionColor);

    center = Offset(size.width / 2, size.height / 2);
    radius = min(size.width / 2, size.height / 2);

    canvas.drawArc(Rect.fromCircle(center: center, radius: radius),
        -pi / 2 + startAngle, sweepAngle, false, progress);

    Paint handler = _getPaint(color: selectionColor, style: PaintingStyle.fill);
    Paint handlerOutter = _getPaint(color: selectionColor, width: 2.0);

    // 繪製 handler
    initHandler = radiansToCoordinates(center, -pi / 2 + startAngle, radius);
    canvas.drawCircle(initHandler, 8.0, handler);
    canvas.drawCircle(initHandler, 12.0, handlerOutter);

    endHandler = radiansToCoordinates(center, -pi / 2 + endAngle, radius);
    canvas.drawCircle(endHandler, 8.0, handler);
    canvas.drawCircle(endHandler, 12.0, handlerOutter);
  }

  Paint _getPaint({@required Color color, double width, PaintingStyle style}) =>
      Paint()
        ..color = color
        ..strokeCap = StrokeCap.round
        ..style = style ?? PaintingStyle.stroke
        ..strokeWidth = width ?? 12.0;

  @override
  bool shouldRepaint(CustomPainter oldDelegate) {
    return true;
  }
}
複製代碼

再一次地,咱們獲取了 center 和 radius 的值,但咱們此次繪製的是圓弧。SliderPainter 將根據用戶交互反饋的值做爲 start、end 和 sweap 屬性的值,以便於咱們根據這些參數來繪製圓弧。值得一提的是咱們須要從初始角度中減去 pi/2,由於咱們的滑塊的圓弧的起始位置是在圓形的正上方,而 drawArc() 方法使用 x 軸正軸做爲起始位置。

當咱們繪製好圓弧之後咱們就須要準備繪製 Handler 了。爲此,咱們將分別繪製兩個圓,一個在內部填充,一個在外部包裹。我調用了一些工具集函數用來將弧度轉換爲圓的座標。你能夠在 Github 倉庫內查閱這些函數

讓滑塊響應交互

目前來看,僅僅使用 CustomPaint 以及兩個 Painter 就已經足夠繪製想要的東西了。然而它們仍是不可以進行交互。所以就要使用 GestureDetector 來對它進行封裝。這樣一來咱們就能夠在畫布上對用戶事件作出相應處理。

一開始咱們將爲 Handler 賦初值,當獲取這些 Handler 的座標後,咱們將按照如下策略執行操做:

  • 監聽對於 Handler 的點擊(按下)事件並更新相應 Handler 的狀態。(_xHandlerSelected = true)。
  • 監聽被選中 Handler 的拖動更新事件,更新其座標,同時分別向下、向上傳遞給 SliderPainter 和咱們的回調函數。
  • 監聽 Handler 的點擊(擡起)事件並重置未選中 Handler 的狀態。

由於咱們須要分別計算出座標值、新的角度值再傳遞給 Handler 和 Painter,因此咱們的 CircularSliderPaint 必須是一個 StatefulWidget

import 'package:flutter/material.dart';
import 'package:flutter_circular_slider/src/base_painter.dart';
import 'package:flutter_circular_slider/src/slider_painter.dart';
import 'package:flutter_circular_slider/src/utils.dart';

class CircularSliderPaint extends StatefulWidget {
  final int init;
  final int end;
  final int intervals;
  final Function onSelectionChange;
  final Color baseColor;
  final Color selectionColor;
  final Widget child;

  CircularSliderPaint(
      {@required this.intervals,
      @required this.init,
      @required this.end,
      this.child,
      @required this.onSelectionChange,
      @required this.baseColor,
      @required this.selectionColor});

  @override
  _CircularSliderState createState() => _CircularSliderState();
}

class _CircularSliderState extends State<CircularSliderPaint> {
  bool _isInitHandlerSelected = false;
  bool _isEndHandlerSelected = false;

  SliderPainter _painter;

  /// 用弧度製表示的起始角度,用來肯定 init Handler 的位置。
  double _startAngle;

  /// 用弧度製表示的結束角度,用來肯定 end Handler 的位置。
  double _endAngle;

  /// 用弧度製表示的選擇區間的絕對角度(夾角)
  double _sweepAngle;

  @override
  void initState() {
    super.initState();
    _calculatePaintData();
  }

  // 咱們須要使用 gesture detector 來更新此部件,
  // 當父部件重建本身時也是如此。
  @override
  void didUpdateWidget(CircularSliderPaint oldWidget) {
    super.didUpdateWidget(oldWidget);
    if (oldWidget.init != widget.init || oldWidget.end != widget.end) {
      _calculatePaintData();
    }
  }

  @override
  Widget build(BuildContext context) {
    return GestureDetector(
      onPanDown: _onPanDown,
      onPanUpdate: _onPanUpdate,
      onPanEnd: _onPanEnd,
      child: CustomPaint(
        painter: BasePainter(
            baseColor: widget.baseColor,
            selectionColor: widget.selectionColor),
        foregroundPainter: _painter,
        child: Padding(
          padding: const EdgeInsets.all(12.0),
          child: widget.child,
        ),
      ),
    );
  }

  void _calculatePaintData() {
    double initPercent = valueToPercentage(widget.init, widget.intervals);
    double endPercent = valueToPercentage(widget.end, widget.intervals);
    double sweep = getSweepAngle(initPercent, endPercent);

    _startAngle = percentageToRadians(initPercent);
    _endAngle = percentageToRadians(endPercent);
    _sweepAngle = percentageToRadians(sweep.abs());

    _painter = SliderPainter(
      startAngle: _startAngle,
      endAngle: _endAngle,
      sweepAngle: _sweepAngle,
      selectionColor: widget.selectionColor,
    );
  }

  _onPanUpdate(DragUpdateDetails details) {
    if (!_isInitHandlerSelected && !_isEndHandlerSelected) {
      return;
    }
    if (_painter.center == null) {
      return;
    }
    RenderBox renderBox = context.findRenderObject();
    var position = renderBox.globalToLocal(details.globalPosition);

    var angle = coordinatesToRadians(_painter.center, position);
    var percentage = radiansToPercentage(angle);
    var newValue = percentageToValue(percentage, widget.intervals);

    if (_isInitHandlerSelected) {
      widget.onSelectionChange(newValue, widget.end);
    } else {
      widget.onSelectionChange(widget.init, newValue);
    }
  }

  _onPanEnd(_) {
    _isInitHandlerSelected = false;
    _isEndHandlerSelected = false;
  }

  _onPanDown(DragDownDetails details) {
    if (_painter == null) {
      return;
    }
    RenderBox renderBox = context.findRenderObject();
    var position = renderBox.globalToLocal(details.globalPosition);
    if (position != null) {
      _isInitHandlerSelected = isPointInsideCircle(
          position, _painter.initHandler, 12.0);
      if (!_isInitHandlerSelected) {
        _isEndHandlerSelected = isPointInsideCircle(
            position, _painter.endHandler, 12.0);
      }
    }
  }
}
複製代碼

這裏有幾點須要注意:

  • 咱們想要在 Handler(以及選擇區間)的位置更新時通知父部件,這也是該部件對外暴露了一個回調函數 onSelectionChange() 的緣由。
  • 當用戶與滑塊進行交互時,該部件須要被從新渲染,當起始位置的參數值改變時也需如此。這就是爲何咱們有必要使用 didUpdateWidget() 方法。
  • CustomPaint 一樣能夠接收一個 child 參數,這樣咱們就可使用它在圓的內部渲染生成一些其餘東西。只須要在 final widget 裏暴露相同的參數,使用者就能夠向其中傳入任何想要的值。
  • 咱們使用一個間隔用以設置滑塊的值。咱們能夠以此方便的將選擇區間以百分比的形式表示。
  • 再一次申明,爲了在百分比、弧度以及座標之間轉換我調用了不一樣的工具集函數。畫布(Canvas)中的座標系與通常座標系有一些不一樣,好比說畫布座標系是以左上角做爲座標原點,這樣一來 x、y 的值都將一直是一個正值。一樣的,弧度制的表示是以 x 正座標軸開始並以順時針方向(老是正值)從 02*pi 計量。
  • 最後,Handler 的座標計算以畫布的原點爲參考,而 GestureDetector 的座標則是相對設備而言的,是全局的,所以咱們須要用到 RenderBox.globalToLocal() 方法來對它們進行轉換。該方法使用部件的 Context 做爲參考。

有了這些,咱們也就擁有了打造圓形滑塊的一切須要。

額外的功能

因爲篇幅有限,在這裏並無展開講解全部的細節。你能夠查看本項目的倉庫,我會樂於回答評論中的任何問題。

在最終的版本里我添加了一些額外的功能,好比自定義選擇區間和 Handler 的顏色;若是你想實現相似時鐘的樣式(小時和分鐘)你能夠根據需求進行選擇。爲了方便各位使用,我一樣將全部內容打包放進了一個最終的部件內。

你也能夠經過從 pub.dartlang.org/packages/fl… 導入本庫的方式來使用這個部件。

文章至此告一段落,感謝各位的閱讀!

若是發現譯文存在錯誤或其餘須要改進的地方,歡迎到 掘金翻譯計劃 對譯文進行修改並 PR,也可得到相應獎勵積分。文章開頭的 本文永久連接 即爲本文在 GitHub 上的 MarkDown 連接。


掘金翻譯計劃 是一個翻譯優質互聯網技術文章的社區,文章來源爲 掘金 上的英文分享文章。內容覆蓋 AndroidiOS前端後端區塊鏈產品設計人工智能等領域,想要查看更多優質譯文請持續關注 掘金翻譯計劃官方微博知乎專欄

相關文章
相關標籤/搜索