Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save ethanliew/828c0ceaa2e7da22feaa9c992706efb5 to your computer and use it in GitHub Desktop.
Save ethanliew/828c0ceaa2e7da22feaa9c992706efb5 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
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