Flutter UI Design

Overview

I wanted to name this article “Navigation Inside a Dialog” because I was initially searching the web for a solution to creating a settings style dialog for a tablet app. I needed to display a list of settings in the dialog. As with many apps, some settings would be simple switches and others would require selection by navigating to another screen and returning (or setting) a value.


Flutter makes it incredibly easy to navigate from screen to screen. You can show a new screen with the command:

Navigator.of(context).push(...);
//or
Navigator.push(context, ...);

and dismiss the screen with:

Navigator.of(context).pop(...);
//or
Navigator.pop(context, ...);

The caveat is if you show a dialog and call the Navigator.push command from within that dialog, the entire screen, including the dialog, disappears. That was not quite the behavior I wanted. As mentioned above, I spent some time searching the web trying to find a solution but I didn’t find anything that was apparent. By chance, I found an article on Medium by Andrea Bizzotto that pointed me in the direction of the Navigator class.

Features

As indicated above, this design would allow navigating from inside a dialog. The intended result is:

  • display a dialog (Material or Cupertino)
  • display a list of settings. Our settings will be as follows:
    – Dark Mode– Accent Color– FAQs
  • navigate to another screen inside the dialog when the user taps a specific setting
  • return a value from the screen when the user makes a selection
  • display a confirmation dialog while inside the dialog (I will explain the necessity for this later)

Implementation

First, create the typical Flutter app and then replace the contents of the generated main.dart file with the following.

import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';

import 'settings.dart';
import 'state.dart';

void main() {
  runApp(SettingsContainer(
    child: MaterialApp(
      home: MyApp(),
    ),
    settings: Settings(accentColor: Colors.green),
  ));
}

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    final settings = SettingsContainer.of(context).settings;
    return Theme(
      data: settings.isDark
          ? ThemeData.dark()
          : ThemeData.light().copyWith(
              primaryTextTheme: TextTheme(
                title: TextStyle(
                  color: Colors.grey.shade800,
                ),
              ),
              appBarTheme: AppBarTheme(
                brightness:
                    settings.isDark ? Brightness.dark : Brightness.light,
                color: Colors.grey.shade100,
              ),
            ),
      child: Scaffold(
        appBar: AppBar(
          title: Text('Navigate Anywhere'),
          elevation: 0.0,
        ),
        body: Center(
          child: _makeButton(context),
        ),
      ),
    );
  }

  _makeButton(context) {
    final container = SettingsContainer.of(context);
    bool isAndroid = Theme.of(context).platform == TargetPlatform.android;
    var showDialogAction = () {
      showDialog(
        context: context,
        builder: (context) {
          return _makeDialog(isAndroid);
        },
      );
    };
    var buttonChild = Text(
      'Show Settings',
      style: TextStyle(
        color: container.settings.textColor,
      ),
    );
    if (isAndroid) {
      return FlatButton(
        onPressed: showDialogAction,
        color: container.settings.accentColor,
        child: buttonChild,
      );
    } else {
      return CupertinoButton(
        onPressed: showDialogAction,
        color: container.settings.accentColor,
        child: buttonChild,
      );
    }
  }

  Dialog _makeDialog(bool isAndroid) {
    double _cornerRadius = isAndroid ? 0 : 12;
    return Dialog(
      shape: RoundedRectangleBorder(
          borderRadius: BorderRadius.circular(_cornerRadius)), //this right here
      child: ClipRRect(
        borderRadius: BorderRadius.circular(_cornerRadius),
        child: Container(
          height: 300.0,
          width: 350.0,
          child: SettingsDialog(),
        ),
      ),
    );
  }
}

This file displays a simple button in the center of the screen that will be used to display the dialog. I use the accent color from the settings to set the background color of the button. I also change the overall app theme from dark to light based on the isDark setting. This file is also used to create the dialog that will be displayed.

Next, create a new dart file and name it settings.dart. Copy the following contents into this file.

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

import 'state.dart';

class SettingsDialog extends StatelessWidget {
  final GlobalKey<NavigatorState> settingsNavKey = GlobalKey<NavigatorState>();

  SettingsDialog();

  @override
  Widget build(BuildContext context) {
    return Navigator(
      key: settingsNavKey,
      initialRoute: 'settings/home',
      onGenerateRoute: (routeSettings) {
       WidgetBuilder builder;
        switch (routeSettings.name) {
          case 'settings/home':
            builder = (BuildContext _) => SettingsHome(context);
            break;
          case 'settings/color':
            builder = (BuildContext _) => SettingsAccentColor(context);
            break;
        }
        return MaterialPageRoute(
          builder: builder,
          settings: routeSettings,
       );
      },
    );
  }
}

class SettingsHome extends StatelessWidget {
  final BuildContext topContext;

  SettingsHome(this.topContext);

  @override
  Widget build(BuildContext context) {
    final container = SettingsContainer.of(topContext);
    bool isAndroid = Theme.of(context).platform == TargetPlatform.android;
    bool isDark = container.settings.isDark;
    Color accentColor = container.settings.accentColor;
    return new Scaffold(
      appBar: new AppBar(
        title: Text('Settings'),
        elevation: 0.0,
        actions: <Widget>[
          IconButton(
            onPressed: () => Navigator.of(topContext).pop(),
            icon: Icon(Icons.close),
          ),
        ],
      ),
      body: ListView(
        children: <Widget>[
          ListTile(
            title: Text('Dark Mode'),
            trailing: Switch.adaptive(
              value: isDark,
              onChanged: (bool value) {
                container.updateSettings(isDark: value);
              },
              activeColor: container.settings.accentColor,
            ),
          ),
          ListTile(
            title: Text('Accent Color'),
            trailing: SizedBox(
              width: 100,
              child: Row(
                mainAxisAlignment: MainAxisAlignment.end,
                children: <Widget>[
                  Container(
                    width: 30,
                    height: 30,
                    decoration: BoxDecoration(
                      color: accentColor,
                      shape: BoxShape.circle,
                    ),
                  ),
                  Icon(Icons.chevron_right),
                ],
              ),
            ),
            onTap: () => Navigator.pushNamed(context, 'settings/color'),
          ),
          ListTile(
            title: Text('FAQs'),
            trailing: Icon(Icons.info_outline),
            onTap: () {
              showDialog(
                  context: topContext,
                  builder: (BuildContext _) {
                    String title = 'Open External';
                    String content =
                        'Are you sure you want to navigate outside of the app?';
                    return isAndroid
                        ? AlertDialog(
                            title: Text(title),
                            content: Text(content),
                            actions: <Widget>[
                              FlatButton(
                                child: Text('No'),
                                onPressed: () => Navigator.pop(topContext),
                              ),
                              FlatButton(
                                child: Text('Yes'),
                                onPressed: () => Navigator.pop(topContext),
                              ),
                            ],
                          )
                        : CupertinoAlertDialog(
                            title: Text(title),
                            content: Text(content),
                            actions: <Widget>[
                              CupertinoDialogAction(
                                child: Text('No'),
                                isDestructiveAction: true,
                                onPressed: () => Navigator.pop(topContext),
                              ),
                              CupertinoDialogAction(
                                child: Text('Yes'),
                                isDefaultAction: true,
                                onPressed: () => Navigator.pop(topContext),
                              ),
                            ],
                          );
                  });
            },
          ),
        ],
      ),
    );
  }
}

class SettingsAccentColor extends StatelessWidget {
  final BuildContext topContext;

  SettingsAccentColor(this.topContext);

  @override
  Widget build(BuildContext context) {
    final container = SettingsContainer.of(topContext);
    Color accentColor = container.settings.accentColor;
    Color iconColor = container.settings.textColor;
    return new Scaffold(
      appBar: new AppBar(
        title: Text('Select Accent Color'),
        elevation: 0.0,
      ),
      body: ListView.builder(
        itemBuilder: (context, index) {
          return Container(
            child: ListTile(
              trailing: accentColor == Colors.primaries[index]
                  ? Icon(
                      Icons.radio_button_checked,
                      color: iconColor,
                    )
                  : Container(),
              onTap: () {
                container.updateSettings(color: Colors.primaries[index]);
                Navigator.pop(context, Colors.primaries[index]);
              },
            ),
            color: Colors.primaries[index],
            margin: EdgeInsets.all(5.0),
          );
        },
        itemCount: Colors.primaries.length,
      ),
    );
  }
}

This file has three widget classes that will be used to display the settings. SettingsDialog class creates the Navigator that is the main topic of this article. The key here is to create a global key that uses NavigatorState and use that key in the creation of the Navigator. In the onGenerateRoute method, I defined the names of the possible routes and the builder used to display the associated widget. Note that on the builder function uses the underscore parameter so that the app level context object is not overridden and the app level context can be passed to the displayed settings widget. This will be explained more.

The next class is the SettingsHome. The class receives a BuildContext as a parameter. This context object isn’t required to make this solution work but it is important if you want to reference the context that opened the dialog. Tapping outside of the dialog will close it by default. But if you chose to do this programmatically, you will need to reference this context when you execute the command: Navigate.pop(context). The purpose of this class is to display the list of settings items. In this use case, it displays an item with a switch widget, an item that navigates to another screen, and an item that shows an AlertDialog. The last two are important in this solution. Because we have created a new context by using a Navigator class with its on GlobalKey, calling

Navigator.pushNamed(context, ‘settings/color’);

will cause the app the perform the navigation only within the parent Widget of the Navigator, which in this case is the Dialog.

As far as the AlertDialog, I found that when it is displayed, it uses the app level context by default. In order to properly dismiss it, we need a reference to the same app level context. This is where we make use of the context that was passed as a parameter.

The last class included in this file is SettingsAccentColor. It simply acts as a selection screen for choosing the accent color. It displays a list of the primary material design colors. When one is tapped, the color is set using the InheritedWidget. In this example, it is also returned with the Navigator.pop command as a demonstration.

Last, create another file and name it state.dart. This file will contain the model and the InheritedWidget used to pass the settings throughout the app.

Copy the following contents into this new file.

import 'package:flutter/material.dart';

class Settings {
  final bool isDark;
  final Color accentColor;

  Settings({this.isDark: false, this.accentColor});

  Settings copyWith({bool isDark, Color accentColor}) {
    return Settings(
      isDark: isDark ?? this.isDark,
      accentColor: accentColor ?? this.accentColor,
    );
  }

  Color get textColor {
    if (accentColor.computeLuminance() > 0.6) {
      return Colors.black;
    } else {
      return Colors.white;
    }
  }
}

class InheritedSettingsContainer extends InheritedWidget {
  final SettingsContainerState data;

  InheritedSettingsContainer({
    Key key,
    @required this.data,
    @required Widget child,
  }) : super(key: key, child: child);

  @override
  bool updateShouldNotify(InheritedSettingsContainer old) => true;
}

class SettingsContainer extends StatefulWidget {
  final Widget child;
  final Settings settings;

  SettingsContainer({
    @required this.child,
    this.settings,
  });

  static SettingsContainerState of(BuildContext context) {
    return (context.inheritFromWidgetOfExactType(InheritedSettingsContainer)
            as InheritedSettingsContainer)
        .data;
  }

  @override
  SettingsContainerState createState() => new SettingsContainerState();
}

class SettingsContainerState extends State<SettingsContainer> {
  Settings settings;

  @override
  void initState() {
    settings = widget.settings ?? new Settings();
    super.initState();
  }

  void updateSettings({isDark, color}) {
    setState(() {
      settings = settings.copyWith(
        isDark: isDark,
        accentColor: color,
      );
    });
  }

  @override
  Widget build(BuildContext context) {
    return new InheritedSettingsContainer(
      data: this,
      child: widget.child,
    );
  }
}

Conclusion

The Navigator class allows your app to use multiple areas for navigation. As indicated by the article title, navigation can exist anywhere: a Dialog, a small section in the app, the master and detail areas of a MasterDetail app, a ClipPath with an abnormal shape,...you name it. If it can receive a child widget, you can navigate within it.


The complete source code can be found here.