Aleteo. RenderObject - Medir y conquistar

Hola a todos, mi nombre es Dmitry Andriyanov. Soy un desarrollador de Flutter en Surf. La biblioteca principal de Flutter es suficiente para crear una interfaz de usuario eficiente y productiva. Pero hay ocasiones en las que necesita implementar casos específicos y luego tiene que profundizar más.







Introductorio



Hay una pantalla con muchos campos de texto. Puede haber 5 o 30. Entre ellos puede haber varios widgets.







Tarea



  • Coloque un bloque con el botón "Siguiente" encima del teclado para pasar al siguiente campo.
  • Al cambiar el enfoque, desplácese por el campo hasta el bloque con el botón "Siguiente".


Problema



El bloque con el botón se superpone al campo de texto. Es necesario implementar el desplazamiento automático por el tamaño del espacio superpuesto del campo de texto.







Preparándose para una solución



1. Tomemos una pantalla de 20 campos.



El código:



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),
               )
           ],
         ),
       ),
     ),
   ),
 );
}


Con el foco en el campo de texto, vemos la siguiente imagen: El







campo es perfectamente visible y todo está en orden.



2. Agregue un bloque con un botón. La superposición se







utiliza para mostrar el bloque . Esto le permite mostrar la placa independientemente de los widgets en la pantalla y no utilizar los contenedores de pila. Al mismo tiempo, no tenemos interacción directa entre los campos y el bloque "Siguiente". Buen artículo sobre superposición. En resumen: Overlay le permite superponer widgets sobre otros widgets, a través de la pila de superposición. OverlayEntry le permite controlar la superposición correspondiente. El código:















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. Como era de esperar, el bloque se superpone al margen.



Ideas de solución



1. Tome la posición actual de desplazamiento de la pantalla del ScrollController y desplácese hasta el campo.

Se desconoce el tamaño del campo, especialmente si es de varias líneas, luego desplazarse hasta él dará un resultado inexacto. La solución no será perfecta ni flexible.



2. Agregue los tamaños de los widgets fuera de la lista y tenga en cuenta el desplazamiento.

Si configura los widgets a una altura fija, entonces, conociendo la posición del desplazamiento y el tamaño de los widgets, sabrá qué hay ahora en la zona de visibilidad y cuánto necesita desplazarse para mostrar un determinado widget.



Contras :



  • Deberá tener en cuenta todos los widgets fuera de la lista y configurarlos en tamaños fijos que se utilizarán en los cálculos, lo que no siempre se corresponde con el diseño requerido y el comportamiento de la interfaz.

  • Las ediciones de la interfaz de usuario darán lugar a revisiones en los cálculos.


3. Tome la posición de los widgets en relación con la pantalla del campo y el bloque "Siguiente" y lea la diferencia.



Menos : no existe tal posibilidad de fábrica.



4. Utilice una capa de renderizado.



Según el artículo , Flutter sabe cómo organizar a sus descendientes en el árbol, lo que significa que esta información se puede extraer. RenderObject se encarga del renderizado , nos dirigiremos a él. El RenderBox tiene un cuadro de tamaño con el ancho y alto del widget. Se calculan al renderizar para widgets: ya sean listas, contenedores, campos de texto (incluso de varias líneas), etc.



Puede obtener el RenderBox a través

context context.findRenderObject() as RenderBox


Puede utilizar GlobalKey para obtener el contexto de un campo.



Menos :



GlobalKey no es la cosa más fácil. Y es mejor usarlo lo menos posible.



“Los widgets con claves globales vuelven a dibujar sus subárboles a medida que se mueven de una ubicación en el árbol a otra. Para volver a dibujar su subárbol, el widget debe llegar a su nueva ubicación en el árbol en el mismo cuadro de animación en el que se eliminó de la ubicación anterior.



Las claves globales son relativamente caras en términos de rendimiento. Si no necesita ninguna de las funciones enumeradas anteriormente, considere usar Key, ValueKey, ObjectKey o UniqueKey.



No puede incluir dos widgets en un árbol con la misma clave global al mismo tiempo. Si intenta hacer esto, se producirá un error de tiempo de ejecución ". Fuente .



De hecho, si mantienes 20 GlobalKey en pantalla no pasará nada malo, pero como se recomienda usarlo solo cuando sea necesario, intentaremos buscar otra forma.



Solución sin GlobalKey



Usaremos una capa de renderizado. El primer paso es verificar si podemos sacar algo del RenderBox y si estos son los datos que necesitamos.



Código de prueba de hipótesis:



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) Dado que necesita desplazarse al campo, necesita obtener su contexto (por ejemplo, a través de FocusNode), encontrar el RenderBox y tomar el tamaño. Pero este es el tamaño del cuadro de texto y si también necesitamos widgets principales (por ejemplo, Padding), debemos llevar el RenderBox principal a través del campo principal.



(2) Heredamos nuestra clase RenderWrapper de SingleChildRenderObjectWidget y creamos un RenderProxyBox para ella. RenderProxyBox simula todas las propiedades del niño y las muestra cuando se representa el árbol de widgets.

El propio Flutter a menudo usa herederos de SingleChildRenderObjectWidget:

Align, AnimatedSize, SizedBox, Opacity, Padding.



(3) Atraviese recursivamente los padres a través del árbol hasta encontrar un RenderWrapper.



(4) Tome parent.size.height: esto le dará la altura correcta. Esta es la manera correcta.



Por supuesto, no puedes irte de esta manera.



Pero el enfoque recursivo también tiene sus inconvenientes :



  • El recorrido recursivo del árbol no garantiza que no nos encontraremos con un antepasado para el que no estemos preparados. Puede que no se ajuste al tipo y eso es todo. De alguna manera en las pruebas me encontré con RenderView y todo cayó. Por supuesto, puede ignorar al antepasado inadecuado, pero desea un enfoque más confiable.
  • Esta es una solución inmanejable y aún no flexible.


Usando RenderObject



Este enfoque resultó del paquete render_metrics y se ha utilizado durante mucho tiempo en una de nuestras aplicaciones.



Lógica de operación:



1. Envuelva el widget de interés (un descendiente de la clase Widget) en RenderMetricsObject . El widget de anidación y destino no importa.



RenderMetricsObject(
 child: ...,
)


2. Después del primer marco, sus métricas estarán disponibles para nosotros. Si el tamaño o la posición del widget en relación con la pantalla (absoluto o en desplazamiento), cuando se soliciten nuevamente las métricas, habrá nuevos datos.



3. No es necesario usar el RenderManager , pero cuando lo use, debe pasar la identificación del widget.



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


4. Puede utilizar devoluciones de llamada:



  • onMount: crea RenderObject. Recibe la identificación pasada (o nula si no se ha pasado) y la instancia de RenderMetricsBox correspondiente como argumentos.
  • onUnMount: eliminación del árbol.


En los parámetros, la función recibe el id que se pasa a RenderMetricsObject. Estas funciones son útiles cuando no necesita un administrador y / o necesita saber cuándo se creó y eliminó un RenderObject del árbol.



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


5. Obtención de métricas. La clase RenderMetricsBox implementa un captador de datos, en el que toma sus dimensiones a través de localToGlobal. localToGlobal convierte el punto del sistema de coordenadas local para este RenderBox en el sistema de coordenadas global relativo a la pantalla en píxeles lógicos.







A: el ancho del widget, convertido al punto más a la derecha de las coordenadas relativas a la pantalla.



B: la altura se convierte en el punto de coordenadas más bajo en relación con la pantalla.



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 es simplemente una clase de datos que proporciona valores xey separados como dobles y puntos de coordenadas como CoordsMetrics .



7. ComparisonDiff : restar dos RenderData devuelve una instancia de ComparisonDiff con la diferencia entre ellos. También proporciona un captador (diffTopToBottom) para la diferencia de posición entre la parte inferior del primer widget y la parte superior del segundo, y viceversa (diffBottomToTop). diffLeftToRight y diffRightToLeft respectivamente.



8. RenderParametersManager es un descendiente de RenderManager. Para obtener métricas de widgets y la diferencia entre ellas.



El código:



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


Resultado usando render_metrics







Salir



Profundizando más allá del nivel del widget, con la ayuda de pequeñas manipulaciones con la capa de render, obtuvimos una funcionalidad útil que le permite escribir una interfaz de usuario y lógica más complejas. A veces es necesario conocer el tamaño de los widgets dinámicos, su posición o comparar los widgets superpuestos. Y esta biblioteca proporciona todas estas funciones para una resolución de problemas más rápida y eficiente. En el artículo, traté de explicar el mecanismo de funcionamiento, di un ejemplo de un problema y una solución. Espero el beneficio de la biblioteca, los artículos y sus comentarios.



All Articles