TSR
TSR

Reputation: 20617

How to close a specific Flutter AlertDialog?

Steps to reproduce:

  1. Copy paste the below code in DartPad.dev/flutter

  2. Hit run

  3. Click the Do Api Call button

  4. you should see two popups, one below and one above

  5. After 5 seconds, the one below is desired to close not the one above, instead, the one above closes

How to close the one below and leave the one above open ?

import 'package:flutter/material.dart';

final Color darkBlue = Color.fromARGB(255, 18, 32, 47);

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

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      theme: ThemeData.dark().copyWith(scaffoldBackgroundColor: darkBlue),
      debugShowCheckedModeBanner: false,
      home: Scaffold(
        body: Center(
          child: CloseSpecificDialog(),
        ),
      ),
    );
  }
}

class CloseSpecificDialog extends StatefulWidget {
  @override
  _CloseSpecificDialogState createState() => _CloseSpecificDialogState();
}

class _CloseSpecificDialogState extends State<CloseSpecificDialog> {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: Center(
          child: RaisedButton(
        child: Text('Do API call'),
        onPressed: () async {
          showDialogBelow();
          showDialogAbove();
          await Future.delayed(Duration(seconds: 5));
          closeDialogBelowNotAbove();
        },
      )),
    );
  }

  void showDialogBelow() {
    showDialog(
        context: context,
        builder: (BuildContext contextPopup) {
          return AlertDialog(
            content: Container(
              width: 350.0,
              height: 150.0,
              child: Center(
                child: Column(
                  mainAxisSize: MainAxisSize.min,
                  mainAxisAlignment: MainAxisAlignment.spaceEvenly,
                  children: [
                    CircularProgressIndicator(),
                    Text('I am below (you should not see this after 5 sec)'),
                  ],
                ),
              ),
            ),
          );
        });
  }

  void showDialogAbove() {
    showDialog(
        context: context,
        builder: (BuildContext contextPopup) {
          return AlertDialog(
            content: Container(
              height: 100.0,
              child: Center(
                child: Column(
                  mainAxisSize: MainAxisSize.min,
                  mainAxisAlignment: MainAxisAlignment.spaceEvenly,
                  children: [
                    CircularProgressIndicator(),
                    Text('I am above (this should not close)'),
                  ],
                ),
              ),
            ),
          );
        });
  }

  /// This should close the dialog below not the one above
  void closeDialogBelowNotAbove() {
    Navigator.of(context).pop();
  }
}

Upvotes: 0

Views: 1972

Answers (3)

user3056783
user3056783

Reputation: 2644

I had a similar requirement for my applications and had to spend quite some time to figure out the approach.

First I will tell you what advice I've got/read online which did not work for me:

  1. Store BuildContext of each dialog from builder function when calling showDialog
  2. Using Navigator.pop(context, rootNavigator: true)
  3. removeRoute method on Navigator

None of these worked. #1 and #2 are a no-go because pop method can only remove the latest route/dialog on the navigation stack, so you can't really remove dialog that is placed below other dialog.

#3 was something I was hoping would work but ultimately it did not work for me. I tried creating enclosing Navigator for specific widget where I'm displaying the dialogs but pushing dialog as new route caused dialog being treated as page.

Solution: using Overlay widget

Flutter multiple dialogs

This is not a perfect solution but Overlay widget is actually used internally by other Flutter widgets, including Navigator. It allows you to control what gets placed in which order so it also means you can decide which element on overlay to remove!

My approach was to create a StatefulWidget which would contain a Stack. This stack would render whatever else passed to it and also Overlay widget. This widget would also hold references to OverlayEntry which are basically identifiers for dialogs themselves.

I'd use GlobalKey to reference the Overlay's state and then insert and remove dialogs (OverlayEntry) as I wished.

There is a disadvantage to this though:

  1. No back button support on Android, so pressing back won't close the dialog.¹
  2. Dialog positioning - you have to manage centering of your dialog yourself, as well as setting up the backdrop.²
  3. Animations - you will have to implement these yourself as well. (You might want to fade in/ fade out backdrop, change position of dialog when opening and closing).

You can find interactive example on this dartpad or you can see the code here:

import 'dart:async';
import 'dart:ui';
import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart';

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

class MyApp extends StatelessWidget {
  const MyApp({Key? key}) : super(key: key);

  // This widget is the root of your application.
  @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 GlobalKey<OverlayState> _overlay = GlobalKey<OverlayState>();
  OverlayEntry? _dialog1;
  OverlayEntry? _dialog2;

  @override
  void initState() {
    super.initState();
    Timer(const Duration(seconds: 3), () {
      _openDialog1();
      debugPrint('Opened dialog 1. Dialog should read: "Dialog 1"');
      Timer(const Duration(seconds: 2), () {
        _openDialog2();
        debugPrint('Opened dialog 2. Dialog should read: "Dialog 2"');
        Timer(const Duration(seconds: 3), () {
          _closeDialog1();
          debugPrint('Closed dialog 1. Dialog should read: "Dialog 2"');
          Timer(const Duration(seconds: 5), () {
            _closeDialog2();
            debugPrint('Closed dialog 2. You should not see any dialog at all.');
          });
        });
      });
    });
  }

  @override
  void dispose() {
    _closeDialog1();
    _closeDialog2();
    super.dispose();
  }

  Future<void> _openDialog1() async {
    _dialog1 = OverlayEntry(
        opaque: false,
        builder: (dialogContext) => CustomDialog(
            title: 'Dialog 1', timeout: false, onClose: _closeDialog1));

    setState(() {
      _overlay.currentState?.insert(_dialog1!);
    });
  }

  Future<void> _openDialog2() async {
    _dialog2 = OverlayEntry(
        opaque: false,
        builder: (dialogContext) => CustomDialog(
            title: 'Dialog 2', timeout: false, onClose: _closeDialog2));

    setState(() {
      _overlay.currentState?.insert(_dialog2!);
    });
  }

  Future<void> _closeDialog1() async {
    setState(() {
      _dialog1?.remove();
      _dialog1 = null;
    });
  }

  Future<void> _closeDialog2() async {
    setState(() {
      _dialog2?.remove();
      _dialog2 = null;
    });
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text(widget.title),
      ),
      body: Stack(
        children: <Widget>[
          Align(
              child:
                  Row(mainAxisAlignment: MainAxisAlignment.center, children: [
            TextButton(onPressed: _openDialog1, child: const Text('Open 1')),
            TextButton(onPressed: _openDialog2, child: const Text('Open 2')),
          ])),
          Align(
            alignment: Alignment.bottomCenter,
            child: Text(
                'Opened 1? ${_dialog1 != null}\nOpened 2? ${_dialog2 != null}'),
          ),
          Overlay(key: _overlay),
        ],
      ),
    );
  }
}

class CustomDialog extends StatefulWidget {
  const CustomDialog({
    Key? key,
    required this.timeout,
    required this.title,
    required this.onClose,
  }) : super(key: key);

  final String id;
  final bool timeout;
  final String title;
  final void Function() onClose;

  @override
  createState() => _CustomDialogState();
}

class _CustomDialogState extends State<CustomDialog>
    with SingleTickerProviderStateMixin {
  late final Ticker _ticker;
  Duration? _elapsed;
  final Duration _closeIn = const Duration(seconds: 5);
  late final Timer? _timer;

  @override
  void initState() {
    super.initState();
    _timer = widget.timeout ? Timer(_closeIn, widget.onClose) : null;
    _ticker = createTicker((elapsed) {
      setState(() {
        _elapsed = elapsed;
      });
    });
    _ticker.start();
  }

  @override
  void dispose() {
    _ticker.dispose();
    _timer?.cancel();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return Positioned(
        width: MediaQuery.of(context).size.width,
        height: MediaQuery.of(context).size.height,
        child: Stack(children: [
          GestureDetector(
              onTap: widget.onClose,
              child: Container(
                  color: Colors.transparent,
                  width: MediaQuery.of(context).size.width,
                  height: MediaQuery.of(context).size.height)),
          BackdropFilter(
              filter: ImageFilter.blur(sigmaX: 10, sigmaY: 10),
              child: AlertDialog(
                backgroundColor: Theme.of(context).scaffoldBackgroundColor,
                title: Text(widget.title),
                content: SizedBox(
                    height: MediaQuery.of(context).size.height / 3,
                    child: Center(
                        child: Text([
                      '${_elapsed?.inMilliseconds ?? 0.0}',
                      if (widget.timeout) ' / ${_closeIn.inMilliseconds}',
                    ].join('')))),
                actions: [
                  TextButton(
                      onPressed: widget.onClose, child: const Text('Close'))
                ],
              )),
        ]));
  }
}

In my example you can see that when the app runs, it will start up Timer which will fire other timers. This only demonstrates that you are able to close/open specific dialogs programatically. Feel free to comment out initState method if you don't want this.


1: Since this solution does not use Navigator at all, you can't use WillPopScope to detect back button press. It's a shame, it'd be great if Flutter had a way to attach listener to back button press.

2: showDialog method does lot for you and you basically have to re-implement what it does within your own code.

Upvotes: 2

Yadhu
Yadhu

Reputation: 3305

Popping will remove route which is added the latest, and showDialog just pushes a new route with dialogue you can directly use the Dialog widgets in a Stack and manage the state using a boolean variable To Achieve same the effect,

class _MyHomePageState extends State<MyHomePage> {
  bool showBelow = true;

  @override
  void initState() {
    super.initState();
    WidgetsBinding.instance.addPostFrameCallback((timeStamp) async {
      await Future.delayed(Duration(seconds: 5));
      setState(() {
        showBelow = false;
      });
    });
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: Stack(
        children: <Widget>[
          if(showBelow) AlertDialog(
            title: Text('Below..'),
            content: Text('Beyond'),
          ),
          AlertDialog(
            title: Text('Above..'),
          ),
        ],
      ),
    );
  }
}

Upvotes: 0

Jitesh Mohite
Jitesh Mohite

Reputation: 34270

Remove

 await Future.delayed(Duration(seconds: 5));
 closeDialogBelowNotAbove();

Add Future.delayed

void showDialogAbove() {
    showDialog(
        context: context,
        builder: (BuildContext contextPopup) {
          Future.delayed(Duration(seconds: 5), () {
            closeDialogBelowNotAbove();
          });
          return AlertDialog(
            content: Container(
              height: 100.0,
              child: Center(
                child: Column(
                  mainAxisSize: MainAxisSize.min,
                  mainAxisAlignment: MainAxisAlignment.spaceEvenly,
                  children: [
                    CircularProgressIndicator(),
                    Text('I am above (this should not close)'),
                  ],
                ),
              ),
            ),
          );
        });
  }

Note: Navigator.pop() method always pop above alert/widget available on the screen, as it works with BuildContext which widget currently has.

Upvotes: -1

Related Questions