La hiérarchie visuelle en QML

Avant-propos

QML est un langage permettant de fabriquer des interfaces graphiques de manière simple. Tout en conservant une très grande souplesse et des performances optimales, il bénéficie de l’avantage du multiplateformes de Qt. Votre application peut ainsi fonctionner sur un ordinateur de bureau sous Linux ou Windows, comme sur appareils mobiles sous Android ou IOS. Je vous laisse en découvrir les grandes lignes dans la documentation en ligne de QML.

Dans cet article, nous allons découvrir ou approfondir notre connaissance de la manière dont le moteur de rendu de QML va organiser l’affichage. Nous allons même nous restreindre à un seul point : l’ordre d’affichage des objets. Autrement dit, quand on écrit du QML qu’est-ce qui est dessiné au-dessus et qu’est-ce qui l’est au-dessous.

Cet article rend compte d’une des étapes nécessaires pour concevoir un mécanisme de tutoriel intégré à une application mobile. Comment, en effet, réussir à  afficher les éléments du tutoriel au-dessus de l’interface manipulée par l’utilisateur ?

En résumé

  • Les objets visuels sont organisés en arbre.
  • L’ordre d’affichage des objets est tout d’abord la racine, ensuite chacun de ses enfants, récursivement (parcours en profondeur), par ordre de z croissant, et pour chaque z par ordre de déclaration.
  • Il est possible de modifier l’arbre en modifiant la valeur de la propriété parent d’un Item. On déplace alors l’Item dans l’arbre visuel, son affichage sera régi par les règles précédentes (on parlera de filiation, via le verbe filier, inventé pour l’occasion).
  • QtQuick.Controls 2 propose un élément visuel de plus haut niveau, référençable via ApplicationWindow.overlay, auquel on peut se filier.
  • Les Popup sont des enfants d’ApplicationWindow.overlay, et ont par défaut un z à zéro.
  • Un élément filié à ApplicationWindow.overlay qui a un z strictement positif est dessiné au-dessus des enfants visuels d’ApplicationWindow et au-dessus des Popup et de ses descendants, avec leur z par défaut à zéro.
  • ApplicationWindow.overlay et ApplicationWindow.contentItem sont des enfants de Window.contentItem

Si vous voulez vérifier ces assertions, je vous invite à lire la suite…

Premiers pas : la hiérarchie visuelle

La hiérarchie, ça se respecte !

En QML, l’ensemble des objets déclarés forme une hiérarchie. Cette hiérarchie est un arbre. Autrement dit, il y a un seul objet à la base, la racine. Cette racine peut avoir des enfants, qui eux-mêmes peuvent en avoir, etc. Certains objets de cette hiérarchie sont des objets graphiques, qu’il faut dessiner. La question que nous posons est : comment vont-ils se superposer les uns les autres, qui cachera qui ? La documentation de Qt décrit très bien ce fonctionnement. Toutefois, je vais reprendre cette description en français et en y ajoutant un principe très utile qui nous permettra de mettre en place notre système de tutoriel intégré.

J’ajoute que nous nous intéressons au cas standard d’utilisation de QML pour fabriquer une interface graphique : nous utilisons Qt Quick.

Enfin, si vous êtes dans une démarche d’apprentissage de la pratique de QML, je vous invite à utiliser Qt Creator et à reproduire les figures illustrant cet article. Le code des exemples est bien celui qui a été utilisé pour les fabriquer. Il est volontairement expurgé de toutes les propriétés de couleur et de position, pour alléger l’article et vous laisser expérimenter.

Ainsi, prenons cet exemple et modifions-le peu à peu pour illustrer le fonctionnement du moteur :

Rectangle
{
  id: root
  Rectangle
  {
    id: child1
    Rectangle
    {
      id: child11
     }
   }
  Rectangle
  {
    id: child2
    Rectangle
    {
      id: child21
    }
  }
  Rectangle { id: child3 }
}

Donne :

Figure 1

Le Rectangle root est situé tout derrière. Parmi ses enfants : child1 est sous child2 qui est sous child3. child2 a un enfant : child21. Cet enfant est au-dessus de child2 mais en dessous de child3.

L’ordre d’affichage par défaut est donc : tout d’abord la racine, ensuite chacun des enfants, récursivement, par ordre de déclaration.

On peut altérer ce dernier point en définissant une valeur à l’attribut z. Ainsi, si j’ajoute z:1 à child2, il sera tracé après que child1 puis child3 seront dessinés. Il sera toujours, dans cette situation, tracé avant child21. On obtient alors :

Figure 2

L’ordre d’affichage paramétré est donc : tout d’abord la racine, ensuite chacun des enfants, récursivement, par ordre de z croissant, pour chaque z par ordre de déclaration.

S’il n’est pas explicitement modifié, z=0. Dans ce cas, observez que l’ordre paramétré est bien identique à l’ordre par défaut.

Maintenant, imaginez que vous vouliez que child11 reste au dessus de tous ces Rectangle.

Pour le moment, child11 est tracé après root et child1, mais avant tous les autres.

QML vous offre la possibilité de modifier child11 de manière à changer son emplacement dans la hiérarchie visuelle des objets. La propriété parent permet de connaître son parent visuel, qui est par défaut, pour les objets déclarés dans du code QML, l’objet contenant le code déclaratif. Ainsi le code déclarant l’objet child11 est situé dans le code déclarant child1. Child1 est le parent de child11.

L’émancipation des enfants

Un solution est de refilier child11 à child21. On sait en effet que child21 est l’élément tracé en dernier, donc au-dessus des autres. Le code serait le suivant .

Rectangle
{
  id: child11
  parent: child21
}

Résultant en :

Figure 3

C’est simple, non ?

Toutefois, si cette méthode fonctionne, que se passe-t-il si vous ajoutez un enfant à root ? Nommons le child4.

Rectangle
{
  id: child4
  z: 2
}

Insérons-le après child3, cela donne :

Figure 4

Vous voulez, pour une raison arbitraire, que child4 soit au-dessus de tous ses frères. Ça fonctionne très bien : child4 a le plus grand z, et est donc tracé après ses frères et leurs enfants. Ce dernier point pose problème, puisque child21 est un enfant de child2, il est tracé avant child4, donc child11, désormais enfant de child21, le sera aussi.

Cette solution de refiliation sur un objet de la hiérarchie n’est pas très évolutive.

La parade est simple : vous pouvez insérer l’Item suivant parmi les enfants de root :

Item
{
  id: lastChild
  z:1000
}

Vous définissez la règle d’utilisation suivante : vous vous interdisez d’insérer un élément enfant de root de plus grand z que lastChild. LastChild est un Item, sans taille définie. Il n’est pas représenté graphiquement et n’a pas d’incidence sur la géométrie de la hiérarchie, y compris son parent. Cela n’empêche pas que ses enfants puissent être visibles.

Désormais, si vous modifiez child11 ainsi :

Rectangle
{
  id: child11
  parent: lastChild
}
Figure 5

Vous vous assurez que child11 est au-dessus de child4 ou de n’importe quel descendant de root, pourvu que personne ne vienne modifier le code en ajoutant par inadvertance un enfant à root avec un z plus grand que 1000.

Pour éviter ce problème, vous pouvez insérer l’ensemble des enfants de root dans un Item (ici : regularChidren). Vous lui créerez également un frère, dont le code sera placé après (ici : topChildren) :

Rectangle
{
  id: root
  Item
  {
    id: regularChidren
    // child1, … sont déclarés ici
  }
  Item
  {
   id: topChildren
  }
}

Vous filiez alors child11 à topChildren, et peu importe le z utilisé dans la hiérarchie sous regularChildrentopChildren sera tracé après, donc child11 également.

La hiérarchie décomposée ? Pas sûr…

C’est une solution plus robuste. Nous avons géré tous les cas, semble-t-il. Ou bien… que se passe-t-il si le code de root est déclaré comme composant QML ? Nommons-le SomeComponent, et utilisons-le comme suit :

Rectangle
{
  id: anotherRoot
  SomeComponent
  {
   id: anotherChild1
  }
  Rectangle
  {
   id: anotherChild2
  }
}

Si on se réfère à la règle d’affichage, l’élément child11 du composant instancié en tant que anotherChild1 sera au-dessus de tous les éléments constituant anotherChild1, mais en dessous d’anotherChild2.

Figure 6

Mazette, le problème est-il sans fin ? La réponse est oui, si on ne fait rien.

L’une des solutions consiste à définir un objet dans notre hiérarchie applicative, qui soit toujours au dessus des autres.

Supposons que la racine de l’arbre des objets soit par exemple un ApplicationWindow. Utilisons la même astuce que précédemment :

ApplicationWindow
{
  id: realRootForSure
  property alias topChildrenOfTheRealRootForSure: topChildren
  Item
  {
    id: regularChidren
    // anotherChild1, anotherChild2 … sont déclarés ici
  }
  Item
  {
    id: topChildren
  }
}

On peut alors modifier notre composant :

Rectangle
{
  id: child11
  parent: realRootForSure.topChildrenOfTheRealRootForSure
}

La propriété topChildren est accessible dans SomeComponent grâce à la règle de visibilité des composants.

Figure 7

Et voilà !

Et voilà ?

Deuxième pas – rester au top des formes

Cachez cette pop-up que je ne saurais voir

Et bien non, il existe en fait des objets particuliers en QML qui utilisent aussi le principe de refiliation, ce sont les Popup de QtQuick.Controls.

Si vous déclarez un Popup (ou un dérivé comme Drawer par exemple), peu importe à quel endroit vous écrivez le code, sans modifier la propriété parent, une fois affiché il sera au-dessus des autres éléments. Sinon, ce ne serait pas une pop-up, pas vrai ?

C’est donc fichu, on ne peut pas afficher d’élément au-dessus d’un Popup ? C’est donc le moment choisi pour ressentir la piqûre anxiogène de la désillusion.

Et bien, en fait, si : il suffit de faire comme Popup, et le coup a été prévu (Ouf ! Que d’émotions).

Overlay, si tu savais

ApplicationWindow a un groupe de propriétés appelées overlay. Attention, il s’agit de l’objet ApplicationWindow de QtQuick.Controls 2 et non pas celui de même nom de QtQuick.Controls tout court (donc 1).

Ce groupe contient un objet en lecture seule : overlay. Le propos d’overlay est, selon la documentation, d’y filier les Popup (Note: depuis Qt 5.10, l’organisation a légèrement changé, mais le cheminement décrit dans cet article peut toujours être suivi).

C’est par conséquent l’objet qui correspond exactement à notre besoin : être toujours au-dessus du reste. Nouvelle modification du code :

Rectangle
{
  id: child11
  parent: ApplicationWindow.overlay
}

Notez que je n’ai pas écrit realRootForSure.overlay. Vous aviez peut-être tiqué quelques lignes plus haut quand j’avais, à l’intérieur d’un composant, fait référence à un id précis, en dehors du composant : realRootForSure.topChildrenOfTheRealRootForSure.

QtQuick.Controls 2 met à notre disposition des propriétés attachées via l’objet d’attachement : ApplicationWindow. Grâce à la propriété attachée ApplicationWindow.overlay, le composant SomeComponent est libéré de la contrainte de connaître le nom de la racine. À l’inverse, imaginez que vous deviez utiliser plusieurs composants issus de bibliothèques et que chaque composant réfère la racine avec un nom différent voire avec un nom déjà utilisé pour autre chose, etc. Bref, il est heureux de pouvoir s’abstenir d’introduire ce genre de dépendance.

Mais, si on reprend la règle d’ordre d’affichage, comment s’assurer que child11 est au-dessus d’un éventuel Popup également affiché ?

Facile : on utilise la propriété z, pour décider de l’ordre d’affichage de tous les enfants d’overlay.

Cela donne au final :

Rectangle
{
  id: child11
  parent: ApplicationWindow.overlay
  z: 1
}

J’ajoute que cette méthode consistant à se filier à ApplicationWindow.overlay peut avoir d’autres destinations que l’affichage. Par exemple je l’utilise pour gérer les événements clavier globalement dans l’application, de cette manière :

Item
{
  id:mainKeyHandler
  focus:true
  visible: false
  Keys.onPressed: handlKeyEvent(event)
  function handlKeyEvent(event)
  {
    if (event.key === Qt.Key_Escape)
    {
      event.accepted = true; Qt.quit(); return;
    }
  }
  onFocusChanged: focus=true
  parent: ApplicationWindow.overlay
}

Après ça, plus aucun problème d’ordre d’affichage ne peut nous menacer. C’est à une saine sérénité que nous pouvons nous adonner désormais.

 

Troisième pas – à la recherche de nos racines

ApplicationWindow vs Window

La solution précédente fait l’hypothèse, plutôt sensée, qu’ApplicationWindow.overlay est un calque situé au-dessus des autres. Il se trouve que j’ai découvert récemment, en déclarant ce bug, qu’ApplicationWindow.contentItem et Window.contentItem étaient distincts. Lever cette confusion est intéressant.

C’est un peu hors-sujet, mais imaginons que vous vouliez prendre un cliché de votre application telle qu’elle apparaît à vos utilisateurs. Il existe une fonction pour ça : Item.grabToImage(). Sur quel Item appliquer cette fonction ?

Ce ne peut pas être ApplicationWindow.contentItem car il pourrait manquer une pop-up affichée au moment du cliché, dans ApplicationWindow.overlay. Ça ne peut pas être non plus ApplicationWindow.overlay puisqu’il manquerait les éléments d’interface qui ne sont pas des pop-up. On pourrait fusionner les deux grâce à des ShaderEffectSource  imbriqués, mais ça commence à ressembler à du bricolage un peu sale. Par ailleurs, il ne faudrait pas que ce ShaderEffectSource  soit un enfant de ApplicationWindow.contentItem ou d’ApplicationWindow.overlay, sinon cela engendrerait une récursion.

Tous ces problèmes et bricolages sont levés en utilisant plutôt Window.contentItem, qui contient donc à la fois ApplicationWindow.contentItem et ApplicationWindow.overlay. Du coup, cela suggère qu’on puisse insérer un élément dans Window.contentItem et que cet élément soit, selon notre règle d’ordre d’affichage, dessiné au-dessus d’ApplicationWindow.overlay et ses enfants.

Ce serait bien de trouver une solution au moins aussi efficace que la précédente, et qui n’aurait pas ce risque.

Par curiosité, je suis allé voir le comment était déterminé la valeur de z d’ApplicationWindow.overlay et c’est assez simple :

QQuickOverlay::QQuickOverlay(QQuickItem *parent)
: QQuickItem(*(new QQuickOverlayPrivate), parent)
{
  Q_D(QQuickOverlay);
  setZ(1000001); // DefaultWindowDecoration+1
  //…
}

C’est une valeur figée, il n’y a pas de mécanisme d’exception. Un Item frère d’ApplicationWindow.overlay avec un z supérieur serait donc dessiné après. Pour qu’un Item apparaisse au-dessus, il suffit de le filier à cet Item frère. Pour éviter de dépendre de la valeur en dur dans le code ci-dessus, qui n’est pas spécifiée et peut changer à tout moment, il suffit de la lire :

property real topMostLayerZ: overlay.z + 1

Et bingo, il suffit de créer un layer à ce niveau pour être au-dessus de tout le contenu d’overlay, pourvu qu’il ne soit pas refilié.

Item
{
  id: topMostLayer
  z: topMostLayerZ
  parent: Window.contentItem
}

Cette solution réintroduit une dépendance de nom dans le composant qu’on souhaiterait filier. Une solution pour en diminuer le risque serait de définir ces noms dans un singleton. Une collision de noms est toujours possible, mais m’apparaît (sans réelle justification) moins probable.

Et si je n’étais pas seul au monde ?

Oui, je sais, c’est une idée saugrenue, mais certains y croient. Je veux dire, j’y crois. Bref, et si un autre Item avait été inséré par un indélicat, avec un z plus grand que celui de votre topMostLayer ? Ah, zut. Bon, normalement, vous contrôlez ce qui se passe dans votre application, mais ne peut-on pas s’arranger pour limiter ce problème ? Mais bien sûr que si, voyons !

La solution définitive, la voici :

Item
{
  id: anItemInTheWindow
  property var myWindowContentItem: Window.contentItem
}
property alias myWindowContentItem: anItemInTheWindow.myWindowContentItem
property real zAboveAll: 0
Component.onCompleted:
{
  var it = myWindowContentItem
  var c = it.children;
  for (var j=0; j<c.length; j++)
  if (c[j].z > zAboveAll)
    zAboveAll = c[j].z;
  zAboveAll++;
}

Vous n’avez plus que l’embarras du choix pour utiliser cette propriété. Par exemple filier vos objets à un Item déterminé.

Item
{
  parent: Window.contentItem
  z: zAboveAll
}

Oui, mais, et si ça change dynamiquement ?

Bon, et ce sera mon avant-dernier mot avant de faire face à des cas plus concrets :

Connections
{
  target: myWindowContentItem
  onChildrenChanged: /* recalcul de zAboveAll */
}

Et pourquoi pas une connexion sur les signaux de changement de z de chaque enfant de Window.contentItem tant qu’on y est ?

Notes et considérations annexes

Théorème : le chemin le plus court entre deux points est celui qu’on arrive à prendre.

Et x ? Et y ?

J’ajoute par ailleurs que j’ai passé sous silence une particularité cruciale liée à l’affichage, et ce pour nous concentrer uniquement sur l’ordre d’affichage. Cette particularité, c’est la position sur l’écran. Indépendamment de l’ordre dans lequel les objets sont affichés, ils ont une position sur l’écran. Le positionnement par défaut dans QML est relatif au parent. Je n’aborde pas ce sujet qui n’est pas simple, mais vous devez savoir qu’en modifiant le parent d’un Item, vous modifiez également le repère de son positionnement.

Et si z est négatif ?

Ça fonctionne aussi. Le seul point notable est que si un Item a un z négatif, alors il sera dessiné sous son parent visuel.

Filier n’existe pas. Parenter non plus.

Mais si. La preuve ? On n’a pas arrêté de le faire tout au long de l’article.

Alors, oui, c’est sûr, mon petit CNRTL me dit qu’apparenter, lui, existe bien. Officiellement. Il est enregistré. Correct. Solide. Sûr. Pur. Mais… il ne signifie pas exactement ce qu’on désire signifier. C’est peut-être insignifiant comme détail, mais je ne le crois pas. Faute de mieux, ce sera lui. Et j’en enfante, la faute m’incombe, ne vous en encombrez pas.

Il existe par ailleurs le terme filiation. Il désigne la relation entre un parent et ses enfants, ou entre un enfant et ses parents. Parfois limité à la première génération, le sens peut porter sur toute l’ascendance. C’est une bonne piste, mais il n’y a pas de verbe d’action pour filier un enfant à un parent. On n’a qu’à dire que si, et hop, on l’utilise.

Il existe bien le terme adopter, mais si le résultat est similaire, l’obtention d’une filiation, l’acteur est le parent et non un tiers qui filierait un enfant à un parent.

Vos propositions à ce sujet sont les bienvenues.

2 commentaires

Répondre à MBA Annuler la réponse

Votre adresse e-mail ne sera pas publiée. Les champs obligatoires sont indiqués avec *