Flutter. RenderObject - Medir e Conquistar

Olá a todos, meu nome é Dmitry Andriyanov. Sou desenvolvedor de Flutter no Surf. A biblioteca principal do Flutter é suficiente para construir uma IU eficiente e produtiva. Mas há momentos em que você precisa implementar casos específicos e depois ir mais fundo.







Introdutório



Existe uma tela com muitos campos de texto. Pode haver 5 ou 30. Entre eles, pode haver vários widgets.







Tarefa



  • Coloque um bloco com o botão "Próximo" acima do teclado para alternar para o próximo campo.
  • Ao mudar o foco, role o campo até o bloco com o botão "Avançar".


Problema



O bloco com o botão se sobrepõe ao campo de texto. É necessário implementar a rolagem automática pelo tamanho do espaço de sobreposição do campo de texto.







Preparando-se para uma solução



1. Vamos pegar uma tela de 20 campos.



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


Com foco no campo de texto, vemos a seguinte imagem: O







campo está perfeitamente visível e tudo está em ordem.



2. Adicione um bloco com um botão. Sobreposição é







usada para exibir o bloco . Isso permite que você mostre a placa independentemente dos widgets na tela e não use os empacotadores de pilha. Ao mesmo tempo, não temos interação direta entre os campos e o bloco "Próximo". Bom artigo sobre sobreposição. Resumindo: Overlay permite sobrepor widgets em cima de outros widgets, por meio da pilha de overlay. OverlayEntry permite que você controle o Overlay correspondente. O 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 esperado, o bloco se sobrepõe à margem.



Ideias de Soluções



1. Obtenha a posição de rolagem atual da tela do ScrollController e vá até o campo.

O tamanho do campo é desconhecido, especialmente se for multilinhas, então rolar até ele fornecerá um resultado impreciso. A solução não será perfeita nem flexível.



2. Adicione os tamanhos dos widgets fora da lista e leve em consideração a rolagem.

Se você definir os widgets para uma altura fixa, então, sabendo a posição do scroll e o tamanho dos widgets, você saberá o que está agora na zona de visibilidade e quanto você precisa rolar para mostrar um determinado widget.



Contras :



  • Você terá que levar em consideração todos os widgets fora da lista e defini-los com tamanhos fixos que serão usados ​​nos cálculos, o que nem sempre corresponde ao design e comportamento da interface necessários.

  • As edições da IU levarão a revisões nos cálculos.


3. Pegue a posição dos widgets em relação à tela do campo e ao bloco "Próximo" e leia a diferença.



Menos - não existe essa possibilidade fora da caixa.



4. Use uma camada de renderização.



Com base no artigo , Flutter sabe como organizar seus descendentes na árvore, o que significa que essa informação pode ser puxada. RenderObject é responsável pela renderização , nós iremos para isso. O RenderBox tem uma caixa de tamanho com a largura e altura do widget. Eles são calculados durante a renderização de widgets: sejam listas, contêineres, campos de texto (mesmo os com várias linhas), etc.



Você pode obter o RenderBox através de

context context.findRenderObject() as RenderBox


Você pode usar a GlobalKey para obter o contexto de um campo.



Minus :



GlobalKey não é a coisa mais fácil. E é melhor usar o mínimo possível.



“Widgets com chaves globais redesenham suas subárvores à medida que se movem de um local para outro na árvore. Para redesenhar sua subárvore, o widget deve chegar em seu novo local na árvore no mesmo quadro de animação em que foi removido do local antigo.



As chaves globais são relativamente caras em termos de desempenho. Se você não precisa de nenhum dos recursos listados acima, considere o uso de Key, ValueKey, ObjectKey ou UniqueKey.



Você não pode incluir dois widgets em uma árvore com a mesma chave global ao mesmo tempo. Se você tentar fazer isso, ocorrerá um erro de tempo de execução. " Fonte .



Na verdade, se você mantiver 20 GlobalKey na tela, nada de ruim acontecerá, mas como é recomendado usá-lo somente quando necessário, tentaremos buscar outro caminho.



Solução sem GlobalKey



Estaremos usando uma camada de renderização. O primeiro passo é verificar se podemos extrair algo do RenderBox e se esses são os dados de que precisamos.



Código de teste de hipóteses:



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) Visto que você precisa rolar até o campo, você precisa obter seu contexto (por exemplo, via FocusNode), encontrar o RenderBox e determinar o tamanho. Mas este é o tamanho da caixa de texto e se também precisarmos de widgets pai (por exemplo, Padding), precisamos levar o RenderBox pai por meio do campo pai.



(2) Herdamos nossa classe RenderWrapper de SingleChildRenderObjectWidget e criamos um RenderProxyBox para ela. RenderProxyBox simula todas as propriedades do filho, exibindo-o quando a árvore do widget é renderizada.

O próprio Flutter costuma usar herdeiros de SingleChildRenderObjectWidget:

Align, AnimatedSize, SizedBox, Opacity, Padding.



(3) Percorra recursivamente os pais através da árvore até encontrar um RenderWrapper.



(4) Pegue parent.size.height - isso dará a altura correta. Este é o caminho certo.



Claro, você não pode sair desta forma.



Mas a abordagem recursiva também tem suas desvantagens :



  • A travessia recursiva da árvore não garante que não encontraremos um ancestral para o qual não estejamos prontos. Ele pode não se encaixar no tipo e é isso. De alguma forma, durante os testes, encontrei RenderView e tudo caiu. Você pode, é claro, ignorar o ancestral inadequado, mas deseja uma abordagem mais confiável.
  • Esta é uma solução incontrolável e ainda não flexível.


Usando RenderObject



Essa abordagem resultou do pacote render_metrics e tem sido usada há muito tempo em um de nossos aplicativos.



Lógica de operação:



1. Envolva o widget de interesse (um descendente da classe Widget) em RenderMetricsObject . O aninhamento e o widget de destino não importam.



RenderMetricsObject(
 child: ...,
)


2. Após o primeiro quadro, suas métricas estarão disponíveis para nós. Se for o tamanho ou posição do widget em relação à tela (absoluta ou em rolagem), então quando as métricas forem solicitadas novamente, haverá novos dados.



3. Não é necessário usar o RenderManager , mas ao usá-lo deve-se passar o id do widget.



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


4. Você pode usar callbacks:



  • onMount - Cria RenderObject. Recebe o id passado (ou nulo, se não passado) e a instância RenderMetricsBox correspondente como argumentos.
  • onUnMount - remoção da árvore.


Nos parâmetros, a função recebe o id passado para RenderMetricsObject. Essas funções são úteis quando você não precisa de um gerenciador e / ou precisa saber quando um RenderObject foi criado e removido da árvore.



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


5. Obtenção de métricas. A classe RenderMetricsBox implementa um getter de dados, no qual assume suas dimensões por meio de localToGlobal. localToGlobal converte o ponto do sistema de coordenadas local para este RenderBox para o sistema de coordenadas global relativo à tela em pixels lógicos.







A - a largura do widget, convertida para o ponto mais à direita das coordenadas em relação à tela.



B - A altura é convertida para o ponto de coordenada mais baixo em relação à tela.



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 é simplesmente uma classe de dados que fornece valores separados de xey como pontos duplos e coordenados como CoordsMetrics .



7. ComparisonDiff - Subtrair dois RenderData retorna uma instância ComparisonDiff com a diferença entre eles. Ele também fornece um getter (diffTopToBottom) para a diferença de posição entre a parte inferior do primeiro widget e a parte superior do segundo e vice-versa (diffBottomToTop). diffLeftToRight e diffRightToLeft respectivamente.



8. RenderParametersManager é um descendente de RenderManager. Para obter as métricas do widget e a diferença entre eles.



O 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







Resultado



Indo mais fundo do que o nível do widget, com a ajuda de pequenas manipulações com a camada de renderização, obtivemos uma funcionalidade útil que permite a você escrever UI e lógica mais complexas. Às vezes, você precisa saber o tamanho dos widgets dinâmicos, sua posição ou comparar os widgets sobrepostos. E essa biblioteca fornece todos esses recursos para uma solução de problemas mais rápida e eficiente. No artigo, tentei explicar o mecanismo de funcionamento, dei um exemplo de um problema e uma solução. Espero o benefício da biblioteca, artigos e seus comentários.



All Articles