30 July

Flutter. RenderObject — замеряй и властвуй

Surf corporate blogProgrammingDevelopment of mobile applicationsDartFlutter
Всем привет, меня зовут Дмитрий Андриянов. Я Flutter-разработчик в Surf. Чтобы построить эффективный и производительный UI достаточно основной библиотеки Flutter. Но бывают случаи, когда нужно реализовывать специфичные кейсы и тогда придётся копать в глубь.



Вводная


Имеется экран со множеством текстовых полей. Их может быть как 5, так и 30. Между ними могут находиться различные виджеты.



Задача


  • Поместить над клавиатурой блок с кнопкой «Далее» для переключения на следующее поле.
  • При смене фокуса подскролливать поле к блоку с кнопкой «Далее».

Проблема


Блок с кнопкой перекрывает текстовое поле. Нужно реализовать автоматический скролл на размер перекрываемого пространства текстового поля.



Подготовка к решению


1.Возьмём экран из 20 полей.

Код:

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

При фокусе в текстовом поле видим следующую картину:



Поле прекрасно видно и всё в порядке.

2. Добавим блок с кнопкой.



Для отображения блока используется Overlay. Это позволяет показывать плашку независимо от виджетов на экране и не использовать обёртки в виде Stack. При этом у нас нет прямого взаимодействия между полями и блоком «Далее».

Хорошая статья про Overlay.

Если кратко: Overlay позволяет накладывать виджеты поверх других виджетов, через стек наложения. OverlayEntry позволяют управлять соответствующим ему Overlay.

Код:

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. Как и ожидалось, блок перекрывает поле.

Идеи по решению


1. Брать текущую позицию прокрутки экрана из ScrollController и скроллить до поля.
Размеры поля неизвестны, особенно если оно многострочное, то скролл к нему даст неточный результат. Решение будет не идеальным и не гибким.

2. Складывать размеры виджетов вне списка и учитывать прокрутку.
Если задать виджетам фиксированную высоту, тогда, зная положение прокрутки и размеры виджетов, будет известно, что сейчас в зоне видимости и на сколько нужно скроллить, чтобы показать определённый виджет.

Минусы:

  • Придётся учитывать все виджеты вне списка и задавать им фиксированные размеры, которые будут использоваться в расчетах, что не всегда соответствует требуемому дизайну и поведению интерфейса.
  • Правки UI приведут к правкам в расчётах.

3. Брать позицию виджетов относительно экрана поля и блока «Далее» и доскралливать на разницу.

Минус — из коробки такой возможности нет.

4. Использовать слой рендера.

Исходя из статьи, Flutter знает, как расположить своих потомков в дереве, а значит эту информацию можно вытащить. За рендер отвечает RenderObject, к нему то и направимся. RenderBox имеет поле size с шириной и высотой виджета. Они рассчитываются при рендере для виджетов: будь то списки, контейнеры, текстовые поля (даже многострочные) и т.д.

Получить RenderBox можно через
context context.findRenderObject() as RenderBox

Для получения контекста поля можно использовать GlobalKey.

Минус:

GlobalKey не самая легкая штука. И применять её лучше как можно реже.

«Виджеты с глобальными ключами перерисовывают свои поддеревья, когда они перемещаются из одного места в дереве в другое. Чтобы перерисовать своё поддерево, виджет должен прибыть в своё новое местоположение в дереве в том же кадре анимации, в котором он был удалён из старого места.

Глобальные ключи относительно дороги в плане производительности. Если вам не нужны какие-либо функции, перечисленные выше, рассмотрите возможность использования Key, ValueKey, ObjectKey или UniqueKey.

Вы не можете одновременно включить два виджета в дерево с одним и тем же глобальным ключом. При попытке сделать это будет ошибка во время исполнения». Источник.

На самом деле, если держать на экране 20 GlobalKey, ничего страшного не случится, но раз рекомендуется использовать его только в случаях необходимости, то попробуем поискать другой путь.

Решение без GlobalKey


Будем использовать слой рендера. Первым делом нужно проверить — можем ли мы вытащить что-то из RenderBox и будут ли это те данные, что нам нужны.

Код для проверки гипотезы:

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) Так как нужна прокрутка до поля, надо получить его контекст (например, через FocusNode), найти RenderBox и взять size. Но это size текстового поля и если нам нужны также родительские виджеты (например, Padding), надо взять родительский RenderBox через поле parent.

(2) Наследуем наш класс RenderWrapper от SingleChildRenderObjectWidget и создаём RenderProxyBox для него. RenderProxyBox имитирует все свойства дочернего элемента, отображая его при рендере дерева виджетов.
Flutter сам часто использует наследников SingleChildRenderObjectWidget:
Align, AnimatedSize, SizedBox, Opacity, Padding.

(3) Рекурсивно проходим родителей по дереву, пока не встретим RenderWrapper.

(4) Берём parent.size.height — это выдаст правильную высоту. Это правильный путь.

Так оставлять, конечно же, нельзя.

Но у рекурсивного подхода тоже есть минусы:

  • Рекурсивный обход дерева не гарантирует, что мы не нарвёмся на предка к которому не готовы. Он может не подойти по типу и всё. Как-то на тестах я нарвался на RenderView и всё упало. Можно, конечно, игнорировать неподходящего предка, но хочется более надежного подхода.
  • Это неуправляемое и всё еще не гибкое решение.

Использование RenderObject


Данный подход вылился пакет render_metrics и уже давно используется на одном из наших приложений.

Логика работы:

1. Оборачиваем интересующий виджет (потомок класса Widget) в RenderMetricsObject. Вложенность и целевой виджет не имеют значения.

RenderMetricsObject(
 child: ...,
)

2. После первого фрейма нам будут доступны его метрики. Если размер или позиция виджета относительно экрана (абсолютное или в прокрутке), то при повторном запросе метрик уже будут новые данные.

3. Использовать менеджер RenderManager не обязательно, но при его использовании нужно передавать id для виджета.

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

4. Можно использовать колбэки:

  • onMount — создание RenderObject. В аргументы получает переданный id (или null, если не был передан) и соответствующий экземпляр RenderMetricsBox.
  • onUnMount — удаление из дерева.

В параметрах функция получает id, переданный в RenderMetricsObject. Эти функции полезны тогда, когда не нужен менеджер и/или нужно знать когда был создан и удалился RenderObject из дерева.

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

5. Получение метрик. Класс RenderMetricsBox реализует геттер data, в котором берёт свои размеры через localToGlobal. localToGlobal преобразует точку из локальной системы координат для этого RenderBox в глобальную систему координат относительно экрана в логических пикселях.



A — ширина width виджета, преобразуется в самую правую точку координат относительно экрана.

B — высота height преобразуется в самую нижнюю точку координат относительно экрана.

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 — просто класс с данными, предоставляющий отдельные x и y значения в виде double и точки координат в виде CoordsMetrics.

7. ComparisonDiff — при вычитании двух RenderData возвращается экземпляр ComparisonDiff с разницей между ними. Также он предоставляет геттер (diffTopToBottom) для разницы позиций между нижним краем первого виджета и верхним второго и наоборот (diffBottomToTop). diffLeftToRight и diffRightToLeft соответственно.

8. RenderParametersManager — наследник RenderManager. Для получения метрик виджета и разницы между ними.

Код:

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

Результат с использованием render_metrics




Итог


Копнув глубже уровня виджетов, с помощью небольших манипуляций со слоем рендера получили полезную функциональность, которая позволяет писать более сложные UI и логику. Иногда нужно знать размеры динамических виджетов, их позицию или сравнить перекрывающие друг на друга виджеты. И данная библиотека предоставляет все эти возможности для более быстрого и эффективного решения задач. В статье я постарался объяснить механизм работы, привёл пример проблемы и решения. Надеюсь на пользу библиотеки, статьи и на вашу обратную связь.
Tags:surfflutterразработка мобильных приложений
Hubs: Surf corporate blog Programming Development of mobile applications Dart Flutter
+7
2.1k 28
Comments 8