flutter

Flutter: How to render a popup widget on top globally without loosing interactions with other widgets?


I want a widget to go beyond its parent's borders. People recommend using Stack with clipBehavior: Clip.none (for example, in this question).

It works, but such a widget is rendered below all the other widgets in the app.

Consider the following code:

import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';

Widget getTestClipNoneWidget(BuildContext context) {
  return Column(
    crossAxisAlignment: CrossAxisAlignment.start,
    children: [
      Container(
        width: MediaQuery.of(context).size.width,
        height: 300.h,
        color: Colors.black,
        child: Stack(
          clipBehavior: Clip.none,
          children: [
            Positioned(
              top: 267.h,
              child: Container(
                decoration: BoxDecoration(
                  color: Colors.green,
                  border: Border.all(
                    width: 1.r,
                    color: Colors.red,
                  ),
                ),
                width: 100.w,
                height: 50.h,
              ),
            ),
          ],
        ),
      ),
      Container(
        height: 200.h,
        width: 50.w,
        color: Colors.blue,
      ),
    ],
  );
}

It will produce this (tested in Flutter Web):

The widgets

The thing is, I want the green box to be above the blue one. How do I achieve that? Ideally in all platforms, not only in Flutter Web.

My Flutter setup configuration:

Flutter 3.13.7 • channel stable • https://github.com/flutter/flutter.git
Framework • revision 2f708eb839 (8 days ago) • 2023-10-09 09:58:08 -0500
Engine • revision a794cf2681
Tools • Dart 3.1.3 • DevTools 2.25.0

EDIT: As I wrote in the comments, I need this to show popup dialogs in Flutter Web. For example, when a user clicks on a search bar and starts typing then search results should appear below the search bar. They should be rendered on top of every other widget. And yes, you can use showDialog or something like that but it doesn't allow interacting with other widgets until the dialog is dismissed. It means you cannot continue typing until you close the dialog which is obviously not an acceptable solution. If you know how to make such dialogs in another way (for example, use something instead of Stack), I will accept it as the answer as well.

EDIT2: I need a general solution, not only for search bars but for any custom popup widgets.


Solution

  • There's a widget for that.

    See the screen recording of the code example below

    Try Autocomplete widget which works well for the search ui you explained.

    A widget for helping the user make a selection by entering some text and choosing from among a list of options. more

    For the popup ui, See Overlay widget.

    Overlays let independent child widgets "float" visual elements on top of other widgets by inserting them into the overlay's stack. The overlay lets each of these widgets manage their participation in the overlay using OverlayEntry objects.

    The Overlay widget uses a custom stack implementation, which is very similar to the Stack widget. The main use case of Overlay is related to navigation and being able to insert widgets on top of the pages in an app. For layout purposes unrelated to navigation, consider using Stack instead.

    In the example below. The OverlayEntry is the widget that is being drawn (The widget that you wanted to popup). It inserted in to the OverlaySate. To anchor the overlay (popup) widget to another, it's wrapped with CompositedTransformFollower and the target widget with CompositedTransformTarget, with LayerLink class linking them. This helps the popup follow along when scrolling.

    Note: The overlay method literally overlays the whole app screen. So, it may draw over sticky widgets like the appbar or bottom nav.

    import 'package:flutter/material.dart';
    import 'package:flutter/scheduler.dart';
    
    void main() => runApp(const App());
    
    class App extends StatelessWidget {
      const App({super.key});
    
      @override
      Widget build(BuildContext context) {
        return MaterialApp(
          home: Material(
            child: Padding(
              padding: const EdgeInsets.all(16.0),
              child: Column(
                mainAxisAlignment: MainAxisAlignment.center,
                crossAxisAlignment: CrossAxisAlignment.start,
                children: [
                  titleAutoComplete,
                  spaceSmall,
                  const AutoCompleteExample(),
                  spaceMedium,
                  textLorem20,
                  spaceMedium,
                  titleCustomAutoComplete,
                  spaceSmall,
                  const CustomAutoCompleteExample(),
                  spaceMedium,
                  textLorem20,
                  spaceMedium,
                  titleOverlay,
                  spaceSmall,
                  const OverlayExample(),
                ],
              ),
            ),
          ),
        );
      }
    }
    
    class OverlayExample extends StatefulWidget {
      const OverlayExample({super.key});
    
      @override
      State<OverlayExample> createState() => _OverlayExampleState();
    }
    
    class _OverlayExampleState extends State<OverlayExample> {
      // A place in an [Overlay] that can contain a widget.
      late OverlayEntry overlayEntry;
      // The current state of an [Overlay].
      late OverlayState overlayState;
      // The link object that connects this [CompositedTransformTarget]
      // with one or more [CompositedTransformFollower]s.
      final layerLink = LayerLink();
    
      @override
      void initState() {
        super.initState();
        overlayEntry = buildOverlay();
        overlayState = Overlay.of(context);
      }
    
      @override
      void dispose() {
        // Make sure to remove OverlayEntry when the widget is disposed.
        overlayEntry.remove();
        super.dispose();
      }
    
      @override
      Widget build(BuildContext context) {
        return CompositedTransformTarget(
          link: layerLink,
          child: ElevatedButton(
            onPressed: () => overlayState.insert(overlayEntry),
            child: const Text('Click Me'),
          ),
        );
      }
    
      buildOverlay() => OverlayEntry(builder: (_) {
            return Positioned(
              width: 100,
              height: 100,
              child: CompositedTransformFollower(
                link: layerLink,
                targetAnchor: Alignment.topCenter,
                followerAnchor: Alignment.bottomLeft,
                child: const FlutterLogo(),
              ),
            );
          });
    }
    
    class AutoCompleteExample extends StatelessWidget {
      const AutoCompleteExample({super.key});
    
      static const List<String> colors = <String>[
        'red',
        'green',
        'blue',
        'purple',
        'yellow',
        'orange'
      ];
    
      @override
      Widget build(BuildContext context) {
        return Autocomplete<String>(
          fieldViewBuilder: fieldViewBuilder,
          optionsBuilder: (TextEditingValue textEditingValue) {
            if (textEditingValue.text == '') {
              return const Iterable<String>.empty();
            }
            return colors.where((String option) {
              return option.contains(textEditingValue.text.toLowerCase());
            });
          },
          onSelected: (String selection) {
            debugPrint('You just selected $selection');
          },
        );
      }
    }
    
    class CustomAutoCompleteExample extends StatelessWidget {
      const CustomAutoCompleteExample({super.key});
    
      @override
      Widget build(BuildContext context) {
        return Autocomplete<MapEntry<String, Color>>(
          fieldViewBuilder: fieldViewBuilder,
          optionsBuilder: (TextEditingValue textEditingValue) {
            if (textEditingValue.text == '') {
              return const Iterable<MapEntry<String, Color>>.empty();
            }
            return colorsMap.entries.where((MapEntry<String, Color> element) {
              return element.key.contains(textEditingValue.text.toLowerCase());
            });
          },
          displayStringForOption: (option) {
            return option.key;
          },
          onSelected: (MapEntry<String, Color> selection) {
            debugPrint('You just selected $selection');
          },
          optionsViewBuilder: (context, onSelected, options) {
            return Align(
              alignment: Alignment.topLeft,
              child: Material(
                elevation: 1,
                child: ListView.builder(
                  shrinkWrap: true,
                  itemCount: options.length,
                  itemBuilder: (BuildContext context, int index) {
                    final option = options.elementAt(index);
                    return InkWell(
                      onTap: () => onSelected(option),
                      child: Builder(
                        builder: (BuildContext context) {
                          final bool highlight =
                              AutocompleteHighlightedOption.of(context) == index;
                          if (highlight) {
                            SchedulerBinding.instance
                                .addPostFrameCallback((Duration timeStamp) {
                              Scrollable.ensureVisible(context, alignment: 0.5);
                            });
                          }
                          return Container(
                            color: highlight ? Theme.of(context).focusColor : null,
                            padding: const EdgeInsets.all(16.0),
                            child: Row(
                              children: [
                                CircleAvatar(backgroundColor: option.value),
                                const SizedBox(width: 24),
                                Text(option.key),
                              ],
                            ),
                          );
                        },
                      ),
                    );
                  },
                ),
              ),
            );
          },
        );
      }
    }
    
    // Constants and stylings
    
    const spaceSmall = SizedBox(height: 8.0);
    const spaceMedium = SizedBox(height: 16.0);
    
    var titleAutoComplete = const Text(
      'AutoComplete Example',
      style: textStyle,
    );
    
    var titleCustomAutoComplete = const Text(
      'Custom AutoComplete Example',
      style: textStyle,
    );
    var titleOverlay = const Text(
      'Overlay Example',
      style: textStyle,
    );
    
    const textLorem20 = Text(
        'Lorem ipsum dolor sit amet consectetur adipisicing elit. Quas fugiat tempore commodi quo quam repellat ipsum blanditiis sapiente at nobis!');
    
    const textStyle = TextStyle(fontSize: 20, fontWeight: FontWeight.bold);
    
    const Map<String, Color> colorsMap = {
      'red': Colors.red,
      'green': Colors.green,
      'blue': Colors.blue,
      'purple': Colors.purple,
      'yellow': Colors.yellow,
      'orange': Colors.orange,
    };
    
    Widget fieldViewBuilder(
      context,
      textEditingController,
      focusNode,
      onFieldSubmitted,
    ) {
      return TextFormField(
        controller: textEditingController,
        focusNode: focusNode,
        onEditingComplete: onFieldSubmitted,
        decoration: InputDecoration(
          hintText: 'Search Color',
          border: OutlineInputBorder(
            borderRadius: BorderRadius.circular(
              8,
            ),
          ),
        ),
      );
    }
    

    Edit: Simplified step by step

    Autocomplete

    1. Declare an array of objects. Strings in this case.

    const colors = ['red', 'green', 'blue'];
    

    2. Insert the Autocomplete widget into the tree.

    Autocomplete<String>(
          optionsBuilder: (textEditingValue) {
            if (textEditingValue.text == '') {
              return const Iterable.empty();
            }
            return colors.where((option) {
              return option.contains(textEditingValue.text.toLowerCase());
            });
          },
          onSelected: (selection) {
            debugPrint('You just selected $selection');
          },
        );
    

    OverlayEntry

    Overlay has a state so it needs to be a StatefulWidget.

    1. Declare the widget you want to popup above other widgets.

      final myWidget = const FlutterLogo();
    

    2. Declare OverlayEntry, OverlayState and LayerLink;

    late overlayEntry;
    late overlayState;
    final layerLink = LayerLink();
    

    3. Override initState and assign instances

     @override
      void initState() {
        super.initState();
        overlayEntry =  overlayEntry = OverlayEntry(builder: (_) {
          return Positioned( // Helps to size the widget
            width: 100,
            height: 100,
            child: CompositedTransformFollower( // Anchors & follows the target widget
              link: layerLink, // Links this widget to the target
              targetAnchor: Alignment.topCenter,
              followerAnchor: Alignment.bottomLeft,
              child: myWidget, // Your popup widget
            ),
          );
        });
    
        overlayState = Overlay.of(context);
      }
    

    4. Insert the widget you want to anchor you popup to into the tree. A button in this case and call overlayState.insert(overlayEntry) to make it popup.

    CompositedTransformTarget( // Target widget follower is anchored to
          link: layerLink, // Links this widget to the follower.
          child: ElevatedButton(
            onPressed: () => overlayState.insert(overlayEntry),
            child: const Text('Click Me'),
          ),
        );
    

    5. To remove

    overlayEntry.remove();