Range slider - một thành phần tùy biến cao để chọn một phạm vi giá trị đã được phát hành trong Flutter 1.7. Bài viết này sẽ cho biết range slider là gì, tại sao bạn có thể sử dụng nó và cách bạn có thể sử dụng Material Theming để tùy chỉnh hành vi và sự xuất hiện của Flutter RangeSlider.

Tại sao lại là Range Slider? 

Một thanh trượt có thể cung cấp một hoặc nhiều lựa chọn trên một rãnh liên tục hoặc ngắt quãng. Không giống như các thanh trượt đơn sẽ xác định trước một giá trị tối thiểu hoặc tối đa với khả năng điều chỉnh lựa chọn theo một hướng, các range slider có hai điểm lựa chọn cho phép điều chỉnh linh hoạt các điểm giá trị tối thiểu và tối đa ấy. Chính sự linh hoạt này làm cho nó trở thành một phần hữu ích trong các trường hợp khi người dùng thích kiểm soát một phạm vi cụ thể, chẳng hạn như chỉ ra các điểm giá trị hoặc một khoảng thời gian.

Cấu tạo và thực hiện

Rangeslider gồm 5 phần:

  1. Một track mà ngón cái có thể trượt qua 
  2. Các dấu tích trên track khi rangeslider rời rạc 
  3. 2 thumb (hoặc knob) cho biết giá trị tối thiểu và tối đa của phạm vi (range)
  4. Các chỉ số giá trị hiển thị nhãn cho các giá trị thumb khi nhãn được xác định và showValueIndicator khớp với loại thanh trượt.
  5. Lớp phủ hiển thị trên các thumb khi được nhấn. 

 Chúng ta cần một RangeSlider có hình ảnh động phong phú. Điều này bao gồm các hình động dựa trên tương tác cho các vị trí của thumb, cũng như hình ảnh động tích hợp cho các chỉ báo lớp phủ và giá trị. Trong Flutter, chúng ta thực hiện điều này bằng cách biến thành phần RangeSlider thành StatefulWidget, nơi lưu trữ các bộ điều khiển hoạt hình dưới dạng trạng thái.

Các giá trị của range slider thực tế được lưu trữ dưới dạng trạng thái trong tiện ích mẹ. Các giá trị được cập nhật bằng cách gọi setState () trong hàm gọi lại RangeSlider, onChange (). Nói cách khác, để có một range slider tương tác, chính tiện ích RangeSlider phải được tạo trong StatefulWidget.

RangeValues _values = RangeValues(0.3, 0.7);

RangeSlider(
  values: _values,
  onChanged: (RangeValues values) {
    setState(() {
      _values = values;
    });
  },
);

Xử lý đầu vào cảm ứng 

Nếu bạn tò mò về cách RangeSlider thực hiện nhập liệu cảm ứng thì hãy đọc tiếp bài viết này! Một khía cạnh thú vị của RangeSlider là nó là một trong những vật dụng Flutter ngoài luồng duy nhất sử dụng GestureArenaTeam. Phần tiếp theo bao gồm cách tùy chỉnh đầu vào cảm ứng.

GestureArenaTeam được sử dụng để đảm bảo RangeSlider có thể xử lý cả tab và kéo trong khi hoạt động chính xác trong chế độ xem cuộn, chế độ xem thanh tab và các tiện ích khác. Một GestureArenaTeam cho phép một thao tác trong một nhóm các thao tác được chọn đúng.

Đầu tiên, trình nhận dạng kéo được thêm vào nhóm, tiếp theo là trình nhận dạng nhấn. Không có đội trưởng, vì vậy, bộ nhận dạng kéo thắng, vì đó là bộ nhận dạng đầu tiên được thêm vào đội, ngay khi bất kỳ bộ nhận dạng nào khác ra khỏi đấu trường. Mặt khác, nếu vòi có thể thắng hoàn toàn, chẳng hạn như khi thanh trượt nằm trong danh sách cuộn theo chiều dọc và vòi người dùng ngay lập tức nhấc lên, thì bộ nhận dạng chạm sẽ thắng.

Các thao tác nhấn và kéo giải quyết 1 trong 3 vấn đề có thể xảy ra:

  • Kéo onStart hoặc nhấn vào onTapDown →_startInteraction
  • Kéo onUpdate →_handleDragUpdate
  • Kéo onEnd or onCancel and Nhấn onEnd or onCancel → _endInteraction

 Khi bắt đầu tương tác, một trong những điều đầu tiên phải được xác định là nên chọn thumb nào cho chuyển động. RangeSlider thực hiện điều này bằng cách sử dụng chức năng có thể sử dụng các thuộc tính như giá trị nhấn và dịch chuyển kéo và trả về lựa chọn thumb: Thumb.start, Thumb.end hoặc null.

Công cụ chọn thumb mặc định trước tiên cố gắng tìm thumb gần nhất trong _startInteraction. Nếu một thumb được chọn, thì vị trí của nó được cập nhật ngay lập tức vào giá trị nhấn. Nhưng nếu giá trị nhấn nằm giữa các thumb, nhưng không nằm trong một trong hai mục tiêu cảm ứng, thì không có lựa chọn nào. Ngoài ra, nếu các thumb đủ gần nhau và chạm vào cả hai mục tiêu chạm, không có thumb nào được chọn. Trong trường hợp này, một thumb chỉ được chọn một khi có chuyển động khác không (chuyển vị kéo). Sau đó, thumb bên trái được chọn cho chuyển động tiêu cực, và thumb bên phải được chọn cho chuyển động tích cực. Đây là kịch bản duy nhất mà sự tương tác thực sự bắt đầu trong bước _handleDragUpdate đầu tiên. Trong cả hai trường hợp, onChangeStart (), phát ra các giá trị đầu của tương tác này.

Khi các thumb cách xa nhau, chạm vào rãnh bên trong sẽ không chọn thumb:

Khi các thumb gần lại với nhau, thao tác kéo được sử dụng để xác định lựa chọn của các thumb:

Việc thực hiện thumb selector mặc định với hành vi được mô tả ở trên:

 static final RangeThumbSelector _defaultRangeThumbSelector = (
      TextDirection textDirection,
      RangeValues values,
      double tapValue,
      Size thumbSize,
      Size trackSize,
      double dx, // drag displacement
    ) {
    final double touchRadius = math.max(thumbSize.width, RangeSlider._minTouchTargetWidth) / 2;

    final bool inStartTouchTarget = (tapValue - values.start).abs() * trackSize.width < touchRadius;

    final bool inEndTouchTarget = (tapValue - values.end).abs() * trackSize.width < touchRadius;


    if (inStartTouchTarget && inEndTouchTarget) {
      bool towardsStart;
      bool towardsEnd;
      switch (textDirection) {
        case TextDirection.ltr:
          towardsStart = dx < 0;
          towardsEnd = dx > 0;
          break;
        case TextDirection.rtl:
          towardsStart = dx > 0;
          towardsEnd = dx < 0;
          break;
      }
      if (towardsStart)
        return Thumb.start;
      if (towardsEnd)
        return Thumb.end;
    } else {
      if (tapValue < values.start || inStartTouchTarget)
        return Thumb.start;
      if (tapValue > values.end || inEndTouchTarget)
        return Thumb.end;
    }
    return null;
  };

Sau khi chọn thumb, tất cả các cập nhật sắp tới sẽ được dùng để xác định vị trí mới của thumb. Hoạt ảnh lớp phủ bắt đầu trên thumb đã chọn và hoạt ảnh chỉ báo giá trị bắt đầu trên cả hai thumb cùng lúc. Khi người dùng thao tác kéo thumb đã chọn, rangeslider sẽ cho ra một bộ giá trị mới với vị trí được cập nhật và các giá trị sau đó được chuyển trở lại rangeslider để cập nhật vị trí tương ứng.

Bước cuối cùng là _endInteraction. Một khi thao tác chạm hoặc kéo được nâng lên, hình động của chỉ báo lớp phủ và giá trị được bắt đầu trong bước đầu tiên được đảo lại. Ở onChangeEnd () cũng phát ra các giá trị cuối.

Chọn đầu vào cảm ứng tùy chỉnh

Ở phần trước, bạn đã thấy code của thao tác lựa chọn một thumb mặc định của vật liệu. Nhưng nếu bạn muốn một cái gì đó khác thì sao? Đoạn code sau cho biết cách viết thumb selector luôn chọn thumb gần nhất, bất kể phần nào của track được chạm vào.

Việc thực hiện một thumb selector tùy chỉnh luôn tìm thấy thumb gần nhất:

static final RangeThumbSelector _customRangeThumbSelector = (
    TextDirection textDirection,
    RangeValues values,
    double tapValue,
    Size thumbSize,
    Size trackSize,
    double dx,
  ) {
    final double start = (tapValue - values.start).abs();
    final double end = (tapValue - values.end).abs();
    return start < end ? Thumb.start : Thumb.end;
  };

Khi có thumb tùy chỉnh này, bạn có thể đặt nó trong theme ứng dụng toàn cầu:

theme: ThemeData(
  sliderTheme: SliderThemeData(
    thumbSelector: _customRangeThumbSelector,
  ),
  // ...
),

Hoặc nó có thể được đặt trên một phiên bản thanh trượt cụ thể bằng SliderTheme:

SliderTheme(
  data: SliderThemeData(
    thumbSelector: _customRangeThumbSelector,
    // ...
  ),
  child: RangeSlider(
    // ...
  ),
),

Kiểm soát vị trí của thumb được phép

Bên trên, bạn đã thấy cách sử dụng SliderThemeData để tùy chỉnh cách chọn đối với thumb. Phần này cho biết cách để giới hạn các vị trí mà thmb có thể được kéo hoặc đặt thành. Có 2 cách để kiểm soát các vị trí cho phép của thumb. Nó có thể được thực hiện theo giá trị, hoặc bởi không gian. Giá trị cho phép có thể trong khoảng $ 0 và $ 100, nhưng bạn muốn phạm vi cách nhau ít nhất $ 20. Vì vậy, phạm vi [$ 30, $ 50] sẽ được cho phép nhưng phạm vi [$ 33, $ 34] sẽ không được phép. Chỉ cần điều chỉnh chức năng onChanged như sau:

RangeSlider(
  values: _values,
  min: 0,
  max: 100,
  onChanged: (RangeValues values) {
    setState(() {
      if (values.end - values.start >= 20) {
        _values = values;
      } else {
        if (_values.start == values.start) {
          _values = RangeValues(_values.start, _values.start + 20);
        } else {
          _values = RangeValues(_values.end - 20, _values.end);
        }
      }
    });
  }
);

Nếu chỉ cần hạn chế thumb xuất hiện, thì thuộc tính minThumbSeparation có thể được sử dụng để giới hạn số lượng pixel logic tách 2 thumb. Thumb trên cùng mặc định sẽ vẽ một đường viền màu trắng xung quanh chính nó để có độ tương phản tốt hơn giữa các thumb. Dưới đây là so sánh hiển thị giá trị mặc định cạnh nhau là 8 so với giá trị tùy chỉnh là 24. 

Painting shapes

Ngoài việc xử lý đầu vào cảm ứng, RenderBox còn chịu trách nhiệm vẽ RangeSlider. Nó vẽ các thành phần RangeSlider sườn theo thứ tự này:

  1. Track
  2. Overlays
  3. Đánh dấu tích (nếu rời rạc)
  4. Các chỉ số giá trị (nếu hiển thị)
  5. Thumbs

Điều này quan trọng để biết khi vẽ hình dạng tùy chỉnh. Tất cả các cài đặt hình dạng được trừu tượng hóa khỏi phương thức RenderBox.paint () thông qua 5 lớp trừu tượng riêng biệt, giúp cho việc vẽ hoặc kết xuất RangeSlider có thể tùy chỉnh hoàn toàn và có thể sử dụng được do các lớp tồn tại trên SliderThemeData. 

Trong phần tiếp theo, tôi sẽ chỉ cho bạn cách ghi đè các hình dạng mặc định bằng các hình tùy chỉnh.

Sử dụng các hình tùy chỉnh

Giống như Thanh trượt đơn, tất cả các hình tạo nên một thanh trượt có thể được tùy chỉnh cho RangeSlider. Xem clip này để biết ví dụ về cách Thanh trượt được tùy chỉnh.
Điều này được thực hiện bằng cách chuyển các triển khai tùy chỉnh của các lớp hình dạng trừu tượng vào SliderThemeData. Điều này tận dụng lớp RangeSliderThumbShape để cung cấp các thumb tùy chỉnh có hình dáng khác nhau tùy thuộc vào phía chúng.
Hình thumb tùy chỉnh phạm vi có thể được thực hiện như sau:

class _CustomRangeThumbShape extends RangeSliderThumbShape {
  static const double _thumbSize = 4.0;

  @override
  Size getPreferredSize(bool isEnabled, bool isDiscrete) => _thumbSize;

  @override
  void paint(
    PaintingContext context,
    Offset center, {
    @required Animation<double> activationAnimation,
    @required Animation<double> enableAnimation,
    bool isDiscrete = false,
    bool isEnabled = false,
    bool isOnTop,
    @required SliderThemeData sliderTheme,
    TextDirection textDirection,
    Thumb thumb,
  }) {
    final Canvas canvas = context.canvas;

    Path thumbPath;
    switch (textDirection) {
      case TextDirection.rtl:
        switch (thumb) {
          case Thumb.start:
            thumbPath = _rightTriangle(_thumbSize, center);
            break;
          case Thumb.end:
            thumbPath = _leftTriangle(_thumbSize, center);
            break;
        }
        break;
      case TextDirection.ltr:
        switch (thumb) {
          case Thumb.start:
            thumbPath = _leftTriangle(_thumbSize, center);
            break;
          case Thumb.end:
            thumbPath = _rightTriangle(_thumbSize, center);
            break;
        }
        break;
    }
    canvas.drawPath(thumbPath, Paint()..color = sliderTheme.thumbColor);
  }
}

Path _rightTriangle(double size, Offset thumbCenter, {bool invert = false}) {
  final Path thumbPath = Path();
  final double halfSize = size / 2.0;
  final double sign = invert ? -1.0 : 1.0;
  thumbPath.moveTo(thumbCenter.dx + halfSize * sign, thumbCenter.dy);
  thumbPath.lineTo(thumbCenter.dx - halfSize * sign, thumbCenter.dy - size);
  thumbPath.lineTo(thumbCenter.dx - halfSize * sign, thumbCenter.dy + size);
  thumbPath.close();
  return thumbPath;
}

Path _leftTriangle(double size, Offset thumbCenter) => _rightTriangle(size, thumbCenter, invert: true);

Sau đó, hình thumb phạm vi tùy chỉnh có thể được đặt trên SliderThemeData:

SliderTheme(
  data: SliderThemeData(
    rangeThumbShape: _CustomRangeThumbShape(),
    // ...
  ),
  child: RangeSlider(
    // ...
  ),
),

Đóng dấu

Material range slider là một thành phần được cộng đồng yêu cầu. Nó hoạt động vượt trội và cũng có thể tùy chỉnh để phù hợp với nhu cầu ứng dụng của bạn. Hành vi và hình thức trực quan có thể được thay đổi trong chủ đề ở cấp độ toàn cầu, hoặc trên cơ sở ví dụ.

Code hoàn chỉnh của tất cả các code có trong bài viết này, cùng với nhiều ví dụ khác, có thể được tìm thấy trong bộ sưu tập  Material gallery on github và trong Material library on github.
Nguồn: https://medium.com/flutter/material-range-slider-in-flutter-a285c6e3447d