import 'dart:math' as math; import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; import 'package:flutter/scheduler.dart'; void main() => runApp(ExampleApp()); class ExampleApp extends StatelessWidget { @override Widget build(BuildContext context) { var theme = ThemeData( primaryColor: Color(0xFF285DD4), accentColor: Colors.pinkAccent, ); return MaterialApp( title: 'Flutter Demo', theme: theme.copyWith( sliderTheme: theme.sliderTheme.copyWith( thumbColor: const Color(0xFFD1DFFF), ), ), home: ExampleScreen(), ); } } class ExampleScreen extends StatefulWidget { @override _ExampleScreenState createState() => _ExampleScreenState(); } class _ExampleScreenState extends State { final min = 0.0; final max = 20000.0; final lower = ValueNotifier(4680.0); final upper = ValueNotifier(14780.0); @override Widget build(BuildContext context) { final theme = Theme.of(context); return Material( child: Container( padding: const EdgeInsets.all(32.0), alignment: Alignment.center, child: Column( mainAxisAlignment: MainAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.start, children: [ Text.rich( TextSpan( children: [ TextSpan( text: 'Price', style: const TextStyle( fontWeight: FontWeight.bold, ), ), TextSpan(text: ' Range'), ], ), style: TextStyle( fontWeight: FontWeight.w300, fontSize: 42.0, color: theme.primaryColor, ), ), const SizedBox(height: 32.0), AnimatedBuilder( animation: Listenable.merge([lower, upper]), builder: (BuildContext context, Widget child) { final localizations = MaterialLocalizations.of(context); final lowerAmount = '\$${localizations.formatDecimal(lower.value.toInt())}'; final upperAmount = '\$${localizations.formatDecimal(upper.value.toInt())}'; return Text( '$lowerAmount - $upperAmount', style: TextStyle( fontSize: 21.0, color: theme.primaryColor, ), ); }, ), const SizedBox(height: 8.0), Text( 'Average price: \$1200', style: TextStyle( fontSize: 21.0, color: theme.disabledColor.withOpacity(0.4), ), ), const SizedBox(height: 32.0), RubberRangePicker( minValue: min, lowerValue: lower.value, upperValue: upper.value, maxValue: max, onRangeChanged: (double lowerValue, double upperValue) { lower.value = lowerValue; upper.value = upperValue; }, ), const SizedBox(height: 32.0), ], ), ), ); } } typedef RubberRangeChanged = void Function(double lowerValue, double upperValue); class RubberRangePicker extends LeafRenderObjectWidget { const RubberRangePicker({ Key key, @required this.lowerValue, @required this.upperValue, this.minValue = 0.0, this.maxValue = 1.0, this.onRangeChanged, }) : assert(minValue != null && maxValue != null && minValue < maxValue), super(key: key); final double lowerValue; final double upperValue; final double minValue; final double maxValue; final RubberRangeChanged onRangeChanged; @override RenderObject createRenderObject(BuildContext context) { final theme = Theme.of(context); final slider = SliderTheme.of(context); return RenderRubberRangePicker( minValue: minValue, maxValue: maxValue, lowerValue: lowerValue, upperValue: upperValue, inactiveTrackColor: slider.inactiveTrackColor, activeTrackColor: slider.activeTrackColor, inactiveThumbColor: theme.canvasColor, activeThumbColor: slider.thumbColor, onRangeChanged: onRangeChanged, ); } @override void updateRenderObject(BuildContext context, RenderRubberRangePicker renderObject) { final theme = Theme.of(context); final slider = SliderTheme.of(context); renderObject ..minValue = minValue ..maxValue = maxValue ..lowerValue = lowerValue ..upperValue = upperValue ..inactiveTrackColor = slider.inactiveTrackColor ..activeTrackColor = slider.activeTrackColor ..inactiveThumbColor = theme.canvasColor ..activeThumbColor = slider.thumbColor ..onRangeChanged = onRangeChanged; } } class RenderRubberRangePicker extends RenderBox { RenderRubberRangePicker({ @required double minValue, @required double maxValue, @required double lowerValue, @required double upperValue, Color inactiveTrackColor, Color activeTrackColor, Color inactiveThumbColor, Color activeThumbColor, RubberRangeChanged onRangeChanged, }) : _minValue = minValue, _maxValue = maxValue, _lowerValue = lowerValue, _upperValue = upperValue, _onRangeChanged = onRangeChanged { _inactiveTrackPaint = Paint() ..style = PaintingStyle.stroke ..color = inactiveTrackColor ..strokeWidth = 1.0; _activeTrackPaint = Paint() ..style = PaintingStyle.stroke ..color = activeTrackColor ..strokeWidth = 1.5; _inactiveThumbPaint = Paint() ..style = PaintingStyle.fill ..color = inactiveThumbColor; _activeThumbPaint = Paint() ..style = PaintingStyle.fill ..color = activeThumbColor; } static const double thumbSize = 28.0; static const double damping = 0.5; static const double elasticity = 0.5; static const bool constraintStretch = true; static const double stretchRange = 60; static const double animationSpeed = 0.8; final firstSegment = Path(); final secondSegment = Path(); final thirdSegment = Path(); Paint _inactiveTrackPaint; Paint _activeTrackPaint; Paint _inactiveThumbPaint; Paint _activeThumbPaint; double _minValue = 0.0; double _maxValue = 1.0; double _lowerValue = 0.0; double _upperValue = 1.0; RubberRangeChanged _onRangeChanged; Ticker _ticker; double _currentTime = 0.0; Rect _lowerThumb = Rect.zero; Rect _upperThumb = Rect.zero; Offset _previousLocation = Offset.zero; bool _movingLower = false; bool _movingUpper = false; double _lowerAnimationStart = 0.0; double _lowerStartOffset = 0.0; double _upperAnimationStart = 0.0; double _upperStartOffset = 0.0; double _vertOffset = 0.0; set minValue(double value) { _minValue = value; markNeedsPaint(); } double get minValue { if (_minValue > _maxValue) { _maxValue = _minValue; markNeedsPaint(); } return _minValue; } set maxValue(double value) { _maxValue = value; markNeedsPaint(); } double get maxValue { if (_maxValue < _minValue) { _minValue = _maxValue; markNeedsPaint(); } return _maxValue; } double get lowerValue => _lowerValue; set lowerValue(double value) { _lowerValue = value.clamp(minValue, maxValue); if (_lowerValue > upperValue) { upperValue = _lowerValue; } markNeedsPaint(); } double get upperValue => _upperValue; set upperValue(double value) { _upperValue = value.clamp(minValue, maxValue); if (_upperValue < lowerValue) { lowerValue = _upperValue; } markNeedsPaint(); } RubberRangeChanged get onRangeChanged => _onRangeChanged; set onRangeChanged(RubberRangeChanged value) { _onRangeChanged = value; notifyRangeChanged(); } void notifyRangeChanged() { _onRangeChanged.call(lowerValue, upperValue); } Color get inactiveTrackColor => _inactiveTrackPaint.color; set inactiveTrackColor(Color value) { _inactiveTrackPaint.color = value; markNeedsPaint(); } Color get activeTrackColor => _activeTrackPaint.color; set activeTrackColor(Color value) { _activeTrackPaint.color = value; markNeedsPaint(); } Color get inactiveThumbColor => _inactiveThumbPaint.color; set inactiveThumbColor(Color value) { _inactiveThumbPaint.color = value; markNeedsPaint(); } Color get activeThumbColor => _activeThumbPaint.color; set activeThumbColor(Color value) { _activeThumbPaint.color = value; markNeedsPaint(); } @override void performLayout() { assert(constraints.hasBoundedWidth); size = Size(constraints.maxWidth, thumbSize); } @override void attach(PipelineOwner owner) { super.attach(owner); _ticker = Ticker((Duration time) { _currentTime = time.inMicroseconds / Duration.microsecondsPerSecond; markNeedsPaint(); }); _ticker.start(); } @override void detach() { _ticker.dispose(); super.detach(); } @override bool hitTestSelf(Offset position) => true; @override void handleEvent(PointerEvent event, HitTestEntry entry) { assert(debugHandleEvent(event, entry)); if (event is PointerDownEvent) { _beginTracking(globalToLocal(event.position)); } else if (event is PointerMoveEvent) { _continueTracking(globalToLocal(event.position)); } else if (event is PointerUpEvent || event is PointerCancelEvent) { _endTracking(); } } bool _beginTracking(Offset location) { _previousLocation = location; _vertOffset = 0; if (_lowerThumb.contains(location)) { _movingLower = true; _lowerAnimationStart = 0.0; _lowerStartOffset = 0.0; } else if (_upperThumb.contains(location)) { _movingUpper = true; _upperAnimationStart = 0.0; _upperStartOffset = 0.0; } return _movingLower || _movingUpper; } bool _continueTracking(Offset location) { final deltaLocation = (location.dx - _previousLocation.dx); final deltaValue = (maxValue - minValue) * deltaLocation / (size.width - thumbSize * 2); _previousLocation = location; if (_movingLower) { lowerValue = (lowerValue + deltaValue).clamp(minValue, maxValue); upperValue = math.max(upperValue, lowerValue); } else if (_movingUpper) { upperValue = (upperValue + deltaValue).clamp(minValue, maxValue); lowerValue = math.min(upperValue, lowerValue); } notifyRangeChanged(); final touchOffset = (location.dy - size.height / 2.0); final touchOffsetVal = touchOffset.abs(); final double sign = touchOffset.sign; double maxVal = stretchRange; if (constraintStretch) { maxVal = math.min(maxVal, (upperOffset - lowerOffset) / 2.0); if (_movingLower) { maxVal = math.min(maxVal, lowerOffset / 2.0); } if (_movingUpper) { maxVal = math.min(maxVal, (size.width - upperOffset) / 2.0); } } double offsetVal = (maxVal - 1 / (touchOffsetVal * math.pow(48, -(1.9 + 0.6 * elasticity)) + 1 / maxVal)); _vertOffset = sign * math.min(offsetVal, touchOffsetVal); return true; } void _endTracking() { if (_movingLower) { _lowerAnimationStart = _currentTime; _lowerStartOffset = _vertOffset; notifyRangeChanged(); } if (_movingUpper) { _upperAnimationStart = _currentTime; _upperStartOffset = _vertOffset; notifyRangeChanged(); } _movingLower = false; _movingUpper = false; } void paint(PaintingContext context, Offset offset) { _updateThumbPositions(); final canvas = context.canvas; canvas.save(); canvas.translate(offset.dx, offset.dy); final margin = 2.0; //thumbSize / 2; final midY = size.height / 2; final pt1 = Offset(margin, midY); final pt2 = Offset(margin + lowerOffset, midY + _lowerThumb.center.dy - size.height / 2.0); final pt3 = Offset(margin + upperOffset, midY + _upperThumb.center.dy - size.height / 2.0); final pt4 = Offset(size.width - margin, midY); firstSegment.reset(); firstSegment.moveTo(pt1.dx, pt1.dy); firstSegment.cubicTo(pt1.dx + lowerOffset / 2.0, pt1.dy, pt2.dx + -lowerOffset / 2.0, pt2.dy, pt2.dx, pt2.dy); canvas.drawPath(firstSegment, _inactiveTrackPaint); final diff = _upperThumb.center.dx - _lowerThumb.center.dx; secondSegment.reset(); secondSegment.moveTo(pt2.dx, pt2.dy); secondSegment.cubicTo(pt2.dx + diff / 2.0, pt2.dy, pt3.dx + -diff / 2.0, pt3.dy, pt3.dx, pt3.dy); canvas.drawPath(secondSegment, _activeTrackPaint); final controlOffset = (size.width - margin * 2 - upperOffset) / 2.0; thirdSegment.reset(); thirdSegment.moveTo(pt3.dx, pt3.dy); thirdSegment.cubicTo(pt3.dx + controlOffset, pt3.dy, pt4.dx + -controlOffset, pt4.dy, pt4.dx, pt4.dy); canvas.drawPath(thirdSegment, _inactiveTrackPaint); canvas.drawCircle( _lowerThumb.center, _lowerThumb.shortestSide / 2, _movingLower ? _activeThumbPaint : _inactiveThumbPaint, ); canvas.drawCircle(_lowerThumb.center, _lowerThumb.shortestSide / 2, _activeTrackPaint); canvas.drawCircle( _upperThumb.center, _upperThumb.shortestSide / 2, _movingUpper ? _activeThumbPaint : _inactiveThumbPaint, ); canvas.drawCircle(_upperThumb.center, _upperThumb.shortestSide / 2, _activeTrackPaint); canvas.restore(); } void _updateThumbPositions() { final timeMultiplier = 2.5 * animationSpeed; double lowerVertOffset = (_movingLower ? _vertOffset : 0); if (!_movingLower) { final elapsedTime = (_currentTime - _lowerAnimationStart) * timeMultiplier; lowerVertOffset = _springCoordinate(elapsedTime, _lowerStartOffset); } lowerVertOffset = _strokeClamp(lowerVertOffset); double upperVertOffset = (_movingUpper ? _vertOffset : 0); if (!_movingUpper) { final elapsedTime = (_currentTime - _upperAnimationStart) * timeMultiplier; upperVertOffset = _springCoordinate(elapsedTime, _upperStartOffset); } upperVertOffset = _strokeClamp(upperVertOffset); _lowerThumb = Rect.fromLTWH(lowerOffset, (size.height - thumbSize) / 2.0 + lowerVertOffset, thumbSize, thumbSize); _upperThumb = Rect.fromLTWH(upperOffset, (size.height - thumbSize) / 2.0 + upperVertOffset, thumbSize, thumbSize); } double get lowerOffset => (size.width - thumbSize * 2) * ((lowerValue - minValue) / (maxValue - minValue)); double get upperOffset => (size.width - thumbSize * 2) * ((upperValue - minValue) / (maxValue - minValue)) + thumbSize; static double _springCoordinate(double time, double offset) { final m = 6.0; final beta = 40.0 / (2 * m); final omega0 = (20 + 100 * damping) / m; final omega = math.pow(-math.pow(beta, 2) + math.pow(omega0, 2), 0.5); return offset * math.exp(-beta * time) * math.cos(omega * time); } static double _strokeClamp(double value, [double strokeWidth = 2.0]) { return (value < -strokeWidth || value > strokeWidth) ? value : 0.0; } }