拒绝引入Flutter三方库,轻松定制 Slider滑动选择器,附源码
介绍
当你接到产品需求,做一个滑块选择器,可能是这样的
当我们使用flutter默认的slider时看到的效果确是这样的
这一看差距太大了,这原生的Flutter Slider肯定无法使用了,于是我们开始寻找开源组件
但是如果这些还是无法满足里,或者你不想引入一些第三方库(可能因为开源协议不友好之类,或者有些细节无法满足),那现在我来教你如何快速实现自己的Slider
官方Slider的实现
在看源码的时候,还发现了官方的bug,顺便帮Flutter官方修复了一下,具体可以查看我的另外一篇文章# 修复Flutter官方Slider bug并成功合入的经历
flutter官方框架的Slider的实现基本功能是没问题的,只是绘制的和设计不符合,所以我们可以先看一下他的实现,然后再看我们需要做什么。
这里先看一下一个Slider分哪几个部分,我们先简单的从下图来分
- 拖动按钮
- 按下时显示的浮层
- 轨道
paint
下面我们来看Slider的绘制部分,我们直接跟进到源码:(这里我们需要具备的基础知识,绘制时再RenderObject的paint中,我们直接找到Slider关联的RenderObject)
这里很容易我们找到_RenderSlider
的paint
,这里的代码其实也不算长,逻辑也很清楚,关键逻辑我已经中文加上了注释
@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,
});
自定义Shape
比如我们需要自定义轨道,我们只需要参考默认的实现,然后自己定义个就好,比如我们实现这样的样式
trackShape 轨道绘制
我们只需要把源码(RoundedRectSliderTrackShape)copy处理稍微修改一下就好了。主要是一些canvas的操作
样式一
///
///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,
);
}
}
样式二
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即可
源码
转载自:https://juejin.cn/post/7236765329917083707