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:
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.
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.
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
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.
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.
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)
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.
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:
|
|
Voici à quoi le RangeSlider ressemble maintenant en utilisant le Theme de base.
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.
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,
),
),
),
);
}
}
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!