Introduction
Le QML offre de nombreuses possibilités dans le domaine de l'interface utilisateur. Les éléments de base (Item, Rectangle, Text, etc) couplés au C++ et au javascript sont de formidables outils de création. Ils permettent aussi d'avoir un code clair et efficace en utilisant le principe de Model/View (http://doc.qt.io/qt-5/qtquick-modelviewsdata-modelview.html).
Ce qui nous intéresse dans notre cas c'est de réaliser un contrôle circulaire et manipulable de plusieurs manières, ce que ne permet pas le Slider ou encore la ProgressBar du QML.
Le CircleSlider est un composant QML qui doit répondre à une problématique précise. Ce doit être un arc de cercle dont on peut modifier les valeurs minimum et maximum, séparément ou simultanément (tout en gardant l'intervalle) et ce, de manière interactive (écran tactile ou souris).
Modification de la valeur maximum
Modification des valeurs maximum
et minimum mais avec intervalle identique
Pour une expérience utilisateur optimum, il faut que ce CircleSlider fasse apparaître 2 « handle » : un lorsqu'on sélectionne la valeur minimum et un autre pour la valeur maximum.
« handle » min « handle » max
Maintenant que nous avons décrit son comportement, il convient de passer à la réalisation. Et un des problèmes majeur de Qt/QML est le nombre de solutions possible.
Nous allons donc poser quelques bases quant à la construction de ce composant :
- Le code doit être succinct
- Il ne doit y avoir aucun calcul superflu
- Il doit être un minimum personnalisable (couleur, taille)
Analysons maintenant l'existant afin de lister les outils nécessaires à la réalisation de ce composant.
Analyse
Comme mentionné dans l'introduction, le CircleSlider est un arc de cercle et doit être développé en QML.
Il est donc indispensable dans un premier temps de faire un petit benchmark des solutions qui s'offrent à nous.
Si l'on fait une recherche GitHub autour des mots clefs « circle », « slider », « progress » et « qml » on découvre plusieurs occurrences du composant ProgressCircle (exemple : https://github.com/papyros/qml-material/blob/803035deba51e7fd69634378555d8e62c79af39f/modules/Material/ProgressCircle.qml). Or dans la plupart des cas celui-ci est un contrôle ProgressBar, avec une modification du style et du panel.
Pour information : depuis la version Qt 5.1, un nouveau module a fait son apparition : Qt Quick Controls.
Ce module est fort intéressant car il permet d'accéder à un grand nombre de contrôles tels que : ProgressBar, CheckBox, Slider, Menu, etc. (http://doc.qt.io/qt-5/qtquick-controls-qmlmodule.html)
J'exclue cette solution car les composants QtQuick.Controls sont faits pour pouvoir s'adapter aux styles des différents OS. C'est parfait pour développer des interfaces natives très rapidement, mais la complexité voire la lourdeur du code se ressent sur des devices de faible puissance. (Il semblerait que les prochaines versions de Qt voient leurs QtQuick.Controls s'améliorer : https://blog.qt.io/blog/2015/11/23/qt-quick-controls-re-engineered-status-update/)
On sait que le QML permet aisément l'utilisation du javascript. Si on réalise la même recherche que précédemment mais en remplaçant « qml » par « javascript », on obtient quasiment le même résultat.
Mais ces recherches ont permis de découvrir plusieurs fonctions intéressantes :
- arc de l'élément Canvas
- Math.atan2 du javascript.
Canvas.arc
La fonction arc crée un arc ou une courbe en spécifiant son centre (x,y), le rayon, l'angle de départ (notre valeur minimum en radian), l'angle de fin (notre valeur maximum en radian). Voici une illustration (http://www.w3schools.com/tags/canvas_arc.asp) :
La fonction arc s'écrit de la manière suivante :
arc(x,y,r,sAngle,eAngle)
Un paramètre optionnel existe arc(x,y,r,sAngle,eAngle,counterClockWise). Si counterClockWise est à «true», l'arc est tracé dans le sens inverse des aiguilles d'une montre. Par défaut il est à « false ».
Math.Atan2
Atan2 est un outil formidable lorsque vous voulez réaliser des interfaces utilisant des cercles et plus particulièrement lorsque vous voulez détecter la position de votre curseur dans un cercle.
En effet, souvenez vous que nous devons faire varier le minimum et le maximum en même temps tout en gardant l'intervalle. Il faudra donc connaître l'angle de rotation par rapport aux coordonnées x et y du pointeur.
Voici comment cette fonction est décrite dans wikipedia (https://fr.wikipedia.org/wiki/Atan2) :
« En trigonométrie, la fonction atan2 à deux arguments est une variation de la fonction arc tangente. Pour tout arguments réels x et y non nuls, atan2(y, x) est l'angle en radians entre la partie positive de l'axe des x d'un plan, et le point de ce plan aux coordonnées (x, y). Cet angle est positif pour les angles dans le sens anti-horaire dit sens trigonométrique (moitié haute du plan, y > 0) et négatif dans l'autre (moitié basse du plan, y < 0). »
L'article est accompagné d'une illustration qui vaut mieux qu'un long discours :
« Le diagramme ci-dessous présente les valeurs prises par atan2 sur des points remarquables du cercle unitaire. Les valeurs, en radians, sont inscrites en bleu à l'intérieur du cercle. Les quatre points (1,0), (0,1), (-1,0), et (0,-1) sont notés à l'extérieur du cercle. Notez que l'ordre des arguments est inversé; la fonction atan2(y,x) donne l'angle correspondant au point (x,y). »
On commence à voir une corrélation entre la position des valeurs minimum et maximum de la fonction arc et l'angle calculé par atan2.
Développement
Tout d'abord, créons notre projet en commençant par lancer QtCreator :
Puis, choisissons un projet de type QtQuickApplication :
Nommons le CircleSlider :
Il faudra ensuite, choisir la version de Qt minimale à utiliser et décocher « with ui.qml file » :
Sélectionner ensuite, le Kit de développement et Voilà :
Créons un fichier nommé CircleSlider.qml et un fichier Handle.qml.
Placer vous sur qml.qrc avec la souris et cliquer droit. Sélectionner « Ajouter nouveau » puis Qt → QML File (Qt Quick 2), nommer le(s) fichier(s) et cliquer sur suivant :
C'était la partie facile. Maintenant, nous allons décortiquer le développement.
Les Handles
Les Handles (voir Introduction) sont des cercles qui doivent apparaître uniquement si l'on clique dessus et dont la couleur doit être parametrable.
Il existe 3 façons de réaliser un cercle en qml :
- Une image
- Un cercle dessiné en utilisant la balise Canvas
- Un composant rectangle à coin arrondi dont le radius est égal à la moitié de la largeur qui est elle-même égale à la hauteur
Pour des raisons pratiques et plus naturelles nous utiliserons la dernière solution. Les Handles seront donc créés en utilisant :
- un Rectangle pour le cercle
- un MouseArea pour détecter les évènements
Voilà le code :
import QtQuick 2.0 Rectangle { id: root property bool pressed: false radius: width * .5 opacity: pressed ? 1 : 0 MouseArea{ id: rootArea; anchors.fill: parent; onPressed: { mouse.accepted= false; root.pressed = true } } }
On peut remarquer plusieurs choses :
- Une propriété pressed qui permettra d'identifier sur quel Handle on clique et ainsi savoir si on incrémente/décrémente le minimum ou le maximum de l'arc de cercle (voir Introduction)
- Les Handles sont rendus visibles uniquement si on clique dessus
- Remarque : Il n'est pas possible d'utiliser la propriété 'visible' ici. Si visible=false, les propriétés width et height sont mises à zéro et il n'est donc plus possible de cliquer sur le cercle (http://doc.qt.io/qt-5/qml-qtquick-item.html#visible-prop)
- On permet la propagation de l'événement pressed jusqu'aux éléments en dessous : mouse.accepted=false
Maintenant le cœur du problème : l'arc de cercle !
L'arc de cercle
Pour réaliser la manipulation de l'arc de cercle on va décomposer la programmation en plusieurs parties :
- Créer un arc à partir des valeurs minimum et maximum
- Connecter les Handles aux extrémités de l'arc de cercle (par conséquent les valeurs minimum et maximum)
- Manipulation de l'arc en entier (écart minimum et maximum identique) ou rotation de l'arc
Précédemment, nous avons créé un projet à partir de QtCreator. Celui-ci n'affiche pour l'instant qu'un simple HelloWorld :
On va modifier le fichier main.qml afin de visualiser le CircleSlider :
import QtQuick 2.5 import QtQuick.Window 2.2 Window { visible: true width: 480 height: 480 CircleSlider { anchors.fill: parent } }
On obtient une fenêtre carré vide, tout va bien.
Passons au fichier CircleSlider.qml.
Dessine moi un arc de cercle
Dans le fichier CircleSlider.qml, nous allons reprendre la fonction arc() du composant Canvas (http://doc.qt.io/qt-5/qml-qtquick-canvas.html). Ce dernier est une reprise de la balise canvas en HTML.
import QtQuick 2.0 Canvas { id: root anchors.fill: parent anchors.margins: 48 onPaint: drawCircle() function drawCircle() { var ctx = root.getContext("2d"); ctx.clearRect(0,0,root.width, root.height) ctx.strokeStyle = "red" ctx.lineWidth = 1 ctx.beginPath() ctx.arc(root.width * .5, root.height*.5, root.width*.5, 0, 1.5*Math.PI) ctx.stroke() } }
Avec ce premier code on obtient ceci :
Expliquons un peu :
onPaint: drawCircle()
onPaint est un Handler de Canvas, il va être appelé à chaque fois que ce composant est dessiné à l'écran. On lui associe la fonction drawCircle() :
function drawCircle() { var ctx = root.getContext("2d"); ctx.clearRect(0,0,root.width, root.height) ctx.strokeStyle = "red" ctx.lineWidth = 1 ctx.beginPath() ctx.arc(root.width * .5, root.height*.5, root.width*.5, 0, 1.5*Math.PI) ctx.stroke() }
La méthode getContext(''2d'') renvoie un objet de type Context2D (http://doc.qt.io/qt-5/qml-qtquick-context2d.html) qui appartient à l'objet Canvas. C'est cet objet qui nous permet de dessiner dans le Canvas.
Les propriétés et méthodes qui sont utilisées ensuite parlent d'elles-mêmes :
- clearRect : Remplace tous les pixels du canvas présent sur le rectangle (0,0,root.width, root.height) par du noir transparent. Très important, comme nous récupérons le contexte (contient le dessin précédent) il faut le « nettoyer » pour ne pas superposer les arcs de cercle
- strokeStyle : le style ou la couleur dessiné autour de l'objet
- lineWidth : taille de la ligne
- beginPath : supprime le trait dessiné avant et en démarre un nouveau
- arc : nous avons vu au début de ce post ce que c'était. Ici je spécifie le centre du cercle et sa taille à l'aide des propriétés du Canvas. Pour rappel le système de coordonnées dans le QML est le suivant :
- stroke : dessine les traits avec le style spécifié par strokeStyle
Très bien, nous avons maintenant une bonne base pour manipuler notre arc de cercle. Mais on va d'abord améliorer le concept afin de :
- Rendre les valeurs minimum, maximum, couleur, épaisseur du trait de l'arc, accessibles depuis des propriétés de notre composant CircleSlider
- Rendre les valeurs minimum et maximum plus faciles à utiliser avec des valeurs de 0 à 100 % plutôt que des radians
- Supprimer les effets de bord dû à l'épaisseur du trait :
import QtQuick 2.0 Canvas { id: root property real min: 10 property real max: 70 property color strokeColor: "red" property int lineWidth: 20 /*! \internal */ property int _radius: (root.width - root.lineWidth) * .5 property int _centerX: root.width * .5 property int _centerY: root.height * .5 anchors.fill: parent anchors.margins: 48 onMaxChanged: { requestPaint() } onMinChanged: { requestPaint() } onPaint: drawCircle() function drawCircle() { var ctx = root.getContext("2d"); var startArcPt = root.min * .01 * (2*Math.PI) var endArcPt = root.max * .01 * (2*Math.PI) ctx.clearRect(0,0,root.width, root.height) ctx.strokeStyle = root.strokeColor ctx.lineWidth = root.lineWidth ctx.beginPath() ctx.arc(root._centerX,root._centerY,root._radius,startArcPt,endArcPt) ctx.stroke() } }
Avec 10 % et 70 %, respectivement pour les propriétés min et max voilà ce que l'on obtient :
En créant une propriété dans un objet QML, un handler lui est automatiquement associé et est appelé à chaque fois que la propriété change.
On connecte onMaxChanged et onMinChanged à la méthode requestPaint qui permet de redessiner le composant Canvas. Ainsi chaque modification des propriétés min et max entraine un nouveau dessin.
Remarque : Nous pourrions faire pareil pour strokeColor et lineWidth.
Pour éviter l'effet de bord, on retranche l'épaisseur du trait (root.lineWidth) de la largeur du Canvas (root.width). En divisant par 2 cette valeur, on obtient le nouveau rayon (_radius).
Les valeurs min et max sont converties en radian via les variables startArcPt et endArcPt.
Essayons maintenant de manipuler les valeurs min et max avec nos Handles.
Manipulation de min et max
On rentre dans le vif du sujet. Souvenez-vous de l'idée principale du CircleSlider, en cliquant sur l'une des extrémités de l'arc de cercle, on fait varier le min ou le max. Exemple du max :
Cette valeur max, une fois convertie correspond à eAngle, dans la fonction arc(x,y,r,sAngle,eAngle) et donc à endArcPt dans notre fonction drawCircle.
Dans un premier temps nous allons placer les Handles sur les extrémités min et max de l'arc de cercle.
Si ce n'est pas déjà fait, copiez le code des Handles dans le fichier Handle.qml que nous avons créé précédemment.
Les Handles vont être placé sur un cercle, on va donc utiliser nos connaissances de collège/lycée et plus précisément les équations paramétriques d'un cercle :
- x = a + Rcosθ
- y = b + Rsinθ
Où a et b sont les coordonnées du centre du cercle et R le rayon. Ce qui donne dans notre programme (CircleSlider.qml) :
Canvas { id: root property real min: 10 property real max: 70 property color strokeColor: "red" property int lineWidth: 20 property int handleSize: 40 /*! \internal */ property int _radius: (root.width - root.lineWidth) * .5 property int _centerX: root.width * .5 property int _centerY: root.height * .5 property int _handleRotCenterX : root._centerX - root.handleSize*.5 property int _handleRotCenterY : root._centerY - root.handleSize*.5 … Handle { id: maxHandle width: root.handleSize height: root.handleSize color: Qt.darker(root.strokeColor) opacity: 1 x: root._radius * Math.cos((2*Math.PI*root.max)/100) + root._handleRotCenterX y: root._radius * Math.sin((2*Math.PI*root.max)/100) + root._handleRotCenterY } }
Le résultat :
L'opacité du Handle est forcée à 1 pour permettre un développement plus simple, de même on va se concentrer uniquement sur le Handle max.
Le rayon R est remplacé par _radius, on convertit la valeur max en radian et on voit deux nouvelles propriétés apparaître : handleRotCenterX et handleRotCenterY. Ces deux propriétés ne correspondent pas exactement à des propriétés géomètriques, root.handleSize*.5 est un offset pour ramener le coordonnées du Handle au cenre de celui-ci. On aurait pu écrire :
x: root._radius * Math.cos((2*Math.PI*root.max)/100) + root._centerX //Rcosθ +a - root.handleSize*.5 //offset pour ramener x au centre du Handle
L'avantage d'utiliser une propriété est d'éviter un calcul en plus à chaque changement de min ou max.
Maintenant mettons tout ce petit monde en mouvement.
Souvenez-vous au début de ce post, nous avons parlé de Math.atan2(x,y). Cette fonction va nous permettre de récupérer, en radian, l'angle dans le cercle à partir des coordonnées x et y de la souris.
C'est peut être un peu nébuleux pour l'instant mais le code permettra peut être d'y voir plus clair.
On va ajouter une MouseArea qui détecte les évènements souris sur toute la surface du cercle et calcule l'angle à l'aide d'une fonction getAngle(mouse). On actualise la valeur max grâce à la fonctionne moveHandle(value) que l'on va créer :
function getAngle(mouse) { var localX = mouse.x - root._centerX var localY = mouse.y - root._centerY var value = 0 // y [0; +Infinity] => atan2 [-PI; 0] // y [-Infinity; 0] => atan2 [0; PI] if(localY <= 0) value = (Math.atan2(localY, localX) + 2*Math.PI) * 100 / (2*Math.PI) else value = Math.atan2(localY, localX) * 100 / (2*Math.PI) return value } function moveHandle(value) { if(maxHandle.pressed) root.max = value } MouseArea { id: mouseArea anchors.fill: parent onPressed: root.moveHandle(getAngle(mouse)) onMouseXChanged: root.moveHandle(getAngle(mouse)) onMouseYChanged: root.moveHandle(getAngle(mouse)) onReleased: { maxHandle.pressed = false } }
La petite subtilité de ce code est dans la fonction getAngle(mouse) :
// y [0; +Infinity] => atan2 [-PI; 0] // y [-Infinity; 0] => atan2 [0; PI] if(localY <= 0) value = (Math.atan2(localY, localX) + 2*Math.PI) * 100 / (2*Math.PI) else value = Math.atan2(localY, localX) * 100 / (2*Math.PI)
Rappelez-vous la description de atan2 dans Wikipedia :
« Cet angle est positif pour les angles dans le sens anti-horaire dit sens trigonométrique (moitié haute du plan, y > 0) et négatif dans l'autre (moitié basse du plan, y < 0). »
Math.atan2 prend alors que des valeurs entre 0 et PI. On va donc ajouter un offset dans le cas où y est négatif.
Les variables localX et localY sont les coordonnées de la souris dans le repère du cercle.
On utilise la propriété pressed du Handle pour détecter qu'on clique sur celui-ci et on la met à false une fois l'appui souris terminé.
Il ne vous reste plus qu'à faire la même chose pour la propriété min. Et nous allons pouvoir attaquer la dernière partie, la rotation de l'arc.
Rotation de l'arc
Pour réaliser cette fonction on aurait pu utiliser la propriété rotation (http://doc.qt.io/qt-5/qml-qtquick-item.html#rotation-prop) du composant Item dont hérite Canvas. Mais l'intérêt du composant CircleSlider est d'avoir min et max qui varie en même temps que la rotation.
La rotation du CircleSlider va être déclenchée par une pression souris sur l'arc de cercle.
Nous allons ensuite réutiliser la fonction getAngle(mouse) pour avoir une première valeur (handle onPressed) de référence et appliquer une variation d'angle (valeur) aux propriétés min et max :
Canvas { id: root ... property real _oldValue: 0 … function moveHandle(value, mouse) { if(minHandle.pressed) root.min = value else if(maxHandle.pressed) root.max = value else if(root.getContext("2d").isPointInPath(mouse.x,mouse.y)){ var offSet = value - root._oldValue root.max += offSet root.min += offSet root._oldValue = value } } MouseArea { id: mouseArea anchors.fill: parent onPressed: { root._oldValue = getAngle(mouse) root.moveHandle(root._oldValue, mouse) } onMouseXChanged: root.moveHandle(getAngle(mouse), mouse) onMouseYChanged: root.moveHandle(getAngle(mouse), mouse) onReleased: { minHandle.pressed = false maxHandle.pressed = false } } ….
Si vous avez bien complété le code pour le Handle min, supprimé l'override de la propriété opacity des Handles, vous devez avoir maintenant le fonctionnement décrit au tout début.
Vous pouvez retrouver le code complet ici : https://github.com/jbflamant/circleslider/releases/tag/blog_post_version
Conclusion
Je dois avouer que lorsque j'ai commencé ce programme, mes idées n'étaient pas très claires sur les composants que j'allais utiliser. J'ai donc tatonné pendant un petit moment et il m'a fallut un peu de temps pour en arriver à un code clair et simplifié tel que celui que je vous l'ai présenté.
J'ai même optimisé certaines parties en écrivant cet article !
Le composant développé est brut, et il y a encore beaucoup à faire pour le rendre personnalisable, en utilisant par exemple des images pour les Handles ou en supprimant le Canvas et en utilisant uniquement les coordonnées des Handles ou les valeurs min et max. Mais ce pourra être le sujet d'un autre article.
N'hésitez à laisser des commentaires positifs ou négatifs, à proposer des améliorations.
Amusez-vous bien!