Search code examples
flutterwidgetwindowmaterial-designoverlay

How to update OverlayEntry dimensions on window resize in flutter


I am trying to show an overlay of the same width as of the TextField. The below code sample works fine but when I run it in the browser and resize the window, The Textfield resizes but the overlay stays with the old dimensions.

How can we update (not rerender the overlay) the dimensions of the overlay on resizing the window? I know flutter has a built in AutoComplete widget but it is buggy since we cannot add padding around the overlay as of today. So I am trying to build my own widget.

Here's a sample code I have tried

import 'package:flutter/material.dart';

void main() {
  runApp(const MyApp());
}

class MyApp extends StatelessWidget {
  const MyApp({Key? key}) : super(key: key);
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter Demo',
      theme: ThemeData(
        primarySwatch: Colors.blue,
      ),
      home: const MyHomePage(title: 'Flutter Demo Home Page'),
    );
  }
}

class MyHomePage extends StatefulWidget {
  const MyHomePage({Key? key, required this.title}) : super(key: key);
  final String title;
  @override
  State<MyHomePage> createState() => _MyHomePageState();
}

class _MyHomePageState extends State<MyHomePage> {
  final ScrollController _scrollController = ScrollController();

  Widget _list() {
    return Container(
      height: 5 * 40,
      child: Scrollbar(
          controller: _scrollController,
          thumbVisibility: true,
          child: ListView.builder(
              controller: _scrollController,
              padding: EdgeInsets.zero,
              itemCount: 20,
              itemBuilder: (context, index) => ListTile(
                    title: Text('item $index'),
                    onTap: () {
                      _overlayEntry.remove();
                    },
                  ))),
    );
  }

  final LayerLink _layerLink = LayerLink();

  OverlayEntry _createOverlay() {
    final renderBox = context.findRenderObject() as RenderBox;
    final size = renderBox.size;
    final offset = renderBox.localToGlobal(Offset.zero);
    return OverlayEntry(
        builder: (context) => Positioned(
              left: offset.dx,
              width: size.width,
              child: CompositedTransformFollower(
                  offset: Offset(0, 50),
                  link: _layerLink,
                  child: Material(color: Colors.red, child: _list())),
            ));
  }

  late OverlayEntry _overlayEntry;

  @override
  void initState() {
    WidgetsBinding.instance.addPostFrameCallback((_) {
      _overlayEntry = _createOverlay();
    });
    super.initState();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text(widget.title),
      ),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: <Widget>[
            CompositedTransformTarget(
              link: _layerLink,
              child: TextFormField(
                decoration: InputDecoration(
                  focusedBorder: OutlineInputBorder(
                    borderSide: BorderSide(
                      color: Colors.black.withOpacity(0.8),
                    ),
                  ),
                  border: OutlineInputBorder(
                    borderSide: BorderSide(color: Colors.red),
                  ),
                ),
                onTap: () {
                   if (_overlayEntry.mounted) {
                        _overlayEntry.remove();
                   }
                   Overlay.of(context).insert(_overlayEntry);
                },
                onChanged: (query) {},
              ),
            ),
          ],
        ),
      ),
    );
  }
}

Heres the output of the above code

Notice that Overlay dimensions do not match the window size (when window was resized)

enter image description here

We could force the overlay dimensions by adjusting the Positioned.right parameter

OverlayEntry(
        builder: (context) => Positioned(
              left: 0,
              right: 0, // custom offset from right
              // width: textFieldSize.width,
              child: CompositedTransformFollower(
                  offset: Offset(0, 50),
                  link: _layerLink,
                  child: Material(color: Colors.red, child: _list())),
            ));

But this would fail if we were to give padding around the Textfield e.g if I set Positioned.right:0 and Give Padding around Textfield the overlay would always take up the whole width.

enter image description here

The final solution I tried is to set the width of the textfield to overlay width, But I am not sure how do we update the Textfield renderBox so that the overlay takes up the expected dimensions.

  OverlayEntry _createOverlay() {
    /// textfield dimensions
    final textFieldRenderBox =
        textFieldKey.currentContext!.findRenderObject() as RenderBox;
    final textFieldOffset = textFieldRenderBox.localToGlobal(Offset.zero);
    final textFieldSize = textFieldRenderBox.size;
    print('$textFieldSize $textFieldOffset ');
    return OverlayEntry(
        builder: (context) => Positioned(
              left: 0,
              width: textFieldSize.width,
              child: CompositedTransformFollower(
                  offset: Offset(0, 50),
                  link: _layerLink,
                  child: Material(color: Colors.red, child: _list())),
            ));
  }

Edit: I tried using didChangeMetrics to listen to MediaQueyChanges and update the Overlay using ValueListenableBuilder but its janky and updates with a delay heres the output and the code


Solution

  • We can update the overlay entry by calling

    _overlayEntry!.markNeedsBuild();
    

    Alternatively, We could solve this using OverLayPortal a new widget introduced in flutter 3.10.

    Originally answered here https://github.com/flutter/flutter/issues/78746#issuecomment-1564893844

    import 'package:flutter/material.dart';
    
    void main() {
      runApp(const MyApp());
    }
    
    class MyApp extends StatelessWidget {
      const MyApp({Key? key}) : super(key: key);
      @override
      Widget build(BuildContext context) {
        return MaterialApp(
            title: 'Flutter Demo',
            theme: ThemeData(
              primarySwatch: Colors.blue,
            ),
            home: MyHomePage(
              title: "My Home Page",
            ));
      }
    }
    
    class MyHomePage extends StatefulWidget {
      const MyHomePage({super.key, required this.title});
    
      final String title;
    
      @override
      State<MyHomePage> createState() => _MyHomePageState();
    }
    
    class _MyHomePageState extends State<MyHomePage> {
      int _counter = 0;
    
      void _incrementCounter() {
        setState(() {
          _counter++;
        });
      }
    
      @override
      Widget build(BuildContext context) {
        return Scaffold(
          appBar: AppBar(
            title: Text(widget.title),
          ),
          body: Center(
            child: Column(
              mainAxisAlignment: MainAxisAlignment.center,
              children: <Widget>[
                Padding(
                  padding: EdgeInsets.only(left:24.0,right:12.0),
                  child: CustomWidget(),
                ),
                const Text(
                  'You have pushed the button this many times:',
                ),
                Text(
                  '$_counter',
                  style: Theme.of(context).textTheme.headlineMedium,
                ),
              ],
            ),
          ),
          floatingActionButton: FloatingActionButton(
            onPressed: _incrementCounter,
            tooltip: 'Increment',
            child: const Icon(Icons.add),
          ), // This trailing comma makes auto-formatting nicer for build methods.
        );
      }
    }
    
    class CustomWidget extends StatefulWidget {
      const CustomWidget({Key? key}) : super(key: key);
      @override
      State<CustomWidget> createState() => _CustomWidgetState();
    }
    
    class _CustomWidgetState extends State<CustomWidget> {
      final ScrollController _scrollController = ScrollController();
    
      Widget _list() {
        return Container(
          height: 5 * 40,
          child: Scrollbar(
              controller: _scrollController,
              thumbVisibility: true,
              child: ListView.builder(
                  controller: _scrollController,
                  padding: EdgeInsets.zero,
                  itemCount: 20,
                  itemBuilder: (context, index) => ListTile(
                        title: Text('item $index'),
                        onTap: () {
                          _portalController.hide();
                        },
                      ))),
        );
      }
    
      @override
      void initState() {
        super.initState();
      }
    
      GlobalKey gkey = GlobalKey();
      final OverlayPortalController _portalController = OverlayPortalController();
      @override
      Widget build(BuildContext context) {
        Widget textfield() => TextFormField(
              key: gkey,
              decoration: InputDecoration(
                focusedBorder: OutlineInputBorder(
                  borderSide: BorderSide(
                    color: Colors.black.withOpacity(0.8),
                  ),
                ),
                border: const OutlineInputBorder(
                  borderSide: BorderSide(color: Colors.red),
                ),
              ),
              onTap: () {
                if (!_portalController.isShowing) {
                  _portalController.show();
                }
              },
              onChanged: (query) {},
            );
        
        return OverlayPortal(
          controller: _portalController,
          overlayChildBuilder: (BuildContext context) {
            final renderBox = gkey.currentContext!.findRenderObject() as RenderBox;
            final Size tSize = renderBox.size;
            final Offset offset = renderBox.localToGlobal(Offset.zero);
            return Positioned(
              left: offset.dx,
              top: offset.dy + tSize.height, // + textfield height
              child: Material(
                color: Colors.red,
                child: SizedBox(
                  width: tSize.width,
                  child: _list(),
                ),
              ),
            );
          },
          child: textfield(),
        );
      }
    }