Build a 2-thumb Range Slider, step by step…

Difficulty: Advanced

Forewords

At time of writing this article, Flutter does not offer any RangeSlider Widget. Requests were opened in both StackOverflow and GitHub-Flutter.

As I needed this Widget for one of my projects, I decided to give it a try.

This article explains, in details, how to build from scratch such Widget, step by step.

Full source code is available on GitHub.

The RangeSlider is also available as a package on Dart Packages, under the name: “flutter_range_slider”.

At the end of this article, you will have read everything to build the following:

RangeSlider RangeSlider

Acknowledgements

This code is very much inspired from the original Flutter Slider Widget (code to be found here).

Also, at time of writing this article, there is no detailed specifications related to Range Sliders available on Google material.io. I tried to stick as much as possible to the idea but sometimes I had to take decisions which might be invalidated by any future specifications defined by Google.

Example of such decision is not to implement the tap to directly select a value since as there are 2 thumbs, it would be ambiguous to determine the intention of the user in terms of which thumb to link to the tap.

Specifications

A RangeSlider supports the following:

  • it allows a user to select a range defined by 2 values (lowerValue and upperValue), between a min and a max
  • 2 modes of functioning need to be foreseen: continuous and discrete
  • 3 callbacks functions need to exist: onChanged, onChangeStart and onChangeEnd
  • it must be possible to use a Theme to customize the look

The architecture of the Widget

The RangeSlider Widget is a StatefulWidget which is rendered as a pure drawing in a Canvas.

In other words, there is no need of any children however, a build method requires a Widget to render.

As there is no children, we will opt for a LeafRenderObjectWidget, which will allow us to define a RenderBox.

This RenderBox gives us all the tools to draw the RangeSlider in a Canvas.

archi


Step 1: Code Skeleton

From this architecture, we derive the following source code skeleton which simply draws the track and the 2 thumbs at each edge of the track (one in red, the other in blue). Explanation is to be found in the code itself.

import 'package:flutter/foundation.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';

class RangeSlider extends StatefulWidget {
  const RangeSlider({
    Key key,
  })  : super(key: key);

  @override
  _RangeSliderState createState() => _RangeSliderState();
}

class _RangeSliderState extends State<RangeSlider> {

  @override
  Widget build(BuildContext context) {
    return new _RangeSliderRenderObjectWidget();
  }
}

/// ------------------------------------------------------
/// Widget that instantiates a RenderObject
/// ------------------------------------------------------
class _RangeSliderRenderObjectWidget extends LeafRenderObjectWidget {
  const _RangeSliderRenderObjectWidget({
    Key key,
  }) : super(key: key);

  @override
  RenderObject createRenderObject(BuildContext context) {
    return new _RenderRangeSlider();
  }
}

/// ------------------------------------------------------
/// Class that renders the RangeSlider as a pure drawing
/// in a Canvas and allows the user to interact.
/// ------------------------------------------------------
class _RenderRangeSlider extends RenderBox {

  /// -----------------------------------------------------------
  /// Global Constants (see Material.io)
  ///
  /// As we already know that we will implement an overlay to 
  /// highlight the thumbnail which is active, we directly
  /// consider the dimensions of the overlay (_overlayRadius)
  /// -----------------------------------------------------------
  static const double _overlayRadius = 16.0;
  static const double _overlayDiameter = _overlayRadius * 2.0;
  static const double _trackHeight = 2.0;
  static const double _preferredTrackWidth = 144.0;
  static const double _preferredTotalWidth = _preferredTrackWidth + 2 * _overlayDiameter;
  static const double _thumbRadius = 6.0;

  /// -------------------------------------------
  /// The size of this RenderBox is defined by
  /// the parent
  /// -------------------------------------------
  @override
  bool get sizedByParent => true;

  /// -------------------------------------------
  /// Update of the RenderBox size using only
  /// the constraints which are provided by
  /// its parent.
  /// Compulsory when sizedByParent returns true
  /// -------------------------------------------
  @override
  void performResize(){
    size = new Size(
      constraints.hasBoundedWidth ? constraints.maxWidth : _preferredTotalWidth,
      constraints.hasBoundedHeight ? constraints.maxHeight : _overlayDiameter,
    );
  }

  /// ------------------------------------------------------------------
  /// Computation of the min,max intrinsic
  /// width and height of the box.
  /// The following 4 methods must be implemented.
  ///
  /// computeMinIntrinsicWidth: minimal width.  Here as there are
  ///                           2 thumbs, enough space to display them
  /// computeMaxIntrinsicWidth: smallest width beyond which increasing
  ///                           the width never decreases the height
  /// computeMinIntrinsicHeight: minimal height.  Diameter of a thumb.
  /// computeMaxIntrinsicHeight: maximal height:  Diameter of a thumb.
  /// ------------------------------------------------------------------
  @override
  double computeMinIntrinsicWidth(double height) {
    return 2 * _overlayDiameter;
  }

  @override
  double computeMaxIntrinsicWidth(double height) {
    return _preferredTotalWidth;
  }

  @override
  double computeMinIntrinsicHeight(double width) {
    return _overlayDiameter;
  }

  @override
  double computeMaxIntrinsicHeight(double width) {
    return _overlayDiameter;
  }

  /// ---------------------------------------------
  /// Paint the Range Slider
  /// ---------------------------------------------
  @override
  void paint(PaintingContext context, Offset offset) {
    final Canvas canvas = context.canvas;

    _paintTrack(canvas, offset);
    _paintThumbs(canvas, offset);
  }

  /// ---------------------------------------------
  /// Paint the track
  ///
  /// The track is vertically centered and takes
  /// as much width as possible, taking into 
  /// consideration the dimensions of the 2 thumbs
  /// at the edges.
  /// ---------------------------------------------
  double trackLength;
  double trackVerticalCenter;
  double trackLeft;
  double trackTop;
  double trackBottom;
  double trackRight;

  void _paintTrack(Canvas canvas, Offset offset){
    final double trackRadius = _trackHeight / 2.0;

    // Compute the track dimensions and position
    trackLength = size.width - 2 * _overlayDiameter;
    trackVerticalCenter = offset.dy + (size.height) / 2.0;
    trackLeft = offset.dx + _overlayDiameter;
    trackTop = trackVerticalCenter - trackRadius;
    trackBottom = trackVerticalCenter + trackRadius;
    trackRight = trackLeft + trackLength;

    // Rectangle that corresponds to the track
    Rect trackLeftRect = new Rect.fromLTRB(trackLeft, trackTop, trackRight, trackBottom);

    // For the moment, paint the track in black
    Paint trackPaint = new Paint()..color = Colors.black;

    // Draw the track
    canvas.drawRect(trackLeftRect, trackPaint);
  }

  /// ---------------------------------------------
  /// Paint the thumbs
  ///
  /// A thumb is nothing but a plain circle.
  /// ---------------------------------------------
  void _paintThumbs(Canvas canvas, Offset offset){
    // Position of the thumbs at each edge of the track
    Offset thumbLowerCenter = new Offset(trackLeft, trackVerticalCenter);
    Offset thumbUpperCenter = new Offset(trackRight, trackVerticalCenter);

    canvas.drawCircle(thumbLowerCenter, _thumbRadius, new Paint()..color = Colors.red);
    canvas.drawCircle(thumbUpperCenter, _thumbRadius, new Paint()..color = Colors.blue);
  }
}

Here is the outcome of this initial source code. skeleton


Step 2: Parameterisation, thumbs positioning and show selected range in track.

In this step, we add the RangeSlider main parameters, position the thumbs according to their initial values (lowerValue, upperValue) and highlight the selected range at the track level.

Parameters

The RangeSlider needs to define the range of possible values as well as the initial values:

Parameter Name Description
min the minimal value of the range. If not defined, we will assume 0.0
max the maximal value of the range. If not defined, we will assume 1.0
lowerValue the initial value of the lowerValue (= left thumb)
upperValue the initial value of the upperValue (= right thumb)

These parameters have to be validated and conveyed down to the _RenderRangeSlider class.

To ease all further calculations for the positioning of the thumbs, we will consider that (min, max) define a range of 100% (where min = 0% and max = 100%). Therefore lowerValue and upperValue will be passed as percentage of that range. This will be achieved through the use of the _unlerp function.

Positioning the thumbs

As mentioned here above, the thumbs position corresponds to a percentage of the track. Therefore, it is straightforward to position them.

Show the selected Range at the track level

The selected range corresponds to the segment of the track between the left thumb and the right thumb. The remainder of the track thus corresponds to the unselected range.

This results in drawing 3 segments:

  • from left edge to the left thumb: unselected range
  • from left thumb to right thumb: selected range
  • from right thumb to the right edge: unselected range

Resulting Code

Modifications to be applied to the code are shown here below:

class RangeSlider extends StatefulWidget {
  const RangeSlider({
    Key key,
    this.min: 0.0,
    this.max: 1.0,
    @required this.lowerValue,
    @required this.upperValue,
  })  : assert(min != null),
        assert(max != null),
        assert(min <= max),
        assert(lowerValue != null),
        assert(upperValue != null),
        assert(lowerValue >= min && lowerValue <= max),
        assert(upperValue > lowerValue && upperValue <= max),
        super(key: key);

  /// The minimum value the user can select.
  ///
  /// Defaults to 0.0. Must be less than or equal to [max].
  final double min;

  /// The maximum value the user can select.
  ///
  /// Defaults to 1.0. Must be greater than or equal to [min].
  final double max;

  /// The currently selected lower value.
  /// 
  /// Corresponds to the left thumb
  /// Must be greater than or equal to [min],
  /// less than or equal to [max]. 
  final double lowerValue;

  /// The currently selected upper value.
  /// 
  /// Corresponds to the right thumb
  /// Must be greater than [lowerValue],
  /// less than or equal to [max]. 
  final double upperValue;

  @override
  _RangeSliderState createState() => _RangeSliderState();
}

class _RangeSliderState extends State<RangeSlider> {

  /// -------------------------------------------------
  /// Returns a value between 0.0 and 1.0
  /// given a value between min and max
  /// -------------------------------------------------
  double _unlerp(double value){
    assert(value <= widget.max);
    assert(value >= widget.min);
    return widget.max > widget.min ? (value - widget.min) / (widget.max - widget.min) : 0.0;
  }

  @override
  Widget build(BuildContext context) {
    return new _RangeSliderRenderObjectWidget(
      lowerValue: _unlerp(widget.lowerValue),
      upperValue: _unlerp(widget.upperValue),
    );
  }
}

/// ------------------------------------------------------
/// Widget that instantiates a RenderObject
/// ------------------------------------------------------
class _RangeSliderRenderObjectWidget extends LeafRenderObjectWidget {
  const _RangeSliderRenderObjectWidget({
    Key key,
    this.lowerValue,
    this.upperValue,
  }) : super(key: key);

  final double lowerValue;
  final double upperValue;

  @override
  RenderObject createRenderObject(BuildContext context) {
    return new _RenderRangeSlider(
      lowerValue: lowerValue,
      upperValue: upperValue,
    );
  }
}

/// ------------------------------------------------------
/// Class that renders the RangeSlider as a pure drawing
/// in a Canvas and allows the user to interact.
/// ------------------------------------------------------
class _RenderRangeSlider extends RenderBox {
  _RenderRangeSlider({
    double lowerValue,
    double upperValue,
  }) {
    // Initialization
    this.lowerValue = lowerValue;
    this.upperValue = upperValue;
  }

  /// -------------------------------------------------
  /// Global Constants. See Material.io 
  /// -------------------------------------------------
  static const double _overlayRadius = 16.0;
  static const double _overlayDiameter = _overlayRadius * 2.0;
  static const double _trackHeight = 2.0;
  static const double _preferredTrackWidth = 144.0;
  static const double _preferredTotalWidth = _preferredTrackWidth + 2 * _overlayDiameter;
  static const double _thumbRadius = 6.0;

  /// -------------------------------------------------
  /// Instance specific properties
  /// -------------------------------------------------
  double _lowerValue;
  double _upperValue;

  /// --------------------------------------------------
  /// Setters
  /// Setters are necessary since we will need
  /// to update the values via the 
  /// _RangeSliderRenderObjectWidget.updateRenderObject
  /// --------------------------------------------------
  set lowerValue(double value){
    assert(value != null && value >= 0.0 && value <= 1.0);
    _lowerValue = value;
  }

  set upperValue(double value){
    assert(value != null && value >= 0.0 && value <= 1.0);
    _upperValue = value;
  }

  /// -------------------------------------------
  /// The size of this RenderBox is defined by
  /// the parent
  /// -------------------------------------------
  @override
  bool get sizedByParent => true;

  /// -------------------------------------------
  /// Update of the RenderBox size using only
  /// the constraints.
  /// Compulsory when sizedByParent returns true
  /// -------------------------------------------
  @override
  void performResize(){
    size = new Size(
      constraints.hasBoundedWidth ? constraints.maxWidth : _preferredTotalWidth,
      constraints.hasBoundedHeight ? constraints.maxHeight : _overlayDiameter,
    );
  }

  /// -------------------------------------------
  /// Computation of the min,max intrinsic
  /// width and height of the box
  /// -------------------------------------------
  @override
  double computeMinIntrinsicWidth(double height) {
    return 2 * _overlayDiameter;
  }

  @override
  double computeMaxIntrinsicWidth(double height) {
    return _preferredTotalWidth;
  }

  @override
  double computeMinIntrinsicHeight(double width) {
    return _overlayDiameter;
  }

  @override
  double computeMaxIntrinsicHeight(double width) {
    return _overlayDiameter;
  }

  /// ---------------------------------------------
  /// Paint the Range Slider
  /// ---------------------------------------------
  @override
  void paint(PaintingContext context, Offset offset) {
    final Canvas canvas = context.canvas;

    _paintTrack(canvas, offset);
    _paintThumbs(canvas, offset);
  }

  /// ---------------------------------------------
  /// Paint the track
  /// ---------------------------------------------
  double trackLength;
  double trackVerticalCenter;
  double trackLeft;
  double trackTop;
  double trackBottom;
  double trackRight;
  double thumbLeftPosition;
  double thumbRightPosition;

  void _paintTrack(Canvas canvas, Offset offset){
    final double trackRadius = _trackHeight / 2.0;

    trackLength = size.width - 2 * _overlayDiameter;
    trackVerticalCenter = offset.dy + (size.height) / 2.0;
    trackLeft = offset.dx + _overlayDiameter;
    trackTop = trackVerticalCenter - trackRadius;
    trackBottom = trackVerticalCenter + trackRadius;
    trackRight = trackLeft + trackLength;

    // Compute the position of the thumbs
    thumbLeftPosition = trackLeft + _lowerValue * trackLength;
    thumbRightPosition = trackLeft + _upperValue * trackLength;

    // Define the paint colors for both unselected and selected track segments
    Paint unselectedTrackPaint = new Paint()..color = Colors.grey[300];
    Paint selectedTrackPaint = new Paint()..color = Colors.green;

    // Draw the track
    if (_lowerValue > 0.0){
      // Draw the unselected left range
      canvas.drawRect(new Rect.fromLTRB(trackLeft, trackTop, thumbLeftPosition, trackBottom), unselectedTrackPaint);
    }
    // Draw the selected range
    canvas.drawRect(new Rect.fromLTRB(thumbLeftPosition, trackTop, thumbRightPosition, trackBottom), selectedTrackPaint);

    if (_upperValue < 1.0){
      // Draw the unselected right range
      canvas.drawRect(new Rect.fromLTRB(thumbRightPosition, trackTop, trackRight, trackBottom), unselectedTrackPaint);
    }
  }

  /// ---------------------------------------------
  /// Paint the thumbs
  /// ---------------------------------------------
  Rect thumbLowerRect;
  Rect thumbUpperRect;

  void _paintThumbs(Canvas canvas, Offset offset){
    Offset thumbLowerCenter = new Offset(thumbLeftPosition, trackVerticalCenter);
    Offset thumbUpperCenter = new Offset(thumbRightPosition, trackVerticalCenter);

    thumbLowerRect = new Rect.fromCircle(center: thumbLowerCenter - offset, radius: _thumbRadius * 2.0);
    thumbUpperRect = new Rect.fromCircle(center: thumbUpperCenter - offset, radius: _thumbRadius * 2.0);

    canvas.drawCircle(thumbLowerCenter, _thumbRadius, new Paint()..color = Colors.red);
    canvas.drawCircle(thumbUpperCenter, _thumbRadius, new Paint()..color = Colors.blue);
  }
}

Here is the outcome of this source code with the following parameters:

  • min: 1.0
  • max: 100.0
  • lowerValue: 10.0
  • upperValue: 70.0

skeleton


Side note

For now on, in order to only point out the necessary part of the code, which was subject to changes, the code that remains unchanged will be skipped and replaced by an ellipsis (…).


Step 3: Allow the user to interact with the thumbs

In this step, we will allow the user to drag the thumbs horizontally and redraw the RangeSlider according to the movements.

Several modifications need to be applied to set up Drag gesture recognition.

HorizontalDragGestureRecognizer

In order to allow the recognition of a Drag gesture, we have to instantiate the HorizontalDragGestureRecognizer class and handle the following:

Callback Description
onStart called when a Drag is starting. Gives the initial position of the Pointer
onUpdate called when the Drag is moving. Gives the delta position since its last call
onEnd called when the Drag ends
onCancel called when the Drag cancels

onStart

When we are starting a Drag gesture, we obtain the Global position of the Pointer. We need to translate this position to a percentage of the track’s length.

onUpdate

When we are dragging the Pointer, we need to translate the delta between the last time this callback was called and the current, into a percentage of the track’s length in order to adapt the new position of the active thumb.

Then, we need to memorize the new value of the active thumb and force a repaint of the RangeSlider canvas. This is done via a call to the markNeedsPaint() method.

onEnd and onCancel

We simply need to reset.

Implement the hitTestSelf

In order to allow interaction with the RenderBox, we need to implement the hitTestSelf

Implement the handleEvent

When an event of type PointerDownEvent is caught, we need to validate that the user points to one of the 2 thumbs.

We catch the PointerDownEvent by implementing the handleEvent method and check that the user pointed one of the 2 thumbs, via the _validateActiveThumb method. If the outcome of this validation is a thumb, we memorize the selected thumb and initiate the Drag by passing the event to it.

Resulting Code

Only the _RenderRangeSlider class needs to be modified. Here are the modifications:

class _RenderRangeSlider extends RenderBox {
  _RenderRangeSlider({
    double lowerValue,
    double upperValue,
  }) {
    // Initialization
    this.lowerValue = lowerValue;
    this.upperValue = upperValue;

    // Initialization of the Drag Gesture Recognizer
    _drag = new HorizontalDragGestureRecognizer()
                ..onStart  = _handleDragStart
                ..onEnd    = _handleDragEnd
                ..onUpdate = _handleDragUpdate
                ..onCancel = _handleDragCancel
                ;
  }

  ...

  /// -------------------------------------------
  /// Mandatory if we want any interaction
  /// -------------------------------------------
  @override
  bool hitTestSelf(Offset position) => true;

  /// ---------------------------------------------
  /// Drag related routines
  /// ---------------------------------------------
  double _currentDragValue = 0.0;
  HorizontalDragGestureRecognizer _drag;

    /// -------------------------------------------
    /// When we start dragging, we need to 
    /// memorize the initial position of the 
    /// pointer, relative to the track.
    /// -------------------------------------------
  void _handleDragStart(DragStartDetails details){
    _currentDragValue = _getValueFromGlobalPosition(details.globalPosition);
  }

    /// -------------------------------------------
    /// When we are dragging, we need to 
    /// consider the delta between the initial
    /// pointer position and the current and
    /// compute the new position of the thumb.
    /// Then, we call the handler of a value change
    /// -------------------------------------------
  void _handleDragUpdate(DragUpdateDetails details){
    final double valueDelta = details.primaryDelta / _trackLength;
    _currentDragValue += valueDelta;

    // we need to limit the movement to the track
    _onRangeChanged(_currentDragValue.clamp(0.0, 1.0));
  }

    /// -------------------------------------------
    /// End or Cancellation of the drag
    /// -------------------------------------------
  void _handleDragEnd(DragEndDetails details){
    _handleDragCancel();
  }
  void _handleDragCancel(){
    _activeThumb = _ActiveThumb.none;
    _currentDragValue = 0.0;
  }

  /// ----------------------------------------------
  /// Handling of a change in the Range selection
  /// ----------------------------------------------
  void _onRangeChanged(double value){
    if (_activeThumb == _ActiveThumb.lowerThumb){
      _lowerValue = value;
    } else {
      _upperValue = value;
    }
    // Force a repaint
    markNeedsPaint();
  }

  /// ----------------------------------------------
  /// Position helper.
  /// Translates the Pointer global position to
  /// a percentage of the track length
  /// ----------------------------------------------
  double _getValueFromGlobalPosition(Offset globalPosition){
    final double visualPosition = (globalToLocal(globalPosition).dx - _overlayDiameter) / _trackLength;

    return visualPosition;
  }

  /// ----------------------------------------------
  /// Event Handling
  /// We need to validate that the pointer hits
  /// a thumb before accepting to initiate a Drag.
  /// ----------------------------------------------
  @override
  void handleEvent(PointerEvent event, BoxHitTestEntry entry){
    if (event is PointerDownEvent){
      _validateActiveThumb(entry.localPosition);

      // If a thumb is active, initiates the GestureDrag
      if (_activeThumb != _ActiveThumb.none){
        _drag.addPointer(event);
        _handleDragStart(new DragStartDetails(globalPosition: event.position));
      }
    }
  }

  /// ----------------------------------------------
  /// Determine whether the user presses a thumb
  /// If yes, activate the thumb
  /// ----------------------------------------------
  _ActiveThumb _activeThumb = _ActiveThumb.none;

  _validateActiveThumb(Offset position){
    if (_thumbLowerRect.contains(position)){
      _activeThumb = _ActiveThumb.lowerThumb;
    } else if (_thumbUpperRect.contains(position)){
      _activeThumb = _ActiveThumb.upperThumb;
    } else {
      _activeThumb = _ActiveThumb.none;
    }
  }
}

enum _ActiveThumb {
  // no thumb is currently active
  none,
  // the lowerThumb is active
  lowerThumb,
  // the upperThumb is active
  upperThumb,
} 

Here is the outcome of this modification.

dragging dragging

With the current implementation, the user may move both thumbs indifferently of the position of the other. In other words, the user could move the left thumb to the right side of the right thumb and vice versa. This is not compatible with the principle of a lowerValue and an upperValue.

We need to enforce the following rules:

  • the maximum range of movement of the left thumb is from the left edge of the track to the position of the right thumb
  • the maximum range of movement of the right thumb is from the left thumb to the right edge of the track.

These maximum ranges will be calculated at the very same time as the validation of the active thumb, inside the _validateActiveThumb method.

The range of movement is then validated at the _handleDragUpdate method.

Here follows the resulting modifications:

  ...

  double _minDragValue;
  double _maxDragValue;

  ...

  void _handleDragUpdate(DragUpdateDetails details){
    final double valueDelta = details.primaryDelta / _trackLength;
    _currentDragValue += valueDelta;

    // we need to limit the movement to possible range of movement
    _onRangeChanged(_currentDragValue.clamp(_minDragValue, _maxDragValue));
  }

  ...

  _validateActiveThumb(Offset position){
    if (_thumbLowerRect.contains(position)){
      _activeThumb = _ActiveThumb.lowerThumb;
      _minDragValue = 0.0;
      _maxDragValue = _upperValue - _thumbRadius * 2.0 / _trackLength;
    } else if (_thumbUpperRect.contains(position)){
      _activeThumb = _ActiveThumb.upperThumb;
      _minDragValue = _lowerValue + _thumbRadius * 2.0 / _trackLength;
      _maxDragValue = 1.0;
    } else {
      _activeThumb = _ActiveThumb.none;
    }
  }

Step 5: Let’s make the Widget return the range of values

Of course, the main objective of the Widget is to allow to obtain the range of values, as defined by the user.

As we here have to deal with 2 values: “lowerValue” and “upperValue”, we have to define a specific callback type:

RangeSliderCallback(double lowerValue, double upperValue)

In order to comply with the official Flutter Slider, we will consider 3 callbacks:

Name Description
onChangeStart When the user starts dragging one of the 2 thumbs
onChanged When the user is dragging one thumb
onChangeEnd When the user terminates the dragging of a thumb

Since the RangeSlider main class __RenderRangeSlider only deals with values between 0.0 and 1.0 (see explanation in Step 2), we will have to convert these values back to the range between min and max. This is done, via the _lerp method.

Let’s now see the changes to apply to the code, to implement these callbacks.

// The new callback type
typedef RangeSliderCallback(double lowerValue, double upperValue);

class RangeSlider extends StatefulWidget {
  const RangeSlider({
    Key key,
    this.min: 0.0,
    this.max: 1.0,
    @required this.lowerValue,
    @required this.upperValue,
    this.onChanged,
    this.onChangeStart,
    this.onChangeEnd,
  })  : assert(min != null),
        assert(max != null),
        assert(min <= max),
        assert(lowerValue != null),
        assert(upperValue != null),
        assert(lowerValue >= min && lowerValue <= max),
        assert(upperValue > lowerValue && upperValue <= max),
        super(key: key);

  ...

  /// Callback to invoke when the user is changing the
  /// values.
  final RangeSliderCallback onChanged;

  /// Callback to invoke when the user starts dragging
  final RangeSliderCallback onChangeStart;

  /// Callback to invoke when the user ends the dragging
  final RangeSliderCallback onChangeEnd;

  ...
}

class _RangeSliderState extends State<RangeSlider> {
  ...
  /// -------------------------------------------------
  /// Returns the number, between min and max
  /// proportional to value, which must be
  /// between 0.0 and 1.0
  /// -------------------------------------------------
  double _lerp(double value){
    assert(value >= 0.0);
    assert(value <= 1.0);
    return value * (widget.max - widget.min) + widget.min;
  }

  /// -------------------------------------------------
  /// Handling of any change applied to lowerValue
  /// and/or upperValue
  /// Invokes the corresponding callback
  /// -------------------------------------------------
  void _handleChanged(double lowerValue, double upperValue){
    if (widget.onChanged is RangeSliderCallback){
      widget.onChanged(_lerp(lowerValue), _lerp(upperValue));
    }
  }
  void _handleChangeStart(double lowerValue, double upperValue){
    if (widget.onChangeStart is RangeSliderCallback){
      widget.onChangeStart(_lerp(lowerValue), _lerp(upperValue));
    }
  }
  void _handleChangeEnd(double lowerValue, double upperValue){
    if (widget.onChangeEnd is RangeSliderCallback){
      widget.onChangeEnd(_lerp(lowerValue), _lerp(upperValue));
    }
  }

  @override
  Widget build(BuildContext context) {
    return new _RangeSliderRenderObjectWidget(
      lowerValue: _unlerp(widget.lowerValue),
      upperValue: _unlerp(widget.upperValue),
      onChanged: _handleChanged,
      onChangeStart: _handleChangeStart,
      onChangeEnd: _handleChangeEnd,
    );
  }
}

class _RangeSliderRenderObjectWidget extends LeafRenderObjectWidget {
  const _RangeSliderRenderObjectWidget({
    Key key,
    this.lowerValue,
    this.upperValue,
    this.onChanged,
    this.onChangeStart,
    this.onChangeEnd,
  }) : super(key: key);

  final RangeSliderCallback onChanged;
  final RangeSliderCallback onChangeStart;
  final RangeSliderCallback onChangeEnd;
  final double lowerValue;
  final double upperValue;

  @override
  RenderObject createRenderObject(BuildContext context) {
    return new _RenderRangeSlider(
      lowerValue: lowerValue,
      upperValue: upperValue,
      onChanged: onChanged,
      onChangeStart: onChangeStart,
      onChangeEnd: onChangeEnd,
    );
  }
}

class _RenderRangeSlider extends RenderBox {
  _RenderRangeSlider({
    double lowerValue,
    double upperValue,
    RangeSliderCallback onChanged,
    RangeSliderCallback onChangeStart,
    RangeSliderCallback onChangeEnd,
  }) {
    // Initialization
    this.lowerValue = lowerValue;
    this.upperValue = upperValue;
    this.onChanged = onChanged;
    this.onChangeStart = onChangeStart;
    this.onChangeEnd = onChangeEnd;

    ...
  }

  ...

  /// -------------------------------------------------
  /// Instance specific properties
  /// -------------------------------------------------
  RangeSliderCallback _onChanged;
  RangeSliderCallback _onChangeStart;
  RangeSliderCallback _onChangeEnd;
  double _lowerValue;
  double _upperValue;
  
  ...

  /// --------------------------------------------------
  /// Setters
  /// Setters are necessary since we will need
  /// to update the values via the 
  /// _RangeSliderRenderObjectWidget.updateRenderObject
  /// --------------------------------------------------
  
  ...

  set onChanged(RangeSliderCallback value){
    _onChanged = value;
  }

  set onChangeStart(RangeSliderCallback value){
    _onChangeStart = value;
  }

  set onChangeEnd(RangeSliderCallback value){
    _onChangeEnd = value;
  }

  ...

    /// -------------------------------------------
    /// When we start dragging, we need to 
    /// memorize the initial position of the 
    /// pointer, relative to the track.
    /// -------------------------------------------
  void _handleDragStart(DragStartDetails details){
    _currentDragValue = _getValueFromGlobalPosition(details.globalPosition);

    // As we are starting to drag, let's invoke the corresponding callback
    _onChangeStart(_lowerValue, _upperValue);
  }

    /// -------------------------------------------
    /// When we are dragging, we need to 
    /// consider the delta between the initial
    /// pointer position and the current and
    /// compute the new position of the thumb.
    /// Then, we call the handler of a value change
    /// -------------------------------------------
  void _handleDragUpdate(DragUpdateDetails details){
    final double valueDelta = details.primaryDelta / _trackLength;
    _currentDragValue += valueDelta;

    // we need to limit the movement to the track
    _onRangeChanged(_currentDragValue.clamp(_minDragValue, _maxDragValue));
  }

    /// -------------------------------------------
    /// End or Cancellation of the drag
    /// -------------------------------------------
  void _handleDragEnd(DragEndDetails details){
    _handleDragCancel();
  }
  void _handleDragCancel(){
    _activeThumb = _ActiveThumb.none;
    _currentDragValue = 0.0;

    // As we have finished with the drag, let's invoke 
    // the appropriate callback
    _onChangeEnd(_lowerValue, _upperValue);
  }

  /// ----------------------------------------------
  /// Handling of a change in the Range selection
  /// ----------------------------------------------
  void _onRangeChanged(double value){
    if (_activeThumb == _ActiveThumb.lowerThumb){
      _lowerValue = value;
    } else {
      _upperValue = value;
    }

    // Invoke the appropriate callback during the drag
    _onChanged(_lowerValue, _upperValue);

    // Force a repaint
    markNeedsPaint();
  }

  ...
}

Here is the outcome of this modification.

dragging dragging


Step 6: Add divisions to the track

In this step, we are going to add some discrete divisions to the track.

To do this, we need to allow the Widget to accept this extra parameter and pass this parameter down to the main class, so that we can draw the divisions.

At the same time, if we set some divisions, this also means that the thumbs will have to stick to the divisions, during the dragging.

Here are the modifications to apply to achieve this (limited to the main class since for the others, it is simply to pass the parameter).

class _RenderRangeSlider extends RenderBox {
  _RenderRangeSlider({
    double lowerValue,
    double upperValue,
    int divisions,
    RangeSliderCallback onChanged,
    RangeSliderCallback onChangeStart,
    RangeSliderCallback onChangeEnd,
  }) {
    // Initialization
    this.divisions = divisions;
    this.lowerValue = lowerValue;
    this.upperValue = upperValue;
    this.onChanged = onChanged;
    this.onChangeStart = onChangeStart;
    this.onChangeEnd = onChangeEnd;

    // Initialization of the Drag Gesture Recognizer
    _drag = new HorizontalDragGestureRecognizer()
                ..onStart  = _handleDragStart
                ..onEnd    = _handleDragEnd
                ..onUpdate = _handleDragUpdate
                ..onCancel = _handleDragCancel
                ;
  }

  /// -------------------------------------------------
  /// Global Constants. See Material.io 
  /// -------------------------------------------------
  ...
  static const double _tickRadius = _trackHeight / 2.0;

  /// -------------------------------------------------
  /// Instance specific properties
  /// -------------------------------------------------
  RangeSliderCallback _onChanged;
  RangeSliderCallback _onChangeStart;
  RangeSliderCallback _onChangeEnd;
  double _lowerValue;
  double _upperValue;
  int _divisions;

  HorizontalDragGestureRecognizer _drag;

  /// --------------------------------------------------
  /// Setters
  /// Setters are necessary since we will need
  /// to update the values via the 
  /// _RangeSliderRenderObjectWidget.updateRenderObject
  /// --------------------------------------------------
  set lowerValue(double value){
    assert(value != null && value >= 0.0 && value <= 1.0);
    _lowerValue = _discretize(value);
  }

  set upperValue(double value){
    assert(value != null && value >= 0.0 && value <= 1.0);
    _upperValue = _discretize(value);
  }

  set divisions(int value){
    _divisions = value;
    
    // If we change the value, we need to repaint
    markNeedsPaint();
  }
  ...

  /// ---------------------------------------------
  /// Paint the Range Slider
  /// ---------------------------------------------
  @override
  void paint(PaintingContext context, Offset offset) {
    final Canvas canvas = context.canvas;

    _paintTrack(canvas, offset);
    if (_divisions != null){
      _paintTickMarks(canvas, offset);
    }
    _paintThumbs(canvas, offset);
  }

  ...

  /// ---------------------------------------------
  /// Paint the tick marks
  /// ---------------------------------------------
  void _paintTickMarks(Canvas canvas, Offset offset){
    final double trackWidth = _trackRight - _trackLeft;
    final double dx = (trackWidth - _trackHeight) / _divisions;

    for (int i=0; i<= _divisions; i++){
      final double left = _trackLeft + i * dx;
      final Offset center = new Offset(left + _tickRadius, _trackTop + _tickRadius);

      canvas.drawCircle(center, _tickRadius, new Paint()..color = Colors.green);
    }
  }

  ...

  /// ----------------------------------------------
  /// Handling of a change in the Range selection
  /// ----------------------------------------------
  void _onRangeChanged(double value){

    // If there are divisions, we need to stick to one
    value = _discretize(value);

    if (_activeThumb == _ActiveThumb.lowerThumb){
      _lowerValue = value;
    } else {
      _upperValue = value;
    }

    // Invoke the appropriate callback during the drag
    _onChanged(_lowerValue, _upperValue);

    // Force a repaint
    markNeedsPaint();
  }

  /// ----------------------------------------------
  /// If there are divisions, values should be
  /// aligned to divisions
  /// ----------------------------------------------
  double _discretize(double value) {
    if (_divisions != null) {
      value = (value * _divisions).round() / _divisions;
    }
    return value;
  }

  ...

  _validateActiveThumb(Offset position){
    if (_thumbLowerRect.contains(position)){
      _activeThumb = _ActiveThumb.lowerThumb;
      _minDragValue = 0.0;
      _maxDragValue = _discretize(_upperValue - _thumbRadius * 2.0 / _trackLength);
    } else if (_thumbUpperRect.contains(position)){
      _activeThumb = _ActiveThumb.upperThumb;
      _minDragValue = _discretize(_lowerValue + _thumbRadius * 2.0 / _trackLength);
      _maxDragValue = 1.0;
    } else {
      _activeThumb = _ActiveThumb.none;
    }
  }
}

Here is the outcome of these changes, if we set a range (min: 0.0, max: 100.0, divisions: 20)

divisions divisions

Well, it looks like we are almost done. However, there are still a couple of things to do, such as:

  • highlight the active thumb, via an animated overlay
  • add an animation when we are activating or deactivating the RangeSlider
  • use a theme for all the colors and thumbs dimensions

Step 7: Adding an animated overlay and activation/deactivation

Highlight the active thumb

The idea is to show the user which thumb is currently being active via an overlay and make it as an animation.

To achieve this, we need to have an AnimationController defined at the level of the _RangeSliderState, then to pass the State down to the main class.

Handle active/inactive Widget

The RangeSlider Widget is to be considered as being active when the onChanged callback is handled ( != null), otherwise inactive.

This results in:

  • when inactive, the user should not be able to interact with the thumbs
  • when we change the state (active vs inactive), we need to animate the thumbs to show the change

Here are the modifications to be applied to the code:

class _RangeSliderState extends State<RangeSlider> with TickerProviderStateMixin {

  // Animation controller that is run when the overlay (a.k.a radial reaction)
  // is shown in response to user interaction.
  AnimationController overlayController;

  // Animation controller that is run when enabling/disabling the slider.
  AnimationController enableController;

  @override
  void initState() {
    super.initState();

    // Initialize the animation controllers
    overlayController = new AnimationController(
      duration: kRadialReactionDuration,
      vsync: this,
    );

    enableController = new AnimationController(
      duration: kEnableAnimationDuration,
      vsync: this,
    );

    // Set the enableController value to active (1 if we are handling callback)
    // or to inactive (0 otherwise)
    enableController.value = widget.onChanged != null ? 1.0 : 0.0;
  }

  @override
  void dispose() {
    // release the animation controllers
    enableController.dispose();
    overlayController.dispose();
    super.dispose();
  }

  ...

  @override
  Widget build(BuildContext context) {
    return new _RangeSliderRenderObjectWidget(
      lowerValue: _unlerp(widget.lowerValue),
      upperValue: _unlerp(widget.upperValue),
      divisions: widget.divisions,
      onChanged: (widget.onChanged != null) ? _handleChanged : null,
      onChangeStart: _handleChangeStart,
      onChangeEnd: _handleChangeEnd,
      state: this,
    );
  }
}

class _RangeSliderRenderObjectWidget extends LeafRenderObjectWidget {
  const _RangeSliderRenderObjectWidget({
    Key key,
    this.lowerValue,
    this.upperValue,
    this.divisions,
    this.onChanged,
    this.onChangeStart,
    this.onChangeEnd,
    this.state,
  }) : super(key: key);

  final _RangeSliderState state;
  final RangeSliderCallback onChanged;
  final RangeSliderCallback onChangeStart;
  final RangeSliderCallback onChangeEnd;
  final double lowerValue;
  final double upperValue;
  final int divisions;

  @override
  RenderObject createRenderObject(BuildContext context) {
    return new _RenderRangeSlider(
      lowerValue: lowerValue,
      upperValue: upperValue,
      divisions: divisions,
      onChanged: onChanged,
      onChangeStart: onChangeStart,
      onChangeEnd: onChangeEnd,
      state: state,
    );
  }
}

class _RenderRangeSlider extends RenderBox {
  _RenderRangeSlider({
    double lowerValue,
    double upperValue,
    int divisions,
    RangeSliderCallback onChanged,
    RangeSliderCallback onChangeStart,
    RangeSliderCallback onChangeEnd,
    @required this.state,
  }) {
    // Initialization
    this.divisions = divisions;
    this.lowerValue = lowerValue;
    this.upperValue = upperValue;
    this.onChanged = onChanged;
    this.onChangeStart = onChangeStart;
    this.onChangeEnd = onChangeEnd;

    // Initialization of the Drag Gesture Recognizer
    _drag = new HorizontalDragGestureRecognizer()
                ..onStart  = _handleDragStart
                ..onEnd    = _handleDragEnd
                ..onUpdate = _handleDragUpdate
                ..onCancel = _handleDragCancel
                ;

    // Initialization of the overlay animation
    _overlayAnimation = new CurvedAnimation(
      parent: state.overlayController,
      curve: Curves.fastOutSlowIn,
    );

    // Initialization of the enable/disable animation
    _enableAnimation = new CurvedAnimation(
      parent: state.enableController,
      curve: Curves.easeInOut,
    );
  }

  /// -------------------------------------------------
  /// Global Constants. See Material.io 
  /// -------------------------------------------------
  
  ...
  static final Tween<double> _overlayRadiusTween = new Tween<double>(begin: 0.0, end: _overlayRadius);

  /// -------------------------------------------------
  /// Instance specific properties
  /// -------------------------------------------------
  
  ...
  Animation<double> _overlayAnimation;
  Animation<double> _enableAnimation;
  _RangeSliderState state;

  ...

  /// --------------------------------------------------
  /// Setters
  /// Setters are necessary since we will need
  /// to update the values via the 
  /// _RangeSliderRenderObjectWidget.updateRenderObject
  /// --------------------------------------------------
  ...

  set onChanged(RangeSliderCallback value){
    // If no changes were applied, skip
    if (_onChanged == value){
      return;
    }
  
    // Were we handling callbacks?
    final bool wasInteractive = isInteractive;

    // Record the new callback
    _onChanged = value;

    // Did we change the callbacks
    if (wasInteractive != isInteractive){
      if (isInteractive){
        state.enableController.forward();
      } else {
        state.enableController.reverse();
      }

      // As we apply a change, we need to redraw
      markNeedsPaint();
    }
  }
  ...

  /// ----------------------------------------------
  /// Are we handling callbacks?
  /// ----------------------------------------------
  bool get isInteractive => (_onChanged != null);

  /// --------------------------------------------
  /// We need to repaint
  /// we are dragging and changing the activation
  /// --------------------------------------------
  @override
  void attach(PipelineOwner owner){
    super.attach(owner);
    _overlayAnimation.addListener(markNeedsPaint);
    _enableAnimation.addListener(markNeedsPaint);
  }

  @override
  void detach(){
    _overlayAnimation.removeListener(markNeedsPaint);
    _enableAnimation.removeListener(markNeedsPaint);
    super.detach();
  }

  ...

  /// ---------------------------------------------
  /// Paint the Range Slider
  /// ---------------------------------------------
  @override
  void paint(PaintingContext context, Offset offset) {
    final Canvas canvas = context.canvas;

    _paintTrack(canvas, offset);
    _paintOverlay(canvas);
    if (_divisions != null){
      _paintTickMarks(canvas, offset);
    }
    _paintThumbs(canvas, offset);
  }

  ...

  /// ---------------------------------------------
  /// Paint the overlay
  /// ---------------------------------------------
  void _paintOverlay(Canvas canvas){
    if (!_overlayAnimation.isDismissed && _activeThumb != _ActiveThumb.none){
      final Paint overlayPaint = new Paint()..color = Colors.red[100];
      final double radius = _overlayRadiusTween.evaluate(_overlayAnimation);

      // We need to find the position of the overlay % active thumb
      Offset center;
      if (_activeThumb == _ActiveThumb.lowerThumb){
        center = new Offset(_thumbLeftPosition, _trackVerticalCenter);
      } else {
        center = new Offset(_thumbRightPosition, _trackVerticalCenter);
      }

      canvas.drawCircle(center, radius, overlayPaint);
    }
  }

  ...

  void _handleDragStart(DragStartDetails details){
    _currentDragValue = _getValueFromGlobalPosition(details.globalPosition);

    // As we are starting to drag, let's invoke the corresponding callback
    _onChangeStart(_lowerValue, _upperValue);

    // Show the overlay
    state.overlayController.forward();
  }
  
  ...

  void _handleDragCancel(){
    _activeThumb = _ActiveThumb.none;
    _currentDragValue = 0.0;

    // As we have finished with the drag, let's invoke 
    // the appropriate callback
    _onChangeEnd(_lowerValue, _upperValue);

    // Hide the overlay
    state.overlayController.reverse();
  }

  ...

  @override
  void handleEvent(PointerEvent event, BoxHitTestEntry entry){
    if (event is PointerDownEvent && isInteractive){
      _validateActiveThumb(entry.localPosition);

      // If a thumb is active, initiates the GestureDrag
      if (_activeThumb != _ActiveThumb.none){
        _drag.addPointer(event);
        _handleDragStart(new DragStartDetails(globalPosition: event.position));
      }
    }
  }

  ...
}

Here is the outcome of this modification.

overlay overlay


Step 8: Use a Theme

The Flutter Slider Widget already uses a Theme, called: SliderTheme which definition gives SliderThemeData.

How can we do this?

List of parameters to use from the SliderTheme

Name Description
activeTrackColor Color of the track showing the selected range
inactiveTrackColor Color of the track showing the non selected ranges
activeTickMarkColor Color of a tick mark (see divisions)
overlayColor Color of the overlay
thumbShape Routine to draw a thumb
thumbColor Color to be used by the thumbShape to draw the thumb
thumbShape.getPreferredSize Dimensions of a thumb

Explanation on the modifications to be applied

The fact of enabling the theming requires a series of modifications to be applied, such as:

  • At the RangeSliderState, build method, we need to retrieve the SliderTheme from the current context, and pass this down to the main class;
  • At the main _RenderRangeSlider class, we need to:
    • accept this parameter and force a repaint when it changes;
    • obtain the thumbRadius from the SliderTheme;
    • define the Intrinsic dimensions from the SliderTheme;
    • draw the track with the colors, defined by the SliderTheme;
    • draw the overlay with the color, defined by the SliderTheme;
    • draw the tickMarks with the color, defined by the SliderTheme;
    • use the thumbShape, defined by the SliderTheme, to draw the thumbs;

Here are the modifications to be applied to the code:

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
class _RangeSliderState extends State<RangeSlider> with TickerProviderStateMixin {
...
  @override
  Widget build(BuildContext context) {
    return new _RangeSliderRenderObjectWidget(
      lowerValue: _unlerp(widget.lowerValue),
      upperValue: _unlerp(widget.upperValue),
      divisions: widget.divisions,
      onChanged: (widget.onChanged != null) ? _handleChanged : null,
      onChangeStart: _handleChangeStart,
      onChangeEnd: _handleChangeEnd,
      sliderTheme: SliderTheme.of(context),
      state: this,
    );
  }
}

class _RangeSliderRenderObjectWidget extends LeafRenderObjectWidget {
  const _RangeSliderRenderObjectWidget({
    Key key,
    this.lowerValue,
    this.upperValue,
    this.divisions,
    this.onChanged,
    this.onChangeStart,
    this.onChangeEnd,
    this.sliderTheme,
    this.state,
  }) : super(key: key);

  ...
  final SliderThemeData sliderTheme;
  ...

  @override
  RenderObject createRenderObject(BuildContext context) {
    return new _RenderRangeSlider(
      lowerValue: lowerValue,
      upperValue: upperValue,
      divisions: divisions,
      onChanged: onChanged,
      onChangeStart: onChangeStart,
      onChangeEnd: onChangeEnd,
      sliderTheme: sliderTheme,
      state: state,
    );
  }

  @override
  void updateRenderObject(BuildContext context, _RenderRangeSlider renderObject) {
    renderObject
      ..lowerValue = lowerValue
      ..upperValue = upperValue
      ..divisions = divisions
      ..onChanged = onChanged
      ..onChangeStart = onChangeStart
      ..onChangeEnd = onChangeEnd
      ..sliderTheme = sliderTheme;
  }
}

class _RenderRangeSlider extends RenderBox {
  _RenderRangeSlider({
    double lowerValue,
    double upperValue,
    int divisions,
    RangeSliderCallback onChanged,
    RangeSliderCallback onChangeStart,
    RangeSliderCallback onChangeEnd,
    SliderThemeData sliderTheme,
    @required this.state,
  }) {
    // Initialization
    ...
    this.sliderTheme = sliderTheme;
    ...
  }

  ...

  SliderThemeData _sliderTheme;

  ...

  set sliderTheme(SliderThemeData value){
    assert (value != null);
    _sliderTheme = value;

    // If we change the theme, we need to repaint
    markNeedsPaint();
  }

  ...

  /// ----------------------------------------------
  /// Obtain the radius of a thumb from the Theme
  /// ----------------------------------------------
  double get _thumbRadius {
    final Size preferredSize = _sliderTheme.thumbShape.getPreferredSize(isInteractive, (_divisions != null));
    return math.max(preferredSize.width, preferredSize.height) / 2.0;
  }

  ...

  /// -------------------------------------------
  /// Computation of the min,max intrinsic
  /// width and height of the box
  /// -------------------------------------------
  @override
  double computeMinIntrinsicWidth(double height) {
    return 2 * math.max(
        _overlayDiameter,
        _sliderTheme.thumbShape.getPreferredSize(true, (_divisions != null)).width
    );
  }

  @override
  double computeMaxIntrinsicWidth(double height) {
    return _preferredTotalWidth;
  }

  @override
  double computeMinIntrinsicHeight(double width) {
    return math.max(
      _overlayDiameter, 
      _sliderTheme.thumbShape.getPreferredSize(true, (_divisions != null)).height
    );
  }

  @override
  double computeMaxIntrinsicHeight(double width) {
    return math.max(
      _overlayDiameter, 
      _sliderTheme.thumbShape.getPreferredSize(true, (_divisions != null)).height
    );
  }

  ...

  /// ---------------------------------------------
  /// Paint the Range Slider
  /// ---------------------------------------------
  @override
  void paint(PaintingContext context, Offset offset) {
    ...
    _paintThumbs(context, offset);
  }

  ...

  void _paintTrack(Canvas canvas, Offset offset){
    
    ...

    // Define the paint colors for both unselected and selected track segments
    Paint unselectedTrackPaint = new Paint()..color = _sliderTheme.inactiveTrackColor;
    Paint selectedTrackPaint = new Paint()..color = _sliderTheme.activeTrackColor;

    ...
  }

  /// ---------------------------------------------
  /// Paint the overlay
  /// ---------------------------------------------
  void _paintOverlay(Canvas canvas){
    if (!_overlayAnimation.isDismissed && _activeThumb != _ActiveThumb.none){
      final Paint overlayPaint = new Paint()..color = _sliderTheme.overlayColor;

      ...

    }
  }

  /// ---------------------------------------------
  /// Paint the tick marks
  /// ---------------------------------------------
  void _paintTickMarks(Canvas canvas, Offset offset){
    final double trackWidth = _trackRight - _trackLeft;
    final double dx = (trackWidth - _trackHeight) / _divisions;

    for (int i=0; i<= _divisions; i++){
      final double left = _trackLeft + i * dx;
      final Offset center = new Offset(left + _tickRadius, _trackTop + _tickRadius);

      canvas.drawCircle(center, _tickRadius, new Paint()..color = _sliderTheme.activeTickMarkColor);
    }
  }
  
  ...

  void _paintThumbs(PaintingContext context, Offset offset){
    final Offset thumbLowerCenter = new Offset(_thumbLeftPosition, _trackVerticalCenter);
    final Offset thumbUpperCenter = new Offset(_thumbRightPosition, _trackVerticalCenter);
    final double thumbRadius = _thumbRadius;

    _thumbLowerRect = new Rect.fromCircle(center: thumbLowerCenter - offset, radius: thumbRadius);
    _thumbUpperRect = new Rect.fromCircle(center: thumbUpperCenter - offset, radius: thumbRadius);

    // Paint the thumbs, via the Theme
    _sliderTheme.thumbShape.paint(
      context,
      thumbLowerCenter,
      isDiscrete: (_divisions != null),
      parentBox: this,
      sliderTheme: _sliderTheme,
      value: _lowerValue,
      enableAnimation: _enableAnimation,
    );

    _sliderTheme.thumbShape.paint(
      context,
      thumbUpperCenter,
      isDiscrete: (_divisions != null),
      parentBox: this,
      sliderTheme: _sliderTheme,
      value: _upperValue,
      enableAnimation: _enableAnimation,
    );
  }

  ...
}

This is what the RangeSlider now looks like.

theme theme


Step 9: ValueIndicator

What could we still do now?

If we want to comply with the official Slider Widget, we still need to:

  • display a label on top of the active thumb when the user is dragging it so that the user may see the value of the thumb while dragging it;

I will deviate a little bit from the original Slider implementation, so that the RangeSlider will:

  • accept a showValueIndicator boolean parameter;
  • accept a valueIndicatorMaxDecimals integer parameter to round the active value to a certain max number of decimals.

Here is the explanation on the changes to be applied…

RangeSlider

2 new parameters:

Parameter Type Default value Description
showValueIndicator bool false accepts or not to display a valueIndicator label above the active thumb while being dragged. Even if set to true, this parameter can be overridden by the SliderTheme showValueIndicator property
valueIndicatorMaxDecimals int 1 max number of decimals to be used to display the value
_RangeSliderState

We need to add a new AnimationController to animate the appearance of the ValueIndicator label.

Also, as in the main class, we will have to display the “real” value (and not the working value which ranges 0.0 to 1.0), we will need to expose the lerp method.

_RangeSliderRenderObjectWidget

We simply need to convey these 2 new parameters down to the main class.

_RenderRangeSlider

Several things needs to be done at this level… We need:

  • a new Animation ( = _valueIndicatorAnimation) to animate the transition of the valueIndicator label (from hidden to visible and vice versa);
  • a TextPainter instance which will be used by the SliderTheme to display the valueIndicator label;
  • obtain from the SliderTheme whether to show or not the valueIndicator besides the value, passed to the RangeSlider widget;
  • paint the valueIndicator

Here are the modifications to be applied to the code:

class RangeSlider extends StatefulWidget {
  const RangeSlider({
    Key key,
    this.min: 0.0,
    this.max: 1.0,
    this.divisions,
    @required this.lowerValue,
    @required this.upperValue,
    this.onChanged,
    this.onChangeStart,
    this.onChangeEnd,
    this.showValueIndicator: false,
    this.valueIndicatorMaxDecimals: 1,
  })  : assert(min != null),
        assert(max != null),
        assert(min <= max),
        assert(divisions == null || divisions > 0),
        assert(lowerValue != null),
        assert(upperValue != null),
        assert(lowerValue >= min && lowerValue <= max),
        assert(upperValue >= lowerValue && upperValue <= max),
        assert(valueIndicatorMaxDecimals >= 0 && valueIndicatorMaxDecimals < 5),
        super(key: key);

  /// Do we show a label above the active thumb when
  /// the RangeSlider is active ?
  final bool showValueIndicator;

  /// Max number of decimals when displaying
  /// the value in the label above the active
  /// thumb
  final int valueIndicatorMaxDecimals;

  ...
}

class _RangeSliderState extends State<RangeSlider> with TickerProviderStateMixin {
  static const Duration kEnableAnimationDuration = const Duration(milliseconds: 75);
  static const Duration kValueIndicatorAnimationDuration = const Duration(milliseconds: 100);

  ...

  // Animation controller that is run when the value indicator is being shown
  // or hidden.
  AnimationController valueIndicatorController;

  @override
  void initState() {
    super.initState();

    ...

    valueIndicatorController = new AnimationController(
      duration: kValueIndicatorAnimationDuration,
      vsync: this,
    );

    ...
  }

  @override
  void dispose() {
    // release the animation controllers
    valueIndicatorController.dispose();
    enableController.dispose();
    overlayController.dispose();
    super.dispose();
  }

  ...

  /// -------------------------------------------------
  /// Returns the number, between min and max
  /// proportional to value, which must be
  /// between 0.0 and 1.0
  /// -------------------------------------------------
  double lerp(double value){
    assert(value >= 0.0);
    assert(value <= 1.0);
    return value * (widget.max - widget.min) + widget.min;
  }

  /// -------------------------------------------------
  /// Handling of any change applied to lowerValue
  /// and/or upperValue
  /// Invokes the corresponding callback
  /// -------------------------------------------------
  void _handleChanged(double lowerValue, double upperValue){
    if (widget.onChanged is RangeSliderCallback){
      widget.onChanged(lerp(lowerValue), lerp(upperValue));
    }
  }
  void _handleChangeStart(double lowerValue, double upperValue){
    if (widget.onChangeStart is RangeSliderCallback){
      widget.onChangeStart(lerp(lowerValue), lerp(upperValue));
    }
  }
  void _handleChangeEnd(double lowerValue, double upperValue){
    if (widget.onChangeEnd is RangeSliderCallback){
      widget.onChangeEnd(lerp(lowerValue), lerp(upperValue));
    }
  }

  @override
  Widget build(BuildContext context) {
    return new _RangeSliderRenderObjectWidget(
      lowerValue: _unlerp(widget.lowerValue),
      upperValue: _unlerp(widget.upperValue),
      divisions: widget.divisions,
      onChanged: (widget.onChanged != null) ? _handleChanged : null,
      onChangeStart: _handleChangeStart,
      onChangeEnd: _handleChangeEnd,
      sliderTheme: SliderTheme.of(context),
      state: this,
      showValueIndicator: widget.showValueIndicator,
      valueIndicatorMaxDecimals: widget.valueIndicatorMaxDecimals,
    );
  }
}

/// ------------------------------------------------------
/// Widget that instantiates a RenderObject
/// ------------------------------------------------------
class _RangeSliderRenderObjectWidget extends LeafRenderObjectWidget {
  const _RangeSliderRenderObjectWidget({
    Key key,
    this.lowerValue,
    this.upperValue,
    this.divisions,
    this.onChanged,
    this.onChangeStart,
    this.onChangeEnd,
    this.sliderTheme,
    this.state,
    this.showValueIndicator,
    this.valueIndicatorMaxDecimals,
  }) : super(key: key);

  final _RangeSliderState state;
  final RangeSliderCallback onChanged;
  final RangeSliderCallback onChangeStart;
  final RangeSliderCallback onChangeEnd;
  final SliderThemeData sliderTheme;
  final double lowerValue;
  final double upperValue;
  final int divisions;
  final bool showValueIndicator;
  final int valueIndicatorMaxDecimals;

  @override
  RenderObject createRenderObject(BuildContext context) {
    return new _RenderRangeSlider(
      lowerValue: lowerValue,
      upperValue: upperValue,
      divisions: divisions,
      onChanged: onChanged,
      onChangeStart: onChangeStart,
      onChangeEnd: onChangeEnd,
      sliderTheme: sliderTheme,
      state: state,
      showValueIndicator: showValueIndicator,
      valueIndicatorMaxDecimals: valueIndicatorMaxDecimals,
    );
  }

  @override
  void updateRenderObject(BuildContext context, _RenderRangeSlider renderObject) {
    renderObject
      ..lowerValue = lowerValue
      ..upperValue = upperValue
      ..divisions = divisions
      ..onChanged = onChanged
      ..onChangeStart = onChangeStart
      ..onChangeEnd = onChangeEnd
      ..sliderTheme = sliderTheme
      ..showValueIndicator = showValueIndicator
      ..valueIndicatorMaxDecimals = valueIndicatorMaxDecimals;
  }
}

/// ------------------------------------------------------
/// Class that renders the RangeSlider as a pure drawing
/// in a Canvas and allows the user to interact.
/// ------------------------------------------------------
class _RenderRangeSlider extends RenderBox {
  _RenderRangeSlider({
    double lowerValue,
    double upperValue,
    int divisions,
    RangeSliderCallback onChanged,
    RangeSliderCallback onChangeStart,
    RangeSliderCallback onChangeEnd,
    SliderThemeData sliderTheme,
    @required this.state,
    bool showValueIndicator,
    int valueIndicatorMaxDecimals,
  }) {
    // Initialization

    ...

    this.showValueIndicator = showValueIndicator;
    this.valueIndicatorMaxDecimals = valueIndicatorMaxDecimals;

    ...

    // Initialization of the animation to show the value indicator
    _valueIndicatorAnimation = new CurvedAnimation(
      parent: state.valueIndicatorController,
      curve: Curves.fastOutSlowIn,
    );
  }

  ...

  bool _showValueIndicator;
  int _valueIndicatorMaxDecimals;
  final TextPainter _valueIndicatorPainter = new TextPainter();

  ...

  set showValueIndicator(bool value){
    // Skip if no changes
    if (value == _showValueIndicator){
      return;
    }
    _showValueIndicator = value;

    // Force a repaint of the value indicator
    _updateValueIndicatorPainter();
  }

  set valueIndicatorMaxDecimals(int value){
    // Skip if no changes
    if (value == _valueIndicatorMaxDecimals){
      return;
    }

    _valueIndicatorMaxDecimals = value;

    // Force a repaint
    markNeedsPaint();
  }

  ...

  /// ----------------------------------------------
  /// Get from the SliderTheme the right to show
  /// the value indicator unless said otherwise
  /// ----------------------------------------------
  bool get showValueIndicator {
    bool showValueIndicator;
    switch (_sliderTheme.showValueIndicator) {
      case ShowValueIndicator.onlyForDiscrete:
        showValueIndicator = (_divisions != null);
        break;
      case ShowValueIndicator.onlyForContinuous:
        showValueIndicator = (_divisions == null);
        break;
      case ShowValueIndicator.always:
        showValueIndicator = true;
        break;
      case ShowValueIndicator.never:
        showValueIndicator = false;
        break;
    }
    return (showValueIndicator && _showValueIndicator);
  }

  /// --------------------------------------------
  /// Update the value indicator painter, based
  /// on the SliderTheme
  /// --------------------------------------------
  void _updateValueIndicatorPainter(){
    if (_showValueIndicator != false){
      _valueIndicatorPainter
        ..text = new TextSpan(
          style: _sliderTheme.valueIndicatorTextStyle,
          text: ''
        )
        ..textDirection = TextDirection.ltr
        ..layout();
    } else {
      _valueIndicatorPainter.text = null;
    }

    // Force a re-layout
    markNeedsLayout();
  }

  @override
  void attach(PipelineOwner owner){
    super.attach(owner);
    _overlayAnimation.addListener(markNeedsPaint);
    _enableAnimation.addListener(markNeedsPaint);
    _valueIndicatorAnimation.addListener(markNeedsPaint);
  }

  @override
  void detach(){
    _valueIndicatorAnimation.removeListener(markNeedsPaint);
    _enableAnimation.removeListener(markNeedsPaint);
    _overlayAnimation.removeListener(markNeedsPaint);
    super.detach();
  }

  ...
  
  @override
  void paint(PaintingContext context, Offset offset) {
    final Canvas canvas = context.canvas;

    _paintTrack(canvas, offset);
    _paintOverlay(canvas);
    if (_divisions != null){
      _paintTickMarks(canvas, offset);
    }
    _paintValueIndicator(context);
    _paintThumbs(context, offset);
  }
  
  ...

  void _paintOverlay(Canvas canvas){
    if (!_overlayAnimation.isDismissed && _previousActiveThumb != _ActiveThumb.none){
      
      ...

    }
  }

  ...

  void _paintThumbs(PaintingContext context, Offset offset){

    ...

    // Paint the thumbs, via the Theme
    _sliderTheme.thumbShape.paint(
      context,
      thumbLowerCenter,
      isDiscrete: (_divisions != null),
      parentBox: this,
      sliderTheme: _sliderTheme,
      value: _lowerValue,
      enableAnimation: _enableAnimation,
      activationAnimation: _valueIndicatorAnimation,
      labelPainter: _valueIndicatorPainter,
    );

    _sliderTheme.thumbShape.paint(
      context,
      thumbUpperCenter,
      isDiscrete: (_divisions != null),
      parentBox: this,
      sliderTheme: _sliderTheme,
      value: _upperValue,
      enableAnimation: _enableAnimation,
      activationAnimation: _valueIndicatorAnimation,
      labelPainter: _valueIndicatorPainter,
    );
  }

  /// ---------------------------------------------
  /// Paint the value indicator
  /// ---------------------------------------------
  void _paintValueIndicator(PaintingContext context){
    if (isInteractive && _showValueIndicator && _previousActiveThumb != _ActiveThumb.none) {
      if (_valueIndicatorAnimation.status != AnimationStatus.dismissed && showValueIndicator){

        // We need to find the position of the value indicator % active thumb
        // as well as the value to be displayed
        Offset thumbCenter;
        double value;
        String textValue;

        if (_previousActiveThumb == _ActiveThumb.lowerThumb){
          thumbCenter = new Offset(_thumbLeftPosition, _trackVerticalCenter);
          value = _lowerValue;
        } else {
          thumbCenter = new Offset(_thumbRightPosition, _trackVerticalCenter);
          value = _upperValue;
        }

        // Adapt the value to be displayed to the max number of decimals
        // as well as convert it to the initial range (min, max)
        value = state.lerp(value);
        textValue = value.toStringAsFixed(_valueIndicatorMaxDecimals);

        // Adapt the value indicator with the active thumb value
        _valueIndicatorPainter
          ..text = new TextSpan(
            style: _sliderTheme.valueIndicatorTextStyle,
            text: textValue,
          )
          ..layout();

        // Ask the SliderTheme to paint the valueIndicator
        _sliderTheme.valueIndicatorShape.paint(
          context,
          thumbCenter,
          activationAnimation: _valueIndicatorAnimation,
          enableAnimation: _enableAnimation,
          isDiscrete: (_divisions != null),
          labelPainter: _valueIndicatorPainter,
          parentBox: this,
          sliderTheme: _sliderTheme,
          value: value,
        );
      }
    }
  }

  ...

    /// -------------------------------------------
    /// When we start dragging, we need to 
    /// memorize the initial position of the 
    /// pointer, relative to the track.
    /// -------------------------------------------
  void _handleDragStart(DragStartDetails details){

    ...

    // Show the value indicator
    if (showValueIndicator){
      state.valueIndicatorController.forward();
    }
  }
  
  ...

  void _handleDragCancel(){
    _previousActiveThumb = _activeThumb;

    ...

    // Hide the value indicator
    if (showValueIndicator){
      state.valueIndicatorController.reverse();
    }
  }

  ...

  /// ----------------------------------------------
  /// Determine whether the user presses a thumb
  /// If yes, activate the thumb
  /// ----------------------------------------------
  _ActiveThumb _activeThumb = _ActiveThumb.none;
  _ActiveThumb _previousActiveThumb = _ActiveThumb.none;

  _validateActiveThumb(Offset position){
    ...
    _previousActiveThumb = _activeThumb;
  }
}

This is what the RangeSlider now looks like.

RangeSlider RangeSlider


How to apply changes to the SliderTheme?

Along this article, I mentioned several times that I wanted the RangeSlider to use the SliderTheme but I haven’t explained yet, how to use it and most important, how to customize it.

The easiest way to force a Slider or now, a RangeSlider to be customized with a theme is to wrap them by a SliderTheme !

  Widget build(BuildContext context){
    return new Container(
      child: new SliderTheme(
        data: SliderTheme.of(context).copyWith(
          ...{put the customization here}...
        ),
        child: new RangeSlider(
          ...
        ),
      ),
    );
  }

The following sample shows a series of customized RangeSlider, together with an example of how to use them :

/// ---------------------------------------------------
/// Helper class aimed at simplifying the way to
/// automate the creation of a series of RangeSliders,
/// based on various parameters
/// 
/// This class is to be used to demonstrate the Widget
/// customization
/// ---------------------------------------------------
class RangeSliderData {
  double min;
  double max;
  double lowerValue;
  double upperValue;
  int divisions;
  bool showValueIndicator;
  int valueIndicatorMaxDecimals;
  bool forceValueIndicator;
  Color overlayColor;
  Color activeTrackColor;
  Color inactiveTrackColor;
  Color thumbColor;
  Color valueIndicatorColor;
  Color activeTickMarkColor;

  static const Color defaultActiveTrackColor = const Color(0xFF0175c2);
  static const Color defaultInactiveTrackColor = const Color(0x3d0175c2);
  static const Color defaultActiveTickMarkColor = const Color(0x8a0175c2);
  static const Color defaultThumbColor = const Color(0xFF0175c2);
  static const Color defaultValueIndicatorColor = const Color(0xFF0175c2);
  static const Color defaultOverlayColor = const Color(0x290175c2);

  RangeSliderData({
    this.min,
    this.max,
    this.lowerValue,
    this.upperValue,
    this.divisions,
    this.showValueIndicator: true,
    this.valueIndicatorMaxDecimals: 1,
    this.forceValueIndicator: false,
    this.overlayColor: defaultOverlayColor,
    this.activeTrackColor: defaultActiveTrackColor,
    this.inactiveTrackColor: defaultInactiveTrackColor,
    this.thumbColor: defaultThumbColor,
    this.valueIndicatorColor: defaultValueIndicatorColor,
    this.activeTickMarkColor: defaultActiveTickMarkColor,
  });

  /// Returns the values in text format, with the number
  /// of decimals, limited to the valueIndicatedMaxDecimals
  /// 
  String get lowerValueText =>
      lowerValue.toStringAsFixed(valueIndicatorMaxDecimals);
  String get upperValueText =>
      upperValue.toStringAsFixed(valueIndicatorMaxDecimals);

  /// Builds a RangeSlider and customizes the theme
  /// based on parameters
  /// 
  Widget build(BuildContext context, RangeSliderCallback callback) {
    return new Container(
      width: double.infinity,
      child: new Row(
        children: <Widget>[
          new Container(
            constraints: new BoxConstraints(
              minWidth: 40.0,
              maxWidth: 40.0,
            ),
            child: new Text(lowerValueText),
          ),
          new Expanded(
            child: new SliderTheme(
              // Customization of the SliderTheme
              data: SliderTheme.of(context).copyWith(
                    overlayColor: overlayColor,
                    activeTickMarkColor: activeTickMarkColor,
                    activeTrackColor: activeTrackColor,
                    inactiveTrackColor: inactiveTrackColor,
                    thumbColor: thumbColor,
                    valueIndicatorColor: valueIndicatorColor,
                    showValueIndicator: showValueIndicator
                        ? ShowValueIndicator.always
                        : ShowValueIndicator.onlyForDiscrete,
                  ),
              child: new RangeSlider(
                min: min,
                max: max,
                lowerValue: lowerValue,
                upperValue: upperValue,
                divisions: divisions,
                showValueIndicator: showValueIndicator,
                valueIndicatorMaxDecimals: valueIndicatorMaxDecimals,
                onChanged: (double lower, double upper) {
                  callback(lower, upper);
                },
              ),
            ),
          ),
          new Container(
            constraints: new BoxConstraints(
              minWidth: 40.0,
              maxWidth: 40.0,
            ),
            child: new Text(upperValueText),
          ),
        ],
      ),
    );
  }
}

class RangeSliderSample extends StatefulWidget {
  @override
  _RangeSliderSampleState createState() => _RangeSliderSampleState();
}

class _RangeSliderSampleState extends State<RangeSliderSample> {
  /// List of RangeSliders to use, together with their parameters
  List<RangeSliderData> rangeSliders = <RangeSliderData>[
    RangeSliderData(min: 0.0, max: 100.0, lowerValue: 10.0, upperValue: 100.0),
    RangeSliderData(
        min: 0.0,
        max: 100.0,
        lowerValue: 25.0,
        upperValue: 75.0,
        divisions: 20,
        overlayColor: Colors.red[100]),
    RangeSliderData(
        min: 0.0,
        max: 100.0,
        lowerValue: 10.0,
        upperValue: 30.0,
        showValueIndicator: false,
        valueIndicatorMaxDecimals: 0),
    RangeSliderData(
        min: 0.0,
        max: 100.0,
        lowerValue: 10.0,
        upperValue: 30.0,
        showValueIndicator: true,
        valueIndicatorMaxDecimals: 0,
        activeTrackColor: Colors.red,
        inactiveTrackColor: Colors.red[50],
        valueIndicatorColor: Colors.green),
    RangeSliderData(
        min: 0.0,
        max: 100.0,
        lowerValue: 25.0,
        upperValue: 75.0,
        divisions: 20,
        thumbColor: Colors.grey,
        valueIndicatorColor: Colors.grey),
  ];

  @override
  Widget build(BuildContext context) {
    /// Builds the list of RangeSliders
    List<Widget> children = <Widget>[];
    for (int index = 0; index < rangeSliders.length; index++) {
      children.add(rangeSliders[index].build(
        context, 
        (double lower, double upper) {
          setState(() {
            rangeSliders[index].lowerValue = lower;
            rangeSliders[index].upperValue = upper;
          });
        }
      ));
      // Add an extra padding at the bottom of each RangeSlider
      children.add(new SizedBox(height: 8.0));
    }

    return new SafeArea(
      top: false,
      bottom: false,
      child: new Scaffold(
        appBar: new AppBar(title: new Text('RangeSlider Demo')),
        body: new Container(
          padding: const EdgeInsets.only(top: 50.0, left: 10.0, right: 10.0),
          child: new Column(
            children: children,
          ),
        ),
      ),
    );
  }
}

RangeSlider RangeSlider


Conclusions

I tried to explain, step by step, a way of developping from scratch a RangeSlider.

I am aware that there are still things to do (Semantics, TextDirection…) but this was not in my direct objectives.

I hope you enjoyed reading this article. Stay tuned for the next ones and, as usual, happy coding.