介绍

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

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

当咱们运用flutter默许的slider时看到的效果确是这样的

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

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

比方:pub.dev/packages/sy…

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

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

官方Slider的完成

在看源码的时候,还发现了官方的bug,顺便帮Flutter官方修正了一下,详细可以检查我的别的一篇文章# 修正Flutter官方Slider bug并成功合入的经历

flutter官方框架的Slider的完成基本功能是没问题的,仅仅制作的和规划不符合,所以咱们可以先看一下他的完成,然后再看咱们需求做什么。

这儿先看一下一个Slider分哪几个部分,咱们先简略的从下图来分

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

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

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,
  });

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

自界说Shape

比方咱们需求自界说轨迹,咱们只需求参阅默许的完成,然后自己界说个就好,比方咱们完成这样的款式

trackShape 轨迹制作

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

款式一

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

///
///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,
   );
  }
}

款式二

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

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…

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