Construire un Range Slider de 2 curseurs, étape par étape…

Difficulté: Avancé

Avant-propos

Au moment d’écrire cet article, Flutter n’offre aucun Widget de type RangeSlider.

Des demandes ont été ouvertes à la fois sur StackOverflow et GitHub-Flutter.

Comme j’avais besoin de ce widget pour un de mes projets, j’ai décidé d’essayer de le construire moi-même.

Cet article explique, en détails, comment construire à partir de zéro un tel Widget, étape par étape.

Le code source complet est disponible sur GitHub.

Le RangeSlider est disponible en tant que package sur Dart Packages, sous le nom de “flutter_range_slider”.

À la fin de cet article, vous serez à même de construire ce qui suit:

RangeSlider RangeSlider

Remerciements

Ce code s’inspire largement du code source initial du Widget Slider (le code peut être trouvé ici).

De plus, au moment de la rédaction de cet article, il n’y a pas de spécifications détaillées relatives aux Range Sliders disponibles sur Google material.io. J’ai essayé de coller autant que possible à l’idée mais parfois j’ai dû prendre des décisions qui pourraient être invalidées par de futures spécifications définies par Google.

Un exemple d’une telle décision est de ne pas implémenter le tap pour sélectionner directement une valeur car comme il y a 2 curseurs, il serait ambigu de déterminer l’intention de l’utilisateur en termes du curseur à lier au tap.

Spécifications

Un RangeSlider supporte ce qui suit:

  • il permet à un utilisateur de sélectionner une plage définie par 2 valeurs (lowerValue et upperValue), entre min et max
  • 2 modes de fonctionnement doivent être prévus: continu et discret
  • 3 fonctions de callbacks doivent exister: onChanged, onChangeStart et onChangeEnd
  • il doit être possible d’utiliser un thème pour personnaliser le look

Architecture du Widget

Le Widget RangeSlider est du type StatefulWidget dont le rendu s’effectue dans un Canvas.

En d’autres termes, il n’y a aucun besoin d’enfants (= child) cependant, une méthode build nécessite un Widget.

Comme il n’y a pas d’enfants, nous allons opter pour un LeafRenderObjectWidget, ce qui nous permettra de définir un RenderBox.

Ce RenderBox nous donne tous les outils pour dessiner le RangeSlider dans un Canvas.

archi


Etape 1: Squelette du code

De cette architecture, nous dérivons le squelette de code source suivant qui dessine simplement un rail (= track) et les 2 curseurs (= thumbs) à chaque extrémité du track (un en rouge, l’autre en bleu). Les explications sont fournies dans le code.

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 qui instancie un RenderObject
/// ------------------------------------------------------
class _RangeSliderRenderObjectWidget extends LeafRenderObjectWidget {
  const _RangeSliderRenderObjectWidget({
    Key key,
  }) : super(key: key);

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

/// ------------------------------------------------------
/// Classe principale qui effectue le rendu du RangeSlider
/// en tant que "dessin" dans un Canvas
/// ------------------------------------------------------
class _RenderRangeSlider extends RenderBox {

  /// -----------------------------------------------------------
  /// Constants Globales (voir Material.io)
  ///
  /// Comme nous savons déjà que nous aurons à implémenter un 
  /// overlay pour mettre en évidence le curseur actif, 
  /// nous considérons directement ses dimensions (_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;

  /// -------------------------------------------
  /// Les dimensions de ce RenderBox sont
  /// définies par son parent
  /// -------------------------------------------
  @override
  bool get sizedByParent => true;

  /// -------------------------------------------
  /// Redimensionnement du RenderBox en utilisant
  /// uniquement les constraintes fournies par
  /// son parent.
  /// Obligatoire quand sizedByParent retourne "true"
  /// -------------------------------------------
  @override
  void performResize(){
    size = new Size(
      constraints.hasBoundedWidth ? constraints.maxWidth : _preferredTotalWidth,
      constraints.hasBoundedHeight ? constraints.maxHeight : _overlayDiameter,
    );
  }

  /// ------------------------------------------------------------------
  /// Calcul des min,max, 
  /// width et height intrinsèques de la "box".
  /// Les 4 méthodes suivantes doivent être implémentées.
  ///
  /// computeMinIntrinsicWidth: minimal width.  Comme nous avons
  ///                           2 curseurs, espace min pour les afficher
  /// computeMaxIntrinsicWidth: largeur minimale en-dessous de laquelle toute
  ///                           modification n'affecte pas le height
  /// computeMinIntrinsicHeight: minimal height.  Diamètre d'un curseur.
  /// computeMaxIntrinsicHeight: maximal height:  Diamètre d'un curseur.
  /// ------------------------------------------------------------------
  @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;
  }

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

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

  /// ---------------------------------------------
  /// Rendu du track (=Rail)
  ///
  /// Le track est centré verticalement et occupe
  /// autant d'espace en largeur que possible, 
  /// considérant les dimensions de 2 curseurs à 
  /// ses extrémités.
  /// ---------------------------------------------
  double trackLength;
  double trackVerticalCenter;
  double trackLeft;
  double trackTop;
  double trackBottom;
  double trackRight;

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

    // Calcul des dimensions du track et 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 qui correspond au track
    Rect trackLeftRect = new Rect.fromLTRB(trackLeft, trackTop, trackRight, trackBottom);

    // Pour le moment, on paint le track en noir
    Paint trackPaint = new Paint()..color = Colors.black;

    // Dessin du track
    canvas.drawRect(trackLeftRect, trackPaint);
  }

  /// ---------------------------------------------
  /// Rendu des curseurs (thumbs)
  ///
  /// Pour le moment, un curseur n'est rien d'autre qu'un cercle
  /// ---------------------------------------------
  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);
  }
}

Voici un aperçu du résultat donné par le code initial. skeleton


Etape 2: Paramétrage, positionnement des curseurs et affichage de la plage sélectionnée sur le rail.

Dans cette étape, nous ajoutons les paramètres principaux de RangeSlider, positionnons les curseurs en fonction de leurs valeurs initiales (lowerValue, upperValue) et mettons en évidence la plage sélectionnée au niveau du rail.

Paramètres

Le RangeSlider doit définir la plage de valeurs possibles ainsi que les valeurs initiales:

Nom du paramètre Description
min la valeur minimale de la plage des valeurs. Si non définie, nous supposerons 0.0
max la valeur maximale de la plage des valeurs. Si non définie, nous supposerons que 1.0
lowerValue la valeur initiale de la valeur inférieure (= curseur gauche)
upperValue la valeur initiale de la valeur supérieure (= curseur droit)

Ces paramètres doivent être validés et transmis à la classe _RenderRangeSlider.

Pour faciliter tous les calculs ultérieurs pour le positionnement des curseurs, nous considérerons que (min, max) définissent une plage de 100% (où min = 0% et max = 100%).

Par conséquent lowerValue et upperValue seront transmis en tant que pourcentages de cette plage. Cela sera réalisé grâce à l’utilisation de la fonction _unlerp.

Positionnement des curseurs

Comme mentionné ci-dessus, la position des curseurs correspond à un pourcentage du rail. Par conséquent, il est aisé de les positionner.

Afficher la plage sélectionnée au niveau du rail

La plage sélectionnée correspond au segment du rail entre le curseur gauche et le curseur droit. Le reste du rail correspond donc à la plage non sélectionnée.

Cela entraîne le dessin de 3 segments:

  • du bord gauche au curseur gauche: plage non sélectionnée
  • du curseur gauche au curseur droit: plage sélectionnée
  • du curseur droit au bord droit: plage non sélectionnée

Code résultant

Les modifications à appliquer au code sont indiquées ci-dessous:

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);

  /// Valeur minimale sélectionnable.
  ///
  /// Défault: 0.0. Doit être inférieure ou égale à [max].
  final double min;

  /// Valeur maximale sélectionnable.
  ///
  /// Défault: 1.0. Doit être supérieure ou égale à [min].
  final double max;

  /// Valeur inférieure sélectionnée.
  /// 
  /// Correspond au curseur de gauche
  /// Doit être supérieure ou égale à [min],
  /// inférieure ou égale à [max]. 
  final double lowerValue;

  /// Valeur supérieure sélectionnée.
  /// 
  /// Correspond au curseur de droite
  /// Doit être supérieure à [lowerValue],
  /// inférieure ou égale à [max]. 
  final double upperValue;

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

class _RangeSliderState extends State<RangeSlider> {

  /// -------------------------------------------------
  /// Retourne une valeur entre 0.0 et 1.0 par rapport
  /// à un pourcentage d'une valeur entre min et 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 qui instancie un 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,
    );
  }
}

/// ------------------------------------------------------
/// Classe principale qui effectue le rendu du RangeSlider
/// en tant que "dessin" dans un Canvas
/// ------------------------------------------------------
class _RenderRangeSlider extends RenderBox {
  _RenderRangeSlider({
    double lowerValue,
    double upperValue,
  }) {
    // Initialization
    this.lowerValue = lowerValue;
    this.upperValue = upperValue;
  }

  /// -------------------------------------------------
  /// Constants Globales (voir 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;

  /// -------------------------------------------------
  /// Propriétés propres à l'instance du Widget
  /// -------------------------------------------------
  double _lowerValue;
  double _upperValue;

  /// --------------------------------------------------
  /// Setters
  /// Setters sont nécessaires car nous aurons à modifier
  /// les valeurs via le 
  /// _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;
  }

  /// -------------------------------------------
  /// Les dimensions de ce RenderBox sont
  /// définies par son parent
  /// -------------------------------------------
  @override
  bool get sizedByParent => true;

  /// -------------------------------------------
  /// Redimensionnement du RenderBox en utilisant
  /// uniquement les constraintes.
  /// Obligatoire quand sizedByParent retourne "true"
  /// -------------------------------------------
  @override
  void performResize(){
    size = new Size(
      constraints.hasBoundedWidth ? constraints.maxWidth : _preferredTotalWidth,
      constraints.hasBoundedHeight ? constraints.maxHeight : _overlayDiameter,
    );
  }

  /// -------------------------------------------
  /// Calcul des min,max, 
  /// width et height intrinsèques de la "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;
  }

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

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

  /// ---------------------------------------------
  /// Rendu du track (=Rail)
  /// ---------------------------------------------
  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;

    // Calcul de la position des curseurs
    thumbLeftPosition = trackLeft + _lowerValue * trackLength;
    thumbRightPosition = trackLeft + _upperValue * trackLength;

    // Définition des couleurs de segments du tracks (sélectionnés et non sélectionnés)
    Paint unselectedTrackPaint = new Paint()..color = Colors.grey[300];
    Paint selectedTrackPaint = new Paint()..color = Colors.green;

    // Rendu du track
    if (_lowerValue > 0.0){
      // Dessine la partie gauche, non sélectionnée
      canvas.drawRect(new Rect.fromLTRB(trackLeft, trackTop, thumbLeftPosition, trackBottom), unselectedTrackPaint);
    }
    // Dessine la partie sélectionnée
    canvas.drawRect(new Rect.fromLTRB(thumbLeftPosition, trackTop, thumbRightPosition, trackBottom), selectedTrackPaint);

    if (_upperValue < 1.0){
      // Dessine la partie droite, non sélectionnée
      canvas.drawRect(new Rect.fromLTRB(thumbRightPosition, trackTop, trackRight, trackBottom), unselectedTrackPaint);
    }
  }

  /// ---------------------------------------------
  /// Rendu des 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);
  }
}

Voici le résultat de ce code source avec les paramètres suivants:

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

skeleton


Note

A partir de maintenant, afin de ne focaliser que sur la partie nécessaire du code qui a fait l’objet de modifications, le code qui reste inchangé sera omis et remplacé par une ellipse(…).

En même temps, j’utiliserai les termes anglais suivants:

  • rail => track
  • curseur => thumb

Etape 3: Permettre à l’utilisateur d’interagir avec les thumbs (= curseurs)

Dans cette étape, nous allons permettre à l’utilisateur de faire glisser les thumbs horizontalement et redessiner le RangeSlider en fonction des mouvements.

Plusieurs modifications doivent être appliquées pour configurer la reconnaissance de geste Drag.

HorizontalDragGestureRecognizer

Afin de permettre la reconnaissance d’un geste de type Drag, nous devons instancier la classe HorizontalDragGestureRecognizer et gérer ce qui suit:

Callback Description
onStart appelé quand un Drag commence. Donne la position initiale du pointeur
onUpdate appelé lorsque le Drag se déplace. Donne la position delta depuis son dernier appel
onEnd appelé lorsque le Drag se termine
surAnnuler appelé quand le Drag annule

onStart

Lorsque nous commençons un geste de type Drag, nous obtenons la position Globale du Pointer (= pointeur). Nous devons traduire cette position en un pourcentage de la longueur du track.

onUpdate

Lorsque nous déplaçons le pointeur, nous devons traduire le delta entre la dernière fois que cette fonction a été appelée et cette fois-ci, en un pourcentage de la longueur du track afin d’adapter la nouvelle position du curseur actif.

Ensuite, nous devons mémoriser la nouvelle valeur du curseur actif et repeindre le RangeSlider. Ceci est fait via un appel à la méthode markNeedsPaint().

onEnd et onCancel

Nous avons simplement besoin de réinitialiser.

Implémentation de hitTestSelf

Afin de permettre l’interaction avec le RenderBox, nous devons implémenter le hitTestSelf.

Implémentation de handleEvent

Lorsqu’un événement de type PointerDownEvent est intercepté, nous devons valider que l’utilisateur pointe sur l’un des deux curseurs.

Nous interceptons le PointerDownEvent en implémentant la méthode handleEvent et vérifions que l’utilisateur a pointé l’un des 2 curseurs, via la méthode _validateActiveThumb.

Si le résultat de cette validation est un curseur, nous mémorisons le curseur sélectionné et initions le Drag en lui passant l’événement.

Code résultant

Seule la classe _RenderRangeSlider doit être modifiée. Voici les modifications:

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

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

  ...

  /// -------------------------------------------
  /// Obligatoire si l'on veut une interaction
  /// -------------------------------------------
  @override
  bool hitTestSelf(Offset position) => true;

  /// ---------------------------------------------
  /// Routines liées au Drag
  /// ---------------------------------------------
  double _currentDragValue = 0.0;
  HorizontalDragGestureRecognizer _drag;

    /// -------------------------------------------
    /// Quand on démarre un drag, nous devons
    /// mémoriser la position initiale du pointeur
    /// par rapport à sa position dans le track.
    /// -------------------------------------------
  void _handleDragStart(DragStartDetails details){
    _currentDragValue = _getValueFromGlobalPosition(details.globalPosition);
  }

    /// -------------------------------------------
    /// Quand on drag, nous devons considérer
    /// la valeur delta entre la position initiale
    /// du pointeur et sa courante, et 
    /// calculer la nouvelle position du curseur.
    /// Ensuite, on appelle le handler du changement de valeur
    /// -------------------------------------------
  void _handleDragUpdate(DragUpdateDetails details){
    final double valueDelta = details.primaryDelta / _trackLength;
    _currentDragValue += valueDelta;

    // On doit limiter le mouvent au track
    _onRangeChanged(_currentDragValue.clamp(0.0, 1.0));
  }

    /// -------------------------------------------
    /// Fin ou annulation du drag
    /// -------------------------------------------
  void _handleDragEnd(DragEndDetails details){
    _handleDragCancel();
  }
  void _handleDragCancel(){
    _activeThumb = _ActiveThumb.none;
    _currentDragValue = 0.0;
  }

  /// ----------------------------------------------
  /// Gestion d'un changement de sélection de Range
  /// ----------------------------------------------
  void _onRangeChanged(double value){
    if (_activeThumb == _ActiveThumb.lowerThumb){
      _lowerValue = value;
    } else {
      _upperValue = value;
    }
    // Force un rafraîchissement
    markNeedsPaint();
  }

  /// ----------------------------------------------
  /// Routine d'aide de position.
  /// Traduit la position globale du Pointeur en
  /// un percentagee de la dimension du track
  /// ----------------------------------------------
  double _getValueFromGlobalPosition(Offset globalPosition){
    final double visualPosition = (globalToLocal(globalPosition).dx - _overlayDiameter) / _trackLength;

    return visualPosition;
  }

  /// ----------------------------------------------
  /// Gestion des événements.
  /// Nous devons valider que le pointeur "touche"
  /// un curseur avant de lancer un Drag.
  /// ----------------------------------------------
  @override
  void handleEvent(PointerEvent event, BoxHitTestEntry entry){
    if (event is PointerDownEvent){
      _validateActiveThumb(entry.localPosition);

      // Si un curseur a été détecté, lance le GestureDrag
      if (_activeThumb != _ActiveThumb.none){
        _drag.addPointer(event);
        _handleDragStart(new DragStartDetails(globalPosition: event.position));
      }
    }
  }

  /// ----------------------------------------------
  /// Détermine si l'utilisateur a appuyé sur un
  /// curseur.  Si oui, mémorise lequel.
  /// ----------------------------------------------
  _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 {
  // pas de curseur actif
  none,
  // curseur gauche actif
  lowerThumb,
  // curseur droit actif
  upperThumb,
} 

Voici le résultat de cette modification.

dragging dragging

Etape 4: Règles liées aux mouvements des curseurs

Avec l’implémentation actuelle, l’utilisateur peut déplacer les deux curseurs indifféremment de la position de l’autre. En d’autres termes, l’utilisateur pourrait déplacer le curseur gauche vers le côté droit du curseur droit et vice versa. Ceci n’est pas compatible avec le principe d’un lowerValue et d’un upperValue.

Nous devons appliquer les règles suivantes:

  • l’amplitude maximale du mouvement du curseur gauche va du bord gauche du track à la position du curseur droit
  • l’amplitude maximale du mouvement du curseur droit va du curseur gauche au bord droit du track.

Ces plages maximales seront calculées en même temps que la validation du curseur actif, à l’intérieur de la méthode _validateActiveThumb.

La plage de mouvement est ensuite validée par la méthode _handleDragUpdate.

Voici les modifications qui en résultent:

  ...

  double _minDragValue;
  double _maxDragValue;

  ...

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

    // nous devons limiter l'amplitude du mouvement 
    _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;
    }
  }

Etape 5: Faisons en sorte que le widget renvoie la plage de valeurs

Bien sûr, l’objectif principal du Widget est de permettre d’obtenir la plage de valeurs, telle que définie par l’utilisateur.

Comme nous avons ici à faire avec 2 valeurs: “lowerValue” et “upperValue”, nous devons définir un type de rappel spécifique (= callback):

RangeSliderCallback(double lowerValue, double upperValue)

Afin de se conformer au Slider officiel de Flutter, nous considérerons 3 callbacks:

Nom Description
onChangeStart Lorsque l’utilisateur commence à faire glisser l’un des 2 curseurs
onChanged Lorsque l’utilisateur fait glisser un curseur
onChangeEnd Lorsque l’utilisateur termine le glissement d’un curseur

Puisque la classe principale RangeSlider _RenderRangeSlider ne traite que les valeurs entre 0.0 et 1.0 (voir l’explication à l’étape 2), nous devrons convertir ces valeurs à la plage entre min et max. Ceci est fait, via la méthode _lerp.

Voyons maintenant les modifications à appliquer au code, pour implémenter ces rappels.

// Définition du nouveau type de callback
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 à appeler quand l'utilisateur change
  /// les valeurs.
  final RangeSliderCallback onChanged;

  /// Callback à appeler au démarrage d'un drag
  final RangeSliderCallback onChangeStart;

  /// Callback à appeler à la fin d'un drag.
  final RangeSliderCallback onChangeEnd;

  ...
}

class _RangeSliderState extends State<RangeSlider> {
  ...
  /// -------------------------------------------------
  /// Retourne une valeur, entre min et max
  /// proportionnelle à value, qui doit être
  /// entre 0.0 et 1.0
  /// -------------------------------------------------
  double _lerp(double value){
    assert(value >= 0.0);
    assert(value <= 1.0);
    return value * (widget.max - widget.min) + widget.min;
  }

  /// -------------------------------------------------
  /// Gestion des changements appliqués à lowerValue
  /// et/ou upperValue
  /// Invoque le callback approprié
  /// -------------------------------------------------
  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,
  }) {
    // Initialisation
    this.lowerValue = lowerValue;
    this.upperValue = upperValue;
    this.onChanged = onChanged;
    this.onChangeStart = onChangeStart;
    this.onChangeEnd = onChangeEnd;

    ...
  }

  ...

  /// -------------------------------------------------
  /// Propriétés propres à l'instance du Widget
  /// -------------------------------------------------
  RangeSliderCallback _onChanged;
  RangeSliderCallback _onChangeStart;
  RangeSliderCallback _onChangeEnd;
  double _lowerValue;
  double _upperValue;
  
  ...

  /// --------------------------------------------------
  /// Setters
  /// Setters sont nécessaires car nous aurons à modifier
  /// les valeurs via le 
  /// _RangeSliderRenderObjectWidget.updateRenderObject
  /// --------------------------------------------------
  
  ...

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

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

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

  ...

    /// -------------------------------------------
    /// Quand on démarre un drag, nous devons
    /// mémoriser la position initiale du pointeur
    /// par rapport à sa position dans le track.
    /// -------------------------------------------
  void _handleDragStart(DragStartDetails details){
    _currentDragValue = _getValueFromGlobalPosition(details.globalPosition);

    // Comme nous démarrons un Drag, appelons le callback approprié
    _onChangeStart(_lowerValue, _upperValue);
  }

    /// -------------------------------------------
    /// Quand on drag, nous devons considérer
    /// la valeur delta entre la position initiale
    /// du pointeur et sa courante, et 
    /// calculer la nouvelle position du curseur.
    /// Ensuite, on appelle le handler du changement de valeur
    /// -------------------------------------------
  void _handleDragUpdate(DragUpdateDetails details){
    final double valueDelta = details.primaryDelta / _trackLength;
    _currentDragValue += valueDelta;

    // On doit limiter le mouvent au track
    _onRangeChanged(_currentDragValue.clamp(_minDragValue, _maxDragValue));
  }

    /// -------------------------------------------
    /// Fin ou annulation du drag
    /// -------------------------------------------
  void _handleDragEnd(DragEndDetails details){
    _handleDragCancel();
  }
  void _handleDragCancel(){
    _activeThumb = _ActiveThumb.none;
    _currentDragValue = 0.0;

    // Comme nous terminons un Drag, appelons 
    // le callback approprié
    _onChangeEnd(_lowerValue, _upperValue);
  }

  /// ----------------------------------------------
  /// Gestion d'un changement de sélection de Range
  /// ----------------------------------------------
  void _onRangeChanged(double value){
    if (_activeThumb == _ActiveThumb.lowerThumb){
      _lowerValue = value;
    } else {
      _upperValue = value;
    }

    // Appel du callback approprié
    _onChanged(_lowerValue, _upperValue);

    // Force un rafraîchissement
    markNeedsPaint();
  }

  ...
}

Voici le résultat de cette modification.

dragging dragging


Etape 6: Ajouter des divisions au rail

Dans cette étape, nous allons ajouter quelques divisions discrètes au track.

Pour ce faire, nous devons autoriser le Widget à accepter ce paramètre supplémentaire et transmettre ce paramètre à la classe principale, afin que nous puissions dessiner les divisions.

En même temps, si nous établissons des divisions, cela signifie également que les curseurs devront “coller” aux divisions, pendant le dragging.

Voici les modifications à appliquer pour y parvenir (limitées à la classe principale puisque pour les autres, il s’agit simplement de passer le paramètre).

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

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

  /// -------------------------------------------------
  /// Constantes globales. See Material.io 
  /// -------------------------------------------------
  ...
  static const double _tickRadius = _trackHeight / 2.0;

  /// -------------------------------------------------
  /// Propriétés propres à l'instance du Widget
  /// -------------------------------------------------
  RangeSliderCallback _onChanged;
  RangeSliderCallback _onChangeStart;
  RangeSliderCallback _onChangeEnd;
  double _lowerValue;
  double _upperValue;
  int _divisions;

  HorizontalDragGestureRecognizer _drag;

  /// --------------------------------------------------
  /// Setters
  /// Setters sont nécessaires car nous aurons à modifier
  /// les valeurs via le 
  /// _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;
    
    // Si nous changeons la valeur, on doit rafraîchir
    markNeedsPaint();
  }
  ...

  /// ---------------------------------------------
  /// Rendu du 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);
  }

  ...

  /// ---------------------------------------------
  /// Rendu des divisions
  /// ---------------------------------------------
  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);
    }
  }

  ...

  /// ----------------------------------------------
  /// Gestion d'un changement de sélection de Range
  /// ----------------------------------------------
  void _onRangeChanged(double value){

    // S'il y a des divisions, nous devons correspondre à une d'elles
    value = _discretize(value);

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

    // Appel du callback approprié
    _onChanged(_lowerValue, _upperValue);

    // Force un rafraîchissement
    markNeedsPaint();
  }

  /// ----------------------------------------------
  /// S'il y a des divisions, les valeurs doivent
  /// être alignées aux 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;
    }
  }
}

Voici le résultat de ces changements, si nous définissons une plage (min: 0.0, max: 100.0, divisions: 20)

divisions divisions

Eh bien, on dirait que nous avons presque fini. Cependant, il reste encore quelques choses à faire, telles que:

  • mettre en évidence le curseur actif, via une superposition animée (= “overlay”)
  • ajouter une animation lorsque nous activons ou désactivons le RangeSlider
  • utiliser un thème pour toutes les couleurs et dimensions des curseurs

Etape 7: Ajout d’une superposition animée et activation / désactivation

Mettre en évidence le curseur actif

L’idée est de montrer à l’utilisateur quel curseur est actuellement actif via une superposition (= “overlay”) et de le transformer en animation.

Pour ce faire, nous avons besoin d’un AnimationController défini au niveau de _RangeSliderState, puis de passer le State à la classe principale.

Gérer le widget actif / inactif

Le widget RangeSlider doit être considéré comme actif lorsque le callback onChanged est défini (!= null), sinon inactif.

Cela a pour résultat:

  • lorsqu’il est inactif, l’utilisateur ne devrait pas pouvoir interagir avec les curseurs
  • lorsque nous changeons l’état (actif vs inactif), nous devons animer les curseurs pour montrer le changement

Voici les modifications à appliquer au code:

class _RangeSliderState extends State<RangeSlider> with TickerProviderStateMixin {

  // Animation controller qui est exécuté lorsqu'un overlay
  // est affiché en réponse à une interaction de l'utilisateur
  AnimationController overlayController;

  // Animation controller qui est exécuté en réponse à une activation/désactivation du Widget
  AnimationController enableController;

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

    // Initialise les contrôleurs d'animations
    overlayController = new AnimationController(
      duration: kRadialReactionDuration,
      vsync: this,
    );

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

    // Positionne la valeur du enableController à active (1 s'il nous avons défini un callback)
    // ou à inactive (0 autrement)
    enableController.value = widget.onChanged != null ? 1.0 : 0.0;
  }

  @override
  void dispose() {
    // Relâchement des contrôleurs d'animations
    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,
  }) {
    // Initialisation
    this.divisions = divisions;
    this.lowerValue = lowerValue;
    this.upperValue = upperValue;
    this.onChanged = onChanged;
    this.onChangeStart = onChangeStart;
    this.onChangeEnd = onChangeEnd;

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

    // Initialisation de l'animation "overlay"
    _overlayAnimation = new CurvedAnimation(
      parent: state.overlayController,
      curve: Curves.fastOutSlowIn,
    );

    // Initialisation de l'animation "enable/disable"
    _enableAnimation = new CurvedAnimation(
      parent: state.enableController,
      curve: Curves.easeInOut,
    );
  }

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

  /// -------------------------------------------------
  /// Propriétés propres à l'instance du Widget
  /// -------------------------------------------------
  
  ...
  Animation<double> _overlayAnimation;
  Animation<double> _enableAnimation;
  _RangeSliderState state;

  ...

  /// --------------------------------------------------
  /// Setters
  /// Setters sont nécessaires car nous aurons à modifier
  /// les valeurs via le 
  /// _RangeSliderRenderObjectWidget.updateRenderObject
  /// --------------------------------------------------
  ...

  set onChanged(RangeSliderCallback value){
    // Si pas de changement, oublier
    if (_onChanged == value){
      return;
    }
  
    // Gérions-nous déjà un callback?
    final bool wasInteractive = isInteractive;

    // On mémorise le nouveau callback
    _onChanged = value;

    // A-t-on changé le callback?
    if (wasInteractive != isInteractive){
      if (isInteractive){
        state.enableController.forward();
      } else {
        state.enableController.reverse();
      }

      // Comme nous avons effectué une modification, rafraîchissement
      markNeedsPaint();
    }
  }
  ...

  /// ----------------------------------------------
  /// Gérons-nous les callbacks?
  /// ----------------------------------------------
  bool get isInteractive => (_onChanged != null);

  /// --------------------------------------------
  /// Rafraîchissement automatisé
  /// lorsqu'une animation est en cours
  /// --------------------------------------------
  @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();
  }

  ...

  /// ---------------------------------------------
  /// Rendu du 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);
  }

  ...

  /// ---------------------------------------------
  /// Rendu de l'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);

      // Calcul de la position de l'overlay % au curseur actif
      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);

    // Comme nous démarrons un drag, invoquons le callback approprié
    _onChangeStart(_lowerValue, _upperValue);

    // Affichage de l'overlay
    state.overlayController.forward();
  }
  
  ...

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

    // Comme le drag se termine,
    // invoquons le callback approprié
    _onChangeEnd(_lowerValue, _upperValue);

    // Cachons l'overlay
    state.overlayController.reverse();
  }

  ...

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

      // Si un curseur est trouvé, initialise le GestureDrag
      if (_activeThumb != _ActiveThumb.none){
        _drag.addPointer(event);
        _handleDragStart(new DragStartDetails(globalPosition: event.position));
      }
    }
  }

  ...
}

Voici le résultat de cette modification.

overlay overlay


Etape 8: Utilisation d’un Theme

Le widget Slider utilise déjà un Theme, appelé: SliderTheme dont la définition donne SliderThemeData.

Comment peut-on faire pour l’utiliser ?

Liste des paramètres du SliderTheme à utiliser

Nom Description
activeTrackColor Couleur du track montrant la plage sélectionnée
inactiveTrackColor Couleur du track montrant les plages non sélectionnées
activeTickMarkColor Couleur d’une marque de graduation (voir divisions)
overlayColor Couleur de la superposition
thumbShape Routine pour dessiner un curseur
thumbColor Couleur à utiliser par le thumbShape pour dessiner le curseur
thumbShape.getPreferredSize Dimensions d’un curseur

Explication sur les modifications à appliquer

Le fait de permettre la “thématisation” nécessite une série de modifications à appliquer, telles que:

  • A la méthode _RangeSliderState, build, nous devons récupérer le SliderTheme du contexte actuel, et le transmettre à la classe principale;
  • Dans la classe principale _RenderRangeSlider, nous devons:

    • accepter ce paramètre et forcer un “repaint” quand il change;
    • obtenir le thumbRadius du SliderTheme;
    • définir les dimensions Intrinsic du SliderTheme;
    • dessiner le track avec les couleurs, définies par SliderTheme;
    • dessiner la superposition (= overlay) avec la couleur, définie par SliderTheme;
    • dessiner le tickMarks (= divisions) avec la couleur, définie par SliderTheme;
    • utiliser le thumbShape, défini par SliderTheme, pour dessiner les curseurs;

Voici les modifications à appliquer au 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,
  }) {
    // Initialisation
    ...
    this.sliderTheme = sliderTheme;
    ...
  }

  ...

  SliderThemeData _sliderTheme;

  ...

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

    // Si l'on change le thème, on doit rafraîchir
    markNeedsPaint();
  }

  ...

  /// ----------------------------------------------
  /// Obtiens le rayon du curseur, via le Theme
  /// ----------------------------------------------
  double get _thumbRadius {
    final Size preferredSize = _sliderTheme.thumbShape.getPreferredSize(isInteractive, (_divisions != null));
    return math.max(preferredSize.width, preferredSize.height) / 2.0;
  }

  ...

  /// -------------------------------------------
  /// Calcul des min,max intrinsic
  /// width et height de la "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
    );
  }

  ...

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

  ...

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

    // Définition des couleurs pour les segments sélectionnés et non sélectionnés du track
    Paint unselectedTrackPaint = new Paint()..color = _sliderTheme.inactiveTrackColor;
    Paint selectedTrackPaint = new Paint()..color = _sliderTheme.activeTrackColor;

    ...
  }

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

      ...

    }
  }

  /// ---------------------------------------------
  /// Rendu des divisions
  /// ---------------------------------------------
  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);

    // Rendu des curseurs, via le 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,
    );
  }

  ...
}

Voici à quoi le RangeSlider ressemble maintenant en utilisant le Theme de base.

theme theme


Etape 9: Indication de la valeur en cours

Que pourrions-nous encore faire maintenant?

Si nous voulons respecter le widget officiel Slider, nous devons encore:

  • afficher une label au-dessus du curseur actif lorsque l’utilisateur le fait glisser afin que l’utilisateur puisse voir la valeur du curseur tout en le faisant glisser;

Je vais dévier un peu de l’implémentation originale du Slider, de sorte que RangeSlider:

  • accepte un paramètre booléen showValueIndicator;
  • accepte un paramètre entier valueIndicatorMaxDecimals pour arrondir la valeur active à un certain nombre maximal de décimales.

Voici l’explication sur les changements à appliquer …

RangeSlider

2 nouveaux paramètres:

Paramètre Type Valeur par défaut Description
showValueIndicator bool faux accepte ou non d’afficher une étiquette “valueIndicator” au-dessus du curseur actif pendant le déplacement. Même s’il est défini comme “true”, ce paramètre peut être remplacé par la propriété SliderTheme showValueIndicator
valueIndicatorMaxDecimals int 1 nombre maximal de décimales à utiliser pour afficher la valeur
_RangeSliderState

Nous devons ajouter un nouveau AnimationController pour animer l’apparence de l’étiquette ValueIndicator.

Aussi, comme dans la classe principale, nous devrons afficher la valeur “réelle” (et non la valeur de travail qui va de 0.0 à 1.0), nous aurons besoin d’exposer la méthode lerp.

_RangeSliderRenderObjectWidget

Nous avons simplement besoin de transmettre ces 2 nouveaux paramètres à la classe principale.

_RenderRangeSlider

Plusieurs choses doivent être faites à ce niveau … Nous avons besoin de:

  • une nouvelle Animation (= _valueIndicatorAnimation) pour animer la transition de l’étiquette valueIndicator (de cachée à visible et vice versa);
  • une instance TextPainter qui sera utilisée par SliderTheme pour afficher l’étiquette valueIndicator;
  • obtenir à partir de SliderTheme s’il faut afficher ou non le valueIndicator;
  • peindre le valueIndicator

Voici les modifications à appliquer au 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);

  /// Affiche-t-on un "label" au-dessus du curseur actif
  /// quand le RangeSlider est actif ?
  final bool showValueIndicator;

  /// Nombre max de décimales pour afficher 
  /// la valeur dans le "label" au-dessus du curseur
  /// actif
  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 qui est exécuté quand le "label" est affiché
  // ou caché
  AnimationController valueIndicatorController;

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

    ...

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

    ...
  }

  @override
  void dispose() {
    // Relâche les contrôleurs d'animation
    valueIndicatorController.dispose();
    enableController.dispose();
    overlayController.dispose();
    super.dispose();
  }

  ...

  /// -------------------------------------------------
  /// Retourne une valeur, entre min et max
  /// proportionnelle à value, qui doit être
  /// entre 0.0 et 1.0
  /// -------------------------------------------------
  double lerp(double value){
    assert(value >= 0.0);
    assert(value <= 1.0);
    return value * (widget.max - widget.min) + widget.min;
  }

  /// -------------------------------------------------
  /// Gestion des changements appliqués à lowerValue
  /// et/ou upperValue
  /// Invoque le callback approprié
  /// -------------------------------------------------
  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 qui instancie un 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;
  }
}

/// ------------------------------------------------------
/// Classe principale qui effectue le rendu du RangeSlider
/// en tant que "dessin" dans un Canvas
/// ------------------------------------------------------
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,
  }) {
    // Initialisation

    ...

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

    ...

    // Initialisation de l'animation liée à l'affichage du "label"
    _valueIndicatorAnimation = new CurvedAnimation(
      parent: state.valueIndicatorController,
      curve: Curves.fastOutSlowIn,
    );
  }

  ...

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

  ...

  set showValueIndicator(bool value){
    // Ignorer si pas de changement
    if (value == _showValueIndicator){
      return;
    }
    _showValueIndicator = value;

    // Force un rafraîchissement du contenu du "label"
    _updateValueIndicatorPainter();
  }

  set valueIndicatorMaxDecimals(int value){
    // Ignorer si pas de changement
    if (value == _valueIndicatorMaxDecimals){
      return;
    }

    _valueIndicatorMaxDecimals = value;

    // Force un rafraîchissement
    markNeedsPaint();
  }

  ...

  /// ----------------------------------------------
  /// Demande au SliderTheme si l'on peut
  /// afficher le "label" au-dessus du curseur actif
  /// ----------------------------------------------
  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);
  }

  /// --------------------------------------------
  /// Adapte le "painter" sur base des données
  /// du SliderTheme
  /// --------------------------------------------
  void _updateValueIndicatorPainter(){
    if (_showValueIndicator != false){
      _valueIndicatorPainter
        ..text = new TextSpan(
          style: _sliderTheme.valueIndicatorTextStyle,
          text: ''
        )
        ..textDirection = TextDirection.ltr
        ..layout();
    } else {
      _valueIndicatorPainter.text = null;
    }

    // Force un 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){

    ...

    // Rendu des curseurs, via le 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,
    );
  }

  /// ---------------------------------------------
  /// Rendu du "label" au-dessus du curseur actif
  /// ---------------------------------------------
  void _paintValueIndicator(PaintingContext context){
    if (isInteractive && _showValueIndicator && _previousActiveThumb != _ActiveThumb.none) {
      if (_valueIndicatorAnimation.status != AnimationStatus.dismissed && showValueIndicator){

        // Calcul de sa position % au curseur actif
        // ainsi que de la valeur à y afficher
        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;
        }

        // Adapte la valeur en terme de décimales
        // et reconvertit un pourcentage % (min, max)
        value = state.lerp(value);
        textValue = value.toStringAsFixed(_valueIndicatorMaxDecimals);

        // Adapte le contenu du "label"
        _valueIndicatorPainter
          ..text = new TextSpan(
            style: _sliderTheme.valueIndicatorTextStyle,
            text: textValue,
          )
          ..layout();

        // Demande au SliderTheme d'effectuer son rendu
        _sliderTheme.valueIndicatorShape.paint(
          context,
          thumbCenter,
          activationAnimation: _valueIndicatorAnimation,
          enableAnimation: _enableAnimation,
          isDiscrete: (_divisions != null),
          labelPainter: _valueIndicatorPainter,
          parentBox: this,
          sliderTheme: _sliderTheme,
          value: value,
        );
      }
    }
  }

  ...

    /// -------------------------------------------
    /// Quand on démarre un drag, nous devons
    /// mémoriser la position initiale du pointeur
    /// par rapport à sa position dans le track.
    /// -------------------------------------------
  void _handleDragStart(DragStartDetails details){

    ...

    // Afficher le "label"
    if (showValueIndicator){
      state.valueIndicatorController.forward();
    }
  }
  
  ...

  void _handleDragCancel(){
    _previousActiveThumb = _activeThumb;

    ...

    // Cacher le "label"
    if (showValueIndicator){
      state.valueIndicatorController.reverse();
    }
  }

  ...

  /// ----------------------------------------------
  /// Détermine si l'utilisateur a appuyé sur un
  /// curseur.  Si oui, mémorise lequel.
  /// ----------------------------------------------
  _ActiveThumb _activeThumb = _ActiveThumb.none;
  _ActiveThumb _previousActiveThumb = _ActiveThumb.none;

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

Voici maintenant à quoi ressemble le RangeSlider.

RangeSlider RangeSlider


Comment appliquer des modifications au SliderTheme?

Tout au long de cet article, j’ai mentionné plusieurs fois que je voulais que le RangeSlider utilise le SliderTheme mais je n’ai pas encore expliqué comment l’utiliser et, plus important, comment le personnaliser.

Le moyen le plus simple de forcer un Slider ou maintenant, un RangeSlider à être personnalisé avec un thème est de l’entourer par un SliderTheme!

  Widget build(BuildContext context){
    return new Container(
      child: new SliderTheme(
        data: SliderTheme.of(context).copyWith(
          ...{modifications à appliquer}...
        ),
        child: new RangeSlider(
          ...
        ),
      ),
    );
  }

L’exemple suivant montre une série de RangeSlider personnalisés, avec un exemple de comment les utiliser:

/// ---------------------------------------------------
/// Helper class dont le but est de simplifier et d'
/// automatiser la création et l'affichage d'une série
/// de RangeSliders suivant une définition externe.
/// 
/// Cette classe montre également comment "personnaliser"
/// le Theme
/// ---------------------------------------------------
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,
  });

  /// Retourne les valeurs en format texte, en utilisant
  /// le nombre de décimales tel que défini par 
  /// valueIndicatedMaxDecimals
  /// 
  String get lowerValueText =>
      lowerValue.toStringAsFixed(valueIndicatorMaxDecimals);
  String get upperValueText =>
      upperValue.toStringAsFixed(valueIndicatorMaxDecimals);

  /// Construis un RangeSlider et personnalise le thème
  /// en fonction des paramètres
  /// 
  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> {
  /// Liste de RangeSliders à utiliser, et définition de leurs paramètres
  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) {
    /// Construis la liste des 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;
          });
        }
      ));
      // Ajoute un espace supplémentaire entre les RangeSliders
      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

J’ai essayé d’expliquer, étape par étape, une manière de développer un RangeSlider à partir de rien.

Je suis conscient qu’il y a encore des choses à faire (Sémantique, TextDirection, …) mais ce n’était pas dans mes objectifs directs.

J’espère que vous trouverez cet article intéressant.

Restez à l’écoute pour les prochains articles et, comme d’habitude, je vous souhaite un bon codage!