拒绝引入Flutter三方库,轻松定制 Slider滑动选择器,附源码

1,820 阅读3分钟

介绍

当你接到产品需求,做一个滑块选择器,可能是这样的

image-20230505141055756

当我们使用flutter默认的slider时看到的效果确是这样的

image-20230505153352679

这一看差距太大了,这原生的Flutter Slider肯定无法使用了,于是我们开始寻找开源组件

比如:pub.dev/packages/sy…

image-20230505154102689

但是如果这些还是无法满足里,或者你不想引入一些第三方库(可能因为开源协议不友好之类,或者有些细节无法满足),那现在我来教你如何快速实现自己的Slider

官方Slider的实现

在看源码的时候,还发现了官方的bug,顺便帮Flutter官方修复了一下,具体可以查看我的另外一篇文章# 修复Flutter官方Slider bug并成功合入的经历

flutter官方框架的Slider的实现基本功能是没问题的,只是绘制的和设计不符合,所以我们可以先看一下他的实现,然后再看我们需要做什么。

这里先看一下一个Slider分哪几个部分,我们先简单的从下图来分

  • 拖动按钮
  • 按下时显示的浮层
  • 轨道

image-20230505154615764

paint

下面我们来看Slider的绘制部分,我们直接跟进到源码:(这里我们需要具备的基础知识,绘制时再RenderObject的paint中,我们直接找到Slider关联的RenderObject)

这里很容易我们找到_RenderSliderpaint,这里的代码其实也不算长,逻辑也很清楚,关键逻辑我已经中文加上了注释

  @override
  void paint(PaintingContext context, Offset offset) {
    final double value = _state.positionController.value;
    final double? secondaryValue = _secondaryTrackValue;
​
    // The visual position is the position of the thumb from 0 to 1 from left
    // to right. In left to right, this is the same as the value, but it is
    // reversed for right to left text.
    final double visualPosition;
    final double? secondaryVisualPosition;
    switch (textDirection) {
      case TextDirection.rtl:
        visualPosition = 1.0 - value;
        secondaryVisualPosition = (secondaryValue != null) ? (1.0 - secondaryValue) : null;
        break;
      case TextDirection.ltr:
        visualPosition = value;
        secondaryVisualPosition = (secondaryValue != null) ? secondaryValue : null;
        break;
    }
    //获取轨道的尺寸
    final Rect trackRect = _sliderTheme.trackShape!.getPreferredRect(
      parentBox: this,
      offset: offset,
      sliderTheme: _sliderTheme,
      isDiscrete: isDiscrete,
    );
    //按钮的位置
    final Offset thumbCenter = Offset(trackRect.left + visualPosition * trackRect.width, trackRect.center.dy);
    if (isInteractive) {
      final Size overlaySize = sliderTheme.overlayShape!.getPreferredSize(isInteractive, false);
      overlayRect = Rect.fromCircle(center: thumbCenter, radius: overlaySize.width / 2.0);
    }
    final Offset? secondaryOffset = (secondaryVisualPosition != null) ? Offset(trackRect.left + secondaryVisualPosition * trackRect.width, trackRect.center.dy) : null;
    //绘制轨道
    _sliderTheme.trackShape!.paint(
      context,
      offset,
      parentBox: this,
      sliderTheme: _sliderTheme,
      enableAnimation: _enableAnimation,
      textDirection: _textDirection,
      thumbCenter: thumbCenter,
      secondaryOffset: secondaryOffset,
      isDiscrete: isDiscrete,
      isEnabled: isInteractive,
    );
​
    if (!_overlayAnimation.isDismissed) {
      //绘制浮层
      _sliderTheme.overlayShape!.paint(
        context,
        thumbCenter,
        activationAnimation: _overlayAnimation,
        enableAnimation: _enableAnimation,
        isDiscrete: isDiscrete,
        labelPainter: _labelPainter,
        parentBox: this,
        sliderTheme: _sliderTheme,
        textDirection: _textDirection,
        value: _value,
        textScaleFactor: _textScaleFactor,
        sizeWithOverflow: screenSize.isEmpty ? size : screenSize,
      );
    }
​
    if (isDiscrete) {
      final double tickMarkWidth = _sliderTheme.tickMarkShape!.getPreferredSize(
        isEnabled: isInteractive,
        sliderTheme: _sliderTheme,
      ).width;
      final double padding = trackRect.height;
      final double adjustedTrackWidth = trackRect.width - padding;
      // If the tick marks would be too dense, don't bother painting them.
      if (adjustedTrackWidth / divisions! >= 3.0 * tickMarkWidth) {
        final double dy = trackRect.center.dy;
        for (int i = 0; i <= divisions!; i++) {
          final double value = i / divisions!;
          // The ticks are mapped to be within the track, so the tick mark width
          // must be subtracted from the track width.
          final double dx = trackRect.left + value * adjustedTrackWidth + padding / 2;
          final Offset tickMarkOffset = Offset(dx, dy);
          //绘制刻度
          _sliderTheme.tickMarkShape!.paint(
            context,
            tickMarkOffset,
            parentBox: this,
            sliderTheme: _sliderTheme,
            enableAnimation: _enableAnimation,
            textDirection: _textDirection,
            thumbCenter: thumbCenter,
            isEnabled: isInteractive,
          );
        }
      }
    }
​
    if (isInteractive && label != null && !_valueIndicatorAnimation.isDismissed) {
      if (showValueIndicator) {
        _state.paintValueIndicator = (PaintingContext context, Offset offset) {
          if (attached) {
            //绘制指示器
            _sliderTheme.valueIndicatorShape!.paint(
              context,
              offset + thumbCenter,
              activationAnimation: _valueIndicatorAnimation,
              enableAnimation: _enableAnimation,
              isDiscrete: isDiscrete,
              labelPainter: _labelPainter,
              parentBox: this,
              sliderTheme: _sliderTheme,
              textDirection: _textDirection,
              value: _value,
              textScaleFactor: textScaleFactor,
              sizeWithOverflow: screenSize.isEmpty ? size : screenSize,
            );
          }
        };
      }
    }
    //绘制按钮
    _sliderTheme.thumbShape!.paint(
      context,
      thumbCenter,
      activationAnimation: _overlayAnimation,
      enableAnimation: _enableAnimation,
      isDiscrete: isDiscrete,
      labelPainter: _labelPainter,
      parentBox: this,
      sliderTheme: _sliderTheme,
      textDirection: _textDirection,
      value: _value,
      textScaleFactor: textScaleFactor,
      sizeWithOverflow: screenSize.isEmpty ? size : screenSize,
    );
  }

其实我们看到官方的设计非常的巧妙

Slider的paint拆分成多个Shape来分别绘制。既然实际绘制是若干个shape,那么我们只需要重新实现这些shape就好了,整体划分了五个shape

  • overlayShape, //滑块按下的浮层显示
  • tickMarkShape, //单滑块的刻度
  • thumbShape, //单滑块的按钮
  • trackShape, //单滑块的轨道
  • valueIndicatorShape, //单滑块指示器

SliderTheme

首先我们需要知道这些shape是从哪里取的,比如:thumbShape,也很直观从_sliderTheme.thumbShape。下面我们需要看看sliderTheme是什么东西了。这时我们从网上可以搜到一堆

SliderTheme(
  data: SliderTheme.of(context).copyWith(activeTrackColor: Colors.red),
  child: Slider(
    value: .5,
    onChanged: (value) {},
  ),
)

看到定义,这么多自定义参数,shape相关的部分我做了注释说明

  const SliderThemeData({
    this.trackHeight,
    this.activeTrackColor,
    this.inactiveTrackColor,
    this.secondaryActiveTrackColor,
    this.disabledActiveTrackColor,
    this.disabledInactiveTrackColor,
    this.disabledSecondaryActiveTrackColor,
    this.activeTickMarkColor,
    this.inactiveTickMarkColor,
    this.disabledActiveTickMarkColor,
    this.disabledInactiveTickMarkColor,
    this.thumbColor,
    this.overlappingShapeStrokeColor,
    this.disabledThumbColor,
    this.overlayColor,
    this.valueIndicatorColor,
    this.overlayShape, //滑块按下的浮层显示
    this.tickMarkShape, //单滑块的刻度
    this.thumbShape, //单滑块的按钮
    this.trackShape, //单滑块的轨道
    this.valueIndicatorShape, //单滑块指示器
    this.rangeTickMarkShape, // 双滑块的刻度
    this.rangeThumbShape,  //双滑块的按钮
    this.rangeTrackShape,  //双滑块的轨道
    this.rangeValueIndicatorShape, //双滑块的指示器
    this.showValueIndicator, // 是否显示指示器
    this.valueIndicatorTextStyle, 
    this.minThumbSeparation,
    this.thumbSelector,
    this.mouseCursor,
  });

页-1

自定义Shape

比如我们需要自定义轨道,我们只需要参考默认的实现,然后自己定义个就好,比如我们实现这样的样式

trackShape 轨道绘制

我们只需要把源码(RoundedRectSliderTrackShape)copy处理稍微修改一下就好了。主要是一些canvas的操作

样式一

image-20230524094136255

///
///Slider轨道绘制
///
class TDRoundedRectSliderTrackShape extends SliderTrackShape with BaseSliderTrackShape {
  /// Create a slider track that draws two rectangles with rounded outer edges.
  const TDRoundedRectSliderTrackShape();
​
  @override
  void paint(
    PaintingContext context,
    Offset offset, {
    required RenderBox parentBox,
    required SliderThemeData sliderTheme,
    required Animation<double> enableAnimation,
    required TextDirection textDirection,
    required Offset thumbCenter,
    Offset? secondaryOffset,
    bool isDiscrete = false,
    bool isEnabled = false,
    double additionalActiveTrackHeight = 2,
  }) {
    assert(sliderTheme.disabledActiveTrackColor != null);
    assert(sliderTheme.disabledInactiveTrackColor != null);
    assert(sliderTheme.activeTrackColor != null);
    assert(sliderTheme.inactiveTrackColor != null);
    assert(sliderTheme.thumbShape != null);
    // If the slider [SliderThemeData.trackHeight] is less than or equal to 0,
    // then it makes no difference whether the track is painted or not,
    // therefore the painting can be a no-op.
    if (sliderTheme.trackHeight == null || sliderTheme.trackHeight! <= 0) {
      return;
    }
    assert(sliderTheme is TDSliderThemeData);
​
    sliderTheme as TDSliderThemeData;
    // Assign the track segment paints, which are leading: active and
    // trailing: inactive.
    final activeTrackColorTween =
        ColorTween(begin: sliderTheme.disabledActiveTrackColor, end: sliderTheme.activeTrackColor);
    final inactiveTrackColorTween =
        ColorTween(begin: sliderTheme.disabledInactiveTrackColor, end: sliderTheme.inactiveTrackColor);
    final activePaint = Paint()..color = activeTrackColorTween.evaluate(enableAnimation)!;
    final inactivePaint = Paint()..color = inactiveTrackColorTween.evaluate(enableAnimation)!;
    final Paint leftTrackPaint;
    final Paint rightTrackPaint;
    switch (textDirection) {
      case TextDirection.ltr:
        leftTrackPaint = activePaint;
        rightTrackPaint = inactivePaint;
        break;
      case TextDirection.rtl:
        leftTrackPaint = inactivePaint;
        rightTrackPaint = activePaint;
        break;
    }
​
    final trackRect = getPreferredRect(
      parentBox: parentBox,
      offset: offset,
      sliderTheme: sliderTheme,
      isEnabled: isEnabled,
      isDiscrete: isDiscrete,
    );
    final trackRadius = Radius.circular(trackRect.height / 2);
    final activeTrackRadius = Radius.circular((trackRect.height + additionalActiveTrackHeight) / 2);
​
    context.canvas.drawRRect(
      RRect.fromLTRBAndCorners(
        trackRect.left,
        (textDirection == TextDirection.rtl) ? trackRect.top - (additionalActiveTrackHeight / 2) : trackRect.top,
        thumbCenter.dx,
        (textDirection == TextDirection.rtl) ? trackRect.bottom + (additionalActiveTrackHeight / 2) : trackRect.bottom,
        topLeft: (textDirection == TextDirection.ltr) ? activeTrackRadius : trackRadius,
        bottomLeft: (textDirection == TextDirection.ltr) ? activeTrackRadius : trackRadius,
      ),
      leftTrackPaint,
    );
    context.canvas.drawRRect(
      RRect.fromLTRBAndCorners(
        thumbCenter.dx,
        (textDirection == TextDirection.rtl) ? trackRect.top - (additionalActiveTrackHeight / 2) : trackRect.top,
        trackRect.right,
        (textDirection == TextDirection.rtl) ? trackRect.bottom + (additionalActiveTrackHeight / 2) : trackRect.bottom,
        topRight: (textDirection == TextDirection.rtl) ? activeTrackRadius : trackRadius,
        bottomRight: (textDirection == TextDirection.rtl) ? activeTrackRadius : trackRadius,
      ),
      rightTrackPaint,
    );
  }
}

样式二

image-20230524094642536

class TDCapsuleRectRangeSliderTrackShape extends RangeSliderTrackShape with TDBaseRangeSliderTrackShape {
  final Color trackColorWhenShowScale;
​
  /// Create a slider track with rounded outer edges.
  ///
  /// The middle track segment is the selected range and is active, and the two
  /// outer track segments are inactive.
  const TDCapsuleRectRangeSliderTrackShape({this.trackColorWhenShowScale = const Color(0xFFE7E7E7)});
​
  @override
  Rect getPreferredRect(
      {required RenderBox parentBox,
      Offset offset = Offset.zero,
      required SliderThemeData sliderTheme,
      bool isEnabled = false,
      bool isDiscrete = false}) {
    var rect = super.getPreferredRect(
      parentBox: parentBox,
      offset: offset,
      sliderTheme: sliderTheme,
      isEnabled: isEnabled,
      isDiscrete: isDiscrete,
    );
    return Rect.fromLTRB(rect.left + 12, rect.top, rect.right - 12, rect.bottom);
  }
​
  @override
  void paint(
    PaintingContext context,
    Offset offset, {
    required RenderBox parentBox,
    required SliderThemeData sliderTheme,
    required Animation<double> enableAnimation,
    required Offset startThumbCenter,
    required Offset endThumbCenter,
    bool isEnabled = false,
    bool isDiscrete = false,
    required TextDirection textDirection,
    double additionalActiveTrackHeight = 3,
  }) {
    assert(sliderTheme.disabledActiveTrackColor != null);
    assert(sliderTheme.disabledInactiveTrackColor != null);
    assert(sliderTheme.activeTrackColor != null);
    assert(sliderTheme.inactiveTrackColor != null);
    assert(sliderTheme.rangeThumbShape != null);
​
    if (sliderTheme.trackHeight == null || sliderTheme.trackHeight! <= 0) {
      return;
    }
    var showScale = (sliderTheme as TDSliderThemeData).showScaleValue;
    // Assign the track segment paints, which are left: active, right: inactive,
    // but reversed for right to left text.
    final activeTrackColorTween = ColorTween(
      begin: sliderTheme.disabledActiveTrackColor,
      end: sliderTheme.activeTrackColor,
    );
    final inactiveTrackColorTween = ColorTween(
      begin: sliderTheme.disabledInactiveTrackColor,
      end: showScale ? trackColorWhenShowScale : sliderTheme.inactiveTrackColor,
    );
    final activePaint = Paint()..color = activeTrackColorTween.evaluate(enableAnimation)!;
    final inactivePaint = Paint()..color = inactiveTrackColorTween.evaluate(enableAnimation)!;
​
    final Offset leftThumbOffset;
    final Offset rightThumbOffset;
    switch (textDirection) {
      case TextDirection.ltr:
        leftThumbOffset = startThumbCenter;
        rightThumbOffset = endThumbCenter;
        break;
      case TextDirection.rtl:
        leftThumbOffset = endThumbCenter;
        rightThumbOffset = startThumbCenter;
        break;
    }
    final thumbSize = sliderTheme.rangeThumbShape!.getPreferredSize(isEnabled, isDiscrete);
    final thumbRadius = thumbSize.width / 2;
    assert(thumbRadius > 0);
​
    final trackRect = getPreferredRect(
      parentBox: parentBox,
      offset: offset,
      sliderTheme: sliderTheme,
      isEnabled: isEnabled,
      isDiscrete: isDiscrete,
    );
​
    final trackRadius = Radius.circular(trackRect.height / 2);
​
    context.canvas.drawRRect(
      RRect.fromLTRBAndCorners(
        trackRect.left - 12,
        trackRect.top,
        trackRect.right + 12,
        trackRect.bottom,
        topLeft: trackRadius,
        bottomLeft: trackRadius,
        topRight: trackRadius,
        bottomRight: trackRadius,
      ),
      inactivePaint,
    );
    var activeTrackRadius = Radius.circular(trackRect.height / 2 - additionalActiveTrackHeight);
    final inactiveSecondPaint = Paint()..color = sliderTheme.inactiveTrackColor!;
    if (showScale) {
      context.canvas.drawRRect(
        RRect.fromLTRBAndCorners(
          trackRect.left - 9,
          trackRect.top + additionalActiveTrackHeight,
          rightThumbOffset.dx,
          trackRect.bottom - additionalActiveTrackHeight,
          topLeft: activeTrackRadius,
          bottomLeft: activeTrackRadius,
        ),
        inactiveSecondPaint,
      );
    }
    context.canvas.drawRect(
      Rect.fromLTRB(
        leftThumbOffset.dx,
        trackRect.top + additionalActiveTrackHeight,
        rightThumbOffset.dx,
        trackRect.bottom - additionalActiveTrackHeight,
      ),
      activePaint,
    );
    if ((sliderTheme).showScaleValue) {
      context.canvas.drawRRect(
        RRect.fromLTRBAndCorners(
          rightThumbOffset.dx,
          trackRect.top + additionalActiveTrackHeight,
          trackRect.right + 9,
          trackRect.bottom - additionalActiveTrackHeight,
          topRight: activeTrackRadius,
          bottomRight: activeTrackRadius,
        ),
        inactiveSecondPaint,
      );
    }
  }
}

overlay,tickMark,thumb,valueIndicator

思路和trackShape一样这里就不过多的赘述,具体可参考文尾备注的源码

RangeSlider

思路与Slider一样,自定义实现所需的shape即可

源码

仓库:github.com/TDesignOtea…

IMG_3700IMG_3701IMG_3702