Battement. RenderObject - Mesurer et conquérir

Bonjour à tous, je m'appelle Dmitry Andriyanov. Je suis développeur Flutter chez Surf. La bibliothèque principale Flutter est suffisante pour créer une interface utilisateur efficace et productive. Mais il y a des moments où vous avez besoin de mettre en œuvre des cas spécifiques et ensuite vous devez creuser plus profondément.







Introduction



Il y a un écran avec de nombreux champs de texte. Il peut y en avoir 5 ou 30. Entre eux, il peut y avoir différents widgets.







Tâche



  • Placez un bloc avec le bouton "Suivant" au-dessus du clavier pour passer au champ suivant.
  • Lors du changement de focus, faites défiler le champ jusqu'au bloc avec le bouton "Suivant".


Problème



Le bloc avec le bouton chevauche le champ de texte. Il est nécessaire d'implémenter le défilement automatique de la taille de l'espace de chevauchement du champ de texte.







Se préparer à une solution



1. Prenons un écran de 20 champs.



Le code:



List<String> list = List.generate(20, (index) => index.toString());

@override
Widget build(BuildContext context) {
 return Scaffold(
   body: SingleChildScrollView(
     child: SafeArea(
       child: Padding(
         padding: const EdgeInsets.all(20),
         child: Column(
           children: <Widget>[
             for (String value in list)
               TextField(
                 decoration: InputDecoration(labelText: value),
               )
           ],
         ),
       ),
     ),
   ),
 );
}


Avec le focus dans le champ de texte, nous voyons l'image suivante: Le







champ est parfaitement visible et tout est en ordre.



2. Ajoutez un bloc avec un bouton. La superposition est







utilisée pour afficher le bloc . Cela vous permet d'afficher la plaque indépendamment des widgets à l'écran et de ne pas utiliser les wrappers de pile. Dans le même temps, nous n'avons aucune interaction directe entre les champs et le bloc "Suivant". Bel article sur Overlay. En bref: Overlay vous permet de superposer des widgets par-dessus d'autres widgets, via la pile de superposition. OverlayEntry vous permet de contrôler la superposition correspondante. Le code:















bool _isShow = false;
OverlayEntry _overlayEntry;

KeyboardListener _keyboardListener;

@override
void initState() {
 SchedulerBinding.instance.addPostFrameCallback((_) {
   _overlayEntry = OverlayEntry(builder: _buildOverlay);
   Overlay.of(context).insert(_overlayEntry);
   _keyboardListener = KeyboardListener()
     ..addListener(onChange: _keyboardHandle);
 });
 super.initState();
}

@override
void dispose() {
 _keyboardListener.dispose();
 _overlayEntry.remove();
 super.dispose();
}
Widget _buildOverlay(BuildContext context) {
 return Stack(
   children: <Widget>[
     Positioned(
       bottom: MediaQuery.of(context).viewInsets.bottom,
       left: 0,
       right: 0,
       child: AnimatedOpacity(
         duration: const Duration(milliseconds: 200),
         opacity: _isShow ? 1.0 : 0.0,
         child: NextBlock(
           onPressed: () {},
           isShow: _isShow,
         ),
       ),
     ),
   ],
 );
void _keyboardHandle(bool isVisible) {
 _isShow = isVisible;
 _overlayEntry?.markNeedsBuild();
}


3. Comme prévu, le bloc chevauche la marge.



Idées de solutions



1. Prenez la position de défilement actuelle de l'écran à partir du ScrollController et faites défiler jusqu'au champ.

Les dimensions du champ sont inconnues, surtout s'il est multiligne, alors le défilement vers celui-ci donnera un résultat inexact. La solution ne sera ni parfaite ni flexible.



2. Ajoutez les tailles des widgets en dehors de la liste et tenez compte du défilement.

Si vous définissez les widgets à une hauteur fixe, alors, connaissant la position du défilement et la taille des widgets, vous saurez ce qu'il y a maintenant dans la zone de visibilité et combien vous devez faire défiler pour afficher un certain widget.



Inconvénients :



  • Vous devrez prendre en compte tous les widgets en dehors de la liste et leur définir des tailles fixes qui seront utilisées dans les calculs, ce qui ne correspond pas toujours au comportement de conception et d'interface requis.

  • Les modifications de l'interface utilisateur entraîneront des révisions dans les calculs.


3. Prenez la position des widgets par rapport à l'écran du champ et du bloc "Suivant" et lisez la différence.



Moins - il n'y a pas une telle possibilité hors de la boîte.



4. Utilisez un calque de rendu.



Sur la base de l' article , Flutter sait comment organiser ses descendants dans l'arbre, ce qui signifie que ces informations peuvent être extraites. RenderObject est responsable du rendu , nous allons y aller. Le RenderBox a une zone de taille avec la largeur et la hauteur du widget. Ils sont calculés lors du rendu des widgets: qu'il s'agisse de listes, de conteneurs, de champs de texte (même multilignes), etc.



Vous pouvez faire passer le RenderBox

context context.findRenderObject() as RenderBox


Vous pouvez utiliser GlobalKey pour obtenir le contexte d'un champ.



Moins :



GlobalKey n'est pas la chose la plus simple. Et il vaut mieux l'utiliser le moins possible.



«Les widgets dotés de clés globales redessinent leurs sous-arborescences lorsqu'ils se déplacent d'un emplacement dans l'arborescence à un autre. Pour redessiner son sous-arbre, le widget doit arriver à son nouvel emplacement dans l'arborescence dans la même image d'animation dans laquelle il a été supprimé de l'ancien emplacement.



Les clés globales sont relativement chères en termes de performances. Si vous n'avez besoin d'aucune des fonctionnalités répertoriées ci-dessus, envisagez d'utiliser Key, ValueKey, ObjectKey ou UniqueKey.



Vous ne pouvez pas inclure simultanément deux widgets dans une arborescence avec la même clé globale. Si vous essayez de faire cela, il y aura une erreur d'exécution. " Source .



En fait, si vous gardez 20 GlobalKey à l'écran, rien de mal ne se passera, mais comme il est recommandé de ne l'utiliser que lorsque cela est nécessaire, nous essaierons de chercher un autre moyen.



Solution sans GlobalKey



Nous utiliserons une couche de rendu. La première étape consiste à vérifier si nous pouvons extraire quelque chose de RenderBox et si ce sont les données dont nous avons besoin.



Code de test d'hypothèse:



FocusNode get focus => widget.focus;
 @override
 void initState() {
   super.initState();
   Future.delayed(const Duration(seconds: 1)).then((_) {
	// (1)
     RenderBox rb = (focus.context.findRenderObject() as RenderBox);
//(3)
     RenderBox parent = _getParent(rb);
//(4)
     print('parent = ${parent.size.height}');
   });
 }
 RenderBox _getParent(RenderBox rb) {
   return rb.parent is RenderWrapper ? rb.parent : _getParent(rb.parent);
 }

Widget build(BuildContext context) {
   return Wrapper(
     child: Container(
       color: Colors.red,
       width: double.infinity,
       height: 100,
       child: Center(
         child: TextField(
           focusNode: focus,
         ),
       ),
     ),
   );
}

//(2)
class Wrapper extends SingleChildRenderObjectWidget {
 const Wrapper({
   Key key,
   Widget child,
 }) : super(key: key, child: child);
 @override
 RenderWrapper createRenderObject(BuildContext context) {
   return RenderWrapper();
 }
}
class RenderWrapper extends RenderProxyBox {
 RenderWrapper({
   RenderBox child,
 }) : super(child);
}


(1) Puisque vous devez faire défiler jusqu'au champ, vous devez obtenir son contexte (par exemple, via FocusNode), trouver le RenderBox et prendre la taille. Mais c'est la taille de la zone de texte, et si nous avons également besoin de widgets parents (par exemple, Padding), nous devons prendre le RenderBox parent via le champ parent.



(2) Nous héritons de notre classe RenderWrapper de SingleChildRenderObjectWidget et créons un RenderProxyBox pour cela. RenderProxyBox simule toutes les propriétés de l'enfant et l'affiche lorsque l'arborescence des widgets est rendue.

Flutter lui-même utilise souvent les héritiers de SingleChildRenderObjectWidget:

Align, AnimatedSize, SizedBox, Opacity, Padding.



(3) Traversez récursivement les parents à travers l'arbre jusqu'à ce que nous rencontrions un RenderWrapper.



(4) Prenez parent.size.height - cela donnera la bonne hauteur. C'est le bon chemin.



Bien sûr, vous ne pouvez pas partir de cette façon.



Mais l'approche récursive a aussi ses inconvénients :



  • La traversée d'arbre récursive ne garantit pas que nous ne rencontrerons pas un ancêtre pour lequel nous ne sommes pas prêts. Il peut ne pas correspondre au type et c'est tout. Pendant les tests, j'ai rencontré RenderView et tout est tombé. Vous pouvez, bien sûr, ignorer l'ancêtre inapproprié, mais vous voulez une approche plus fiable.
  • C'est une solution ingérable et toujours pas flexible.


Utilisation de RenderObject



Cette approche est le résultat du package render_metrics et a longtemps été utilisée dans l'une de nos applications.



Logique d'opération:



1. Enveloppez le widget d'intérêt (un descendant de la classe Widget) dans RenderMetricsObject . L'imbrication et le widget cible n'ont pas d'importance.



RenderMetricsObject(
 child: ...,
)


2. Après la première image, ses métriques nous seront disponibles. Si la taille ou la position du widget par rapport à l'écran (absolue ou en défilement), alors lorsque les métriques sont à nouveau demandées, il y aura de nouvelles données.



3. Il n'est pas nécessaire d' utiliser le RenderManager , mais lorsque vous l'utilisez, vous devez transmettre l'ID du widget.



RenderMetricsObject(
 id: _text1Id,
 manager: renderManager,
 child: ...


4. Vous pouvez utiliser les rappels:



  • onMount - Créez RenderObject. Reçoit l'ID passé (ou null, s'il n'est pas passé) et l'instance de RenderMetricsBox correspondante en tant qu'arguments.
  • onUnMount - suppression de l'arborescence.


Dans les paramètres, la fonction reçoit l'id passé à RenderMetricsObject. Ces fonctions sont utiles lorsque vous n'avez pas besoin d'un gestionnaire et / ou que vous avez besoin de savoir quand un RenderObject a été créé et supprimé de l'arborescence.



RenderMetricsObject(
 id: _textBlockId,
 onMount: (id, box) {},
 onUnMount: (box) {},
 child...
)


5. Obtention de métriques. La classe RenderMetricsBox implémente un getter de données, dans lequel elle prend ses dimensions via localToGlobal. localToGlobal convertit le point du système de coordonnées local pour ce RenderBox en système de coordonnées global par rapport à l'écran en pixels logiques.







A - la largeur du widget, convertie au point de coordonnées le plus à droite par rapport à l'écran.



B - La hauteur est convertie en point de coordonnées le plus bas par rapport à l'écran.



class RenderMetricsBox extends RenderProxyBox {
 RenderData get data {
   Size size = this.size;
   double width = size.width;
   double height = size.height;
   Offset globalOffset = localToGlobal(Offset(width, height));
   double dy = globalOffset.dy;
   double dx = globalOffset.dx;

   return RenderData(
     yTop: dy - height,
     yBottom: dy,
     yCenter: dy - height / 2,
     xLeft: dx - width,
     xRight: dx,
     xCenter: dx - width / 2,
     width: width,
     height: height,
   );
 }

 RenderMetricsBox({
   RenderBox child,
 }) : super(child);
}


6. RenderData est simplement une classe de données qui fournit des valeurs x et y individuelles en tant que doubles et des points de coordonnées en tant que CoordsMetrics .



7. ComparisonDiff - La soustraction de deux RenderData renvoie une instance de ComparisonDiff avec la différence entre eux. Il fournit également un getter (diffTopToBottom) pour la différence de position entre le bas du premier widget et le haut du second, et vice versa (diffBottomToTop). diffLeftToRight et diffRightToLeft respectivement.



8. RenderParametersManager est un descendant de RenderManager. Pour obtenir les métriques de widget et la différence entre elles.



Le code:



class RenderMetricsScreen extends StatefulWidget {
 @override
 State<StatefulWidget> createState() => _RenderMetricsScreenState();
}

class _RenderMetricsScreenState extends State<RenderMetricsScreen> {
 final List<String> list = List.generate(20, (index) => index.toString());
 ///    render_metrics
 ///      
 final _renderParametersManager = RenderParametersManager();
 final ScrollController scrollController = ScrollController();
 /// id    ""
 final doneBlockId = 'doneBlockId';
 final List<FocusNode> focusNodes = [];

 bool _isShow = false;
 OverlayEntry _overlayEntry;
 KeyboardListener _keyboardListener;
 ///   FocusNode,    
 FocusNode lastFocusedNode;

 @override
 void initState() {
   SchedulerBinding.instance.addPostFrameCallback((_) {
     _overlayEntry = OverlayEntry(builder: _buildOverlay);
     Overlay.of(context).insert(_overlayEntry);
     _keyboardListener = KeyboardListener()
       ..addListener(onChange: _keyboardHandle);
   });

   FocusNode node;

   for(int i = 0; i < list.length; i++) {
     node = FocusNode(debugLabel: i.toString());
     focusNodes.add(node);
     node.addListener(_onChangeFocus(node));
   }

   super.initState();
 }

 @override
 void dispose() {
   _keyboardListener.dispose();
   _overlayEntry.remove();
   focusNodes.forEach((node) => node.dispose());
   super.dispose();
 }

 @override
 Widget build(BuildContext context) {
   return Scaffold(
     body: SingleChildScrollView(
       controller: scrollController,
       child: SafeArea(
         child: Padding(
           padding: const EdgeInsets.all(20),
           child: Column(
             children: <Widget>[
               for (int i = 0; i < list.length; i++)
                 RenderMetricsObject(
                   id: focusNodes[i],
                   manager: _renderParametersManager,
                   child: TextField(
                     focusNode: focusNodes[i],
                     decoration: InputDecoration(labelText: list[i]),
                   ),
                 ),
             ],
           ),
         ),
       ),
     ),
   );
 }

 Widget _buildOverlay(BuildContext context) {
   return Stack(
     children: <Widget>[
       Positioned(
         bottom: MediaQuery.of(context).viewInsets.bottom,
         left: 0,
         right: 0,
         child: RenderMetricsObject(
           id: doneBlockId,
           manager: _renderParametersManager,
           child: AnimatedOpacity(
             duration: const Duration(milliseconds: 200),
             opacity: _isShow ? 1.0 : 0.0,
             child: NextBlock(
               onPressed: () {},
               isShow: _isShow,
             ),
           ),
         ),
       ),
     ],
   );
 }

 VoidCallback _onChangeFocus(FocusNode node) => () {
   if (!node.hasFocus) return;
   lastFocusedNode = node;
   _doScrollIfNeeded();
 };

 /// ,      
 /// .
 void _doScrollIfNeeded() async {
   if (lastFocusedNode == null) return;
   double scrollOffset;

   try {
     ///    id,  data    null
     scrollOffset = await _calculateScrollOffset();
   } catch (e) {
     return;
   }

   _doScroll(scrollOffset);
 }

 ///   
 void _doScroll(double scrollOffset) {
   double offset = scrollController.offset + scrollOffset;
   if (offset < 0) offset = 0;
   scrollController.position.animateTo(
     offset,
     duration: const Duration(milliseconds: 200),
     curve: Curves.linear,
   );
 }

 ///     .
 ///
 ///         ""  
 ///  (/).
 Future<double> _calculateScrollOffset() async {
   await Future.delayed(const Duration(milliseconds: 300));

   ComparisonDiff diff = _renderParametersManager.getDiffById(
     lastFocusedNode,
     doneBlockId,
   );

   lastFocusedNode = null;

   if (diff == null || diff.firstData == null || diff.secondData == null) {
     return 0.0;
   }
   return diff.diffBottomToTop;
 }

 void _keyboardHandle(bool isVisible) {
   _isShow = isVisible;
   _overlayEntry?.markNeedsBuild();
 }
}


Résultat utilisant render_metrics







Résultat



En creusant plus profondément que la couche de widgets, à l'aide de petites manipulations avec la couche de rendu, nous avons obtenu des fonctionnalités utiles qui vous permettent d'écrire une interface utilisateur et une logique plus complexes. Parfois, vous devez connaître la taille des widgets dynamiques, leur position ou comparer les widgets qui se chevauchent. Et cette bibliothèque fournit toutes ces fonctionnalités pour une résolution de problèmes plus rapide et plus efficace. Dans l'article, j'ai essayé d'expliquer le mécanisme de fonctionnement, donné un exemple de problème et une solution. J'espère pour le bénéfice de la bibliothèque, des articles et de vos commentaires.



All Articles