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

    Всем привет, меня зовут Дмитрий Андриянов. Я 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 и логику. Иногда нужно знать размеры динамических виджетов, их позицию или сравнить перекрывающие друг на друга виджеты. И данная библиотека предоставляет все эти возможности для более быстрого и эффективного решения задач. В статье я постарался объяснить механизм работы, привёл пример проблемы и решения. Надеюсь на пользу библиотеки, статьи и на вашу обратную связь.
    Surf
    Компания

    Комментарии 8

      +1

      А в чем проблема использовать Stack? Текущее решение мне кажется оверинжирингом, во всяком случае, для конкретной указанной проблемы.


      Во-первых, Stack все равно используется внутри buildOverlay. Во-вторых, надо оборачивать каждый TextField.


      Я бы сделал что-нибудь типа такого и обернул им SingleChildScrollView:


      class Wrapper extends StatelessWidget {
        const Wrapper({Key key, this.child, this.isVisible}) : super(key: key);
      
        final Widget child;
        final bool isVisible;
      
        @override
        Widget build(BuildContext context) {
          const double height = 50;
          return Stack(
            children: <Widget>[
              Padding(
                  padding: EdgeInsets.only(bottom: isVisible ? height : 0),
                  child: child,
                ),
              if (isVisible)
                Align(
                  alignment: Alignment.bottomCenter,
                  child: Container(
                    color: Colors.red,
                    height: height,
                  ),
                ),
            ],
          );
        }
      }
        0
        Можно оставить чисто стек, но Overlay (неважно что внутри) более независимо.
        Придется тогда весь экран класть в стек, это может породить дополнительные проблемы.

        Текущий кейс просто пример. После написания статьи, скроллил к активному фильтру в AppBar, у фильтров динамический размер. Обернул каждый в RenderMetricsObject, сложил размеры и проскроллил при заходе на экран.

        А как доскралливать на разницу в вашем решении, плашка же перекроет поле. Особенно, если поле многострочное. Или вы только о инструментах позиционирования?
        0
        — В OverlayEntry можно прокидывать такие виджеты как Positioned и другие, которые работают со Stack.
        То есть не надо строить Stack в OverlayEntry если у вас 1 ребёнок
        Тут и тут

        — Не уверен, что скролл вниз удобен.
        Скролл вверх, чтобы поле стало видимым — это понятно, но наоборот — скорее не привычно

        Данный случай можно решить так
        import 'package:flutter/material.dart';
        import 'package:flutter/scheduler.dart';
        import 'package:keyboard_visibility/keyboard_visibility.dart';
        
        void main() => runApp(
              MaterialApp(
                home: App(),
              ),
            );
        
        class App extends StatefulWidget {
          @override
          _AppState createState() => _AppState();
        }
        
        class NextBlock extends StatelessWidget {
          const NextBlock({
            Key key,
            this.isShow,
          }) : super(key: key);
        
          final bool isShow;
        
          @override
          Widget build(BuildContext context) {
            if (!isShow) return const SizedBox();
        
            return ColoredBox(
              color: const Color.fromRGBO(0, 0, 0, 0.3),
              child: Padding(
                padding: const EdgeInsets.all(8.0),
                child: FlatButton(
                  color: Colors.white,
                  onPressed: () {},
                  child: Text('Next'),
                ),
              ),
            );
          }
        }
        
        class _AppState extends State<App> {
          final list = List.generate(20, (index) => index.toString());
          bool _isShow = false;
          OverlayEntry _overlayEntry;
        
          KeyboardVisibilityNotification _keyboardListener;
        
          final _key = GlobalKey();
          double _height = 0;
        
          @override
          void initState() {
            super.initState();
            _overlayEntry = OverlayEntry(builder: _buildOverlay);
            _keyboardListener = KeyboardVisibilityNotification()
              ..addNewListener(onChange: _keyboardHandle);
        
            SchedulerBinding.instance.addPostFrameCallback((timeStamp) {
              Overlay.of(context).insert(_overlayEntry);
            });
          }
        
          @override
          void dispose() {
            _keyboardListener.dispose();
            _overlayEntry.remove();
            super.dispose();
          }
        
          Widget _buildOverlay(BuildContext context) => Positioned(
                bottom: MediaQuery.of(context).viewInsets.bottom,
                left: 0,
                right: 0,
                child: AnimatedOpacity(
                  key: _key,
                  duration: const Duration(milliseconds: 200),
                  opacity: _isShow ? 1.0 : 0.0,
                  child: NextBlock(
                    isShow: _isShow,
                  ),
                ),
              );
        
          void _keyboardHandle(bool isVisible) {
            _isShow = isVisible;
            _overlayEntry?.markNeedsBuild();
            if (isVisible) {
              SchedulerBinding.instance.addPostFrameCallback((timeStamp) {
                final height = _key.currentContext.size.height;
                if (height != _height) {
                  setState(() => _height = height);
                }
              });
            }
          }
        
          @override
          Widget build(BuildContext context) {
            final mediaQuery = MediaQuery.of(context);
        
            return MediaQuery(
              data: _isShow
                  ? mediaQuery.copyWith(
                      viewInsets: mediaQuery.viewInsets.copyWith(
                        bottom: mediaQuery.viewInsets.bottom + _height,
                      ),
                    )
                  : mediaQuery,
              child: 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),
                            )
                        ],
                      ),
                    ),
                  ),
                ),
              ),
            );
          }
        }
        

          0
          1. Круто.
          2. По поводу вниз — это было требование заказчика. Данный пример реальный кейс из приложения, просто UI другой. Не стал ничего менять и показал как есть.
            0
            Тогда так
            import 'package:flutter/material.dart';
            import 'package:flutter/rendering.dart';
            import 'package:flutter/scheduler.dart';
            import 'package:keyboard_visibility/keyboard_visibility.dart';
            
            void main() => runApp(
                  MaterialApp(
                    home: App(),
                  ),
                );
            
            class App extends StatefulWidget {
              @override
              _AppState createState() => _AppState();
            }
            
            class NextBlock extends StatelessWidget {
              const NextBlock({
                Key key,
                this.isShow,
              }) : super(key: key);
            
              final bool isShow;
            
              @override
              Widget build(BuildContext context) {
                if (!isShow) return const SizedBox();
            
                return ColoredBox(
                  color: const Color.fromRGBO(0, 0, 0, 0.3),
                  child: Padding(
                    padding: const EdgeInsets.all(8.0),
                    child: FlatButton(
                      color: Colors.white,
                      onPressed: () {},
                      child: Text('Next'),
                    ),
                  ),
                );
              }
            }
            
            class _AppState extends State<App> {
              final list = List.generate(20, (index) => index.toString());
              bool _isShow = false;
              OverlayEntry _overlayEntry;
            
              KeyboardVisibilityNotification _keyboardListener;
              FocusManager _focusScope;
            
              final _key = GlobalKey();
              final _scrollKey = GlobalKey();
              double _height = 0;
            
              @override
              void initState() {
                super.initState();
                _overlayEntry = OverlayEntry(builder: _buildOverlay);
                _keyboardListener = KeyboardVisibilityNotification()
                  ..addNewListener(onChange: _keyboardHandle);
            
                SchedulerBinding.instance.addPostFrameCallback((timeStamp) {
                  _focusScope = context.owner.focusManager..addListener(_handleFocusChange);
                  Overlay.of(context).insert(_overlayEntry);
                });
              }
            
              void _handleFocusChange() {
                final textFieldContext = FocusScope.of(context).focusedChild.context;
            
                Scrollable.ensureVisible(
                  textFieldContext,
                  alignment: 0.9,
                  duration: const Duration(milliseconds: 400),
                );
              }
            
              @override
              void dispose() {
                _focusScope.removeListener(_handleFocusChange);
                _keyboardListener.dispose();
                _overlayEntry.remove();
                super.dispose();
              }
            
              Widget _buildOverlay(BuildContext context) => Positioned(
                    bottom: MediaQuery.of(context).viewInsets.bottom,
                    left: 0,
                    right: 0,
                    child: AnimatedOpacity(
                      key: _key,
                      duration: const Duration(milliseconds: 200),
                      opacity: _isShow ? 1.0 : 0.0,
                      child: NextBlock(
                        isShow: _isShow,
                      ),
                    ),
                  );
            
              void _keyboardHandle(bool isVisible) {
                _isShow = isVisible;
                _overlayEntry?.markNeedsBuild();
                if (isVisible) {
                  SchedulerBinding.instance.addPostFrameCallback((timeStamp) {
                    final height = _key.currentContext.size.height;
                    if (height != _height) {
                      setState(() => _height = height);
                    }
                  });
                }
              }
            
              @override
              Widget build(BuildContext context) {
                final mediaQuery = MediaQuery.of(context);
            
                return MediaQuery(
                  data: _isShow
                      ? mediaQuery.copyWith(
                          viewInsets: mediaQuery.viewInsets.copyWith(
                            bottom: mediaQuery.viewInsets.bottom + _height,
                          ),
                        )
                      : mediaQuery,
                  child: Scaffold(
                    body: SingleChildScrollView(
                      key: _scrollKey,
                      child: SafeArea(
                        child: Padding(
                          padding: const EdgeInsets.all(20),
                          child: Column(
                            children: <Widget>[
                              for (String value in list)
                                TextField(
                                  decoration: InputDecoration(labelText: value),
                                )
                            ],
                          ),
                        ),
                      ),
                    ),
                  ),
                );
              }
            }
            

              0
              А если у виджета текстового поля будут паддинги, контейнеры и т.д., то как тогда доскроллить в нужное место в вашем решении?

              Также если нужно будет скроллить не до текстового поля, а допустим, до кнопки, то ваше решение выше не подойдет.

              Только если использовать колбэк для получения контекста или GlobalKey.
                0
                Ваше решение тоже не идеально, так как нижнее поле закрыто оверлеем, хотя речь в статье об этом, а значит основная проблема не решена.
                Скриншот
                image


                В любом случае надеюсь в библиотеке появится что-то, что бы само пробрасывало менеджер и реализация для сливеров
                  0
                  Согласен.
                  Цель библиотеки получать:
                  — размеры
                  — позицию
                  — разницу
                  Любых виджетов.

                  С этим она справляется.

                  Сам пример да, не идеален.

                  Или же вы о чем-то другом?

        Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

        Самое читаемое