-
-
Save mkiisoft/fd75e9421ffd23f8224d66ec20dd03fd to your computer and use it in GitHub Desktop.
Rubber Range Picker - Based on https://dribbble.com/shots/6101178-Rubber-Range-Picker-Open-Source by Cuberto - 14th March 2019
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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<ExampleScreen> { | |
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: <Widget>[ | |
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; | |
} | |
} | |
/* | |
MIT License | |
Copyright (c) 2019 Cuberto | |
Permission is hereby granted, free of charge, to any person obtaining a copy | |
of this software and associated documentation files (the "Software"), to deal | |
in the Software without restriction, including without limitation the rights | |
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell | |
copies of the Software, and to permit persons to whom the Software is | |
furnished to do so, subject to the following conditions: | |
The above copyright notice and this permission notice shall be included in all | |
copies or substantial portions of the Software. | |
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR | |
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, | |
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE | |
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER | |
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, | |
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE | |
SOFTWARE. | |
*/ |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment