Flutter tutorials and courses by Andrea Bizzotto

Responsive layouts in Flutter: Split View and Drawer Navigation

Flutter, as a multi-platform UI toolkit, allows you to build apps on apps on mobile, desktop, and web with a single codebase.

However, taking a mobile app layout and "stretching" it to a large screen never leads to a great user experience:

Simple vertical layout on widescreen
Simple vertical layout on widescreen

Instead, it's much better to use a responsive layout that makes the most of the available screen estate.

Depending on your intended visual hierarchy, you can mix and match many different techniques, widgets, and packages to make a Flutter app responsive.

In this article, we'll focus on one very specific type of responsive layout and learn how to create a split view that looks like this on a widescreen:

Split view example app (widescreen)
Split view example app (widescreen)

and like this on mobile:

The same app on mobile with drawer navigation
The same app on mobile with drawer navigation

As we will see, this can be done by changing the top-level layout of your app when the window width crosses a certain threshold, using a so-called layout breakpoint.

On mobile, the desired layout can be implemented with a navigation drawer containing a menu we can use to switch between different pages.

By default, we should be able to:

  • open the drawer with the hamburger icon on the top-left corner (and close it with the back button).
  • reveal or dismiss it with an interactive drag gesture from the left edge of the screen.

All this functionality is built-in inside the Flutter Drawer widget, we will use it on mobile.

But on large screens we can easily fit both the menu and content side by side, so we don't need a Drawer.

We won't use any 3rd party packages because there is no need to. Instead, we'll rely on built-in Flutter widgets such as MediaQuery and Drawer.

So let's see what are the primary goals for this tutorial:

Goal #1: Reusable SplitView widget

We want to implement a custom SplitView widget that can be used in any app. As such, the widget API shouldn't make any assumptions about:

  • what pages exist in the app
  • what's inside the page selection menu

In other words, the SplitView widget should be reusable and take the menu and content widgets as arguments.

Goal #2: Page Selection with Riverpod

We want to enable page selection and switch between pages by selecting them from the menu.

To do this, we'll introduce some global application state and use the Riverpod package.

Goal #3: Drawer Navigation on Mobile

We want to enable drawer navigation so that the content page is always full-screen and we can open the menu on the side.

Along the way, we'll discover some interesting caveats about working with a hamburger menu with nested Scaffolds.

Ready? Let's go!

Starter Project

You can download the starter project from this page on GitHub and select the starter-project branch.

Let's start with a simple app that contains a couple of pages to choose from, along with an AppMenu widget for choosing between them:

Example app with drawer navigation
Example app with drawer navigation

This is what the AppMenu looks like:

// app_menu.dart import 'package:flutter/material.dart'; import 'package:split_view_example_flutter/first_page.dart'; import 'package:split_view_example_flutter/second_page.dart'; // a map of ("page name", WidgetBuilder) pairs final _availablePages = <String, WidgetBuilder>{ 'First Page': (_) => FirstPage(), 'Second Page': (_) => SecondPage(), }; class AppMenu extends StatelessWidget { @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar(title: Text('Menu')), body: ListView( // Note: use ListView.builder if there are many items children: <Widget>[ // iterate through the keys to get the page names for (var pageName in _availablePages.keys) PageListTile( pageName: pageName, ), ], ), ); } }

The _availablePages variable is just a map of WidgetBuilders that we'll use to build either the FirstPage or SecondPage depending on what page is selected.

We also have a PageListTile widget that we can use to represent each of the items in the list:

class PageListTile extends StatelessWidget { const PageListTile({ Key? key, this.selectedPageName, required this.pageName, this.onPressed, }) : super(key: key); final String? selectedPageName; final String pageName; final VoidCallback? onPressed; @override Widget build(BuildContext context) { return ListTile( // show a check icon if the page is currently selected // note: we use Opacity to ensure that all tiles have a leading widget // and all the titles are left-aligned leading: Opacity( opacity: selectedPageName == pageName ? 1.0 : 0.0, child: Icon(Icons.check), ), title: Text(pageName), onTap: onPressed, ); } }

This uses a ListTile widget with an onPressed callback that we can use to notify the parent widget when the tile is selected.

Note that onPressed is declared as a nullable VoidCallback? property. See my ultimate guide to Dart Null Safety if you're not familiar with this syntax.

This is how the two content pages are implemented:

// first_page.dart // Just a simple placeholder widget page // (in a real app you'd have something more interesting) class FirstPage extends StatelessWidget { @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar(title: Text('First Page')), body: Center( child: Text('First Page', style: Theme.of(context).textTheme.headline4), ), ); } } // SecondPage is identical, apart from the Text values

Inside main.dart, all we do is to return FirstPage as the home of the MaterialApp:

// main.dart void main() { runApp(MyApp()); } class MyApp extends StatelessWidget { @override Widget build(BuildContext context) { return MaterialApp( debugShowCheckedModeBanner: false, theme: ThemeData( primarySwatch: Colors.indigo, ), // just return `FirstPage` for now. We'll change this later home: FirstPage(), ); } }

Implementing the SplitView

As it stands, this app shows the FirstPage as a full-screen widget and doesn't have any page selection code yet.

So let's work out how to build this split view layout:

Split view example app (widescreen)
Split view example app (widescreen)

Let's create a simple SplitView widget with a single layout breakpoint:

// split_view.dart import 'package:flutter/material.dart'; import 'package:split_view_example_flutter/app_menu.dart'; import 'package:split_view_example_flutter/first_page.dart'; class SplitView extends StatelessWidget { @override Widget build(BuildContext context) { final screenWidth = MediaQuery.of(context).size.width; const breakpoint = 600.0; if (screenWidth >= breakpoint) { // widescreen: menu on the left, content on the right return Row( children: [ // use SizedBox to constrain the AppMenu to a fixed width SizedBox( width: 240, // TODO: make this configurable child: AppMenu(), ), // vertical black line as separator Container(width: 0.5, color: Colors.black), // use Expanded to take up the remaining horizontal space Expanded( // TODO: make this configurable child: FirstPage(), ), ], ); } else { // narrow screen: show content, menu inside drawer return Scaffold( body: FirstPage(), // use SizedBox to contrain the AppMenu to a fixed width drawer: SizedBox( width: 240, child: Drawer( child: AppMenu(), ), ), ); } } }

This works by comparing the screen width obtained from MediaQuery with a constant breakpoint:

  • If the screen width is greater than 600 points, we return a Row layout with the AppMenu on the left and the FirstPage on the right.
  • Otherwise, we return a Scaffold with the FirstPage as the body and Drawer(child: AppMenu()) as the drawer.

In both cases, we wrap the AppMenu with a SizedBox of fixed width (240 points).

If you want the menu to have a width that is proportional to the screen width in split view mode, replace the SizedBox with an Expanded widget and tweak the flex values of both Expanded widgets.


If we pass a SplitView() as the home of MaterialApp() and run the app on desktop, we can already resize the window and see that the top-level layout changes when we cross the breakpoint value of 600 points.

Testing the split view
Testing the split view

Note: the SplitView widget rebuilds when the window size changes. This is because we call MediaQuery.of(context) in the build() method. From the documentation of MediaQuery.of:

You can use this function to query the size and orientation of the screen, as well as other media parameters (see MediaQueryData for more examples). When that information changes, your widget will be scheduled to be rebuilt, keeping your widget up-to-date.

However, this widget is not reusable at all because:

  • both the menu width and breakpoint are hard-coded
  • both the content and menu widgets themselves are hard-coded

We can do better:

// split_view.dart import 'package:flutter/material.dart'; class SplitView extends StatelessWidget { const SplitView({ Key? key, // menu and content are now configurable required this.menu, required this.content, // these values are now configurable with sensible default values this.breakpoint = 600, this.menuWidth = 240, }) : super(key: key); final Widget menu; final Widget content; final double breakpoint; final double menuWidth; @override Widget build(BuildContext context) { final screenWidth = MediaQuery.of(context).size.width; if (screenWidth >= breakpoint) { // widescreen: menu on the left, content on the right return Row( children: [ SizedBox( width: menuWidth, child: menu, ), Container(width: 0.5, color: Colors.black), Expanded(child: content), ], ); } else { // narrow screen: show content, menu inside drawer return Scaffold( body: content, drawer: SizedBox( width: menuWidth, child: Drawer( child: menu, ), ), ); } } }

By introducing two Widget properties (menu and content), we let the parent widget decide what should go inside the SplitView. And the breakpoint and menuWidth are now configurable properties with sensible default values.

As a result, we can update the home argument of the MaterialApp:

MaterialApp( ... home: SplitView( menu: AppMenu(), content: FirstPage(), ) )

Much better.

For reference, this is a simplified diagram of the widget tree on widescreen:

Widget tree on widescreens
Widget tree on wider screens (SizedBox and Expanded widgets omitted for simplicity)

And this is the equivalent on mobile:

Widget tree on mobile
Widget tree on mobile (SizedBox and Expanded widgets omitted for simplicity)

As we can see, the mobile version has nested Scaffold widgets. We will get back to this later on when we tweak the drawer behaviour.

But before we get to that, we need to enable page selection.

Page Selection with Riverpod

Our app currently looks like this:

Split view (widescreen)
Split view (widescreen)

Note that there is no checkmark on any of the list items. And if we tap on the "Second Page" on the menu, nothing happens because we don't yet have a concept of currently selected page.

So how can we enable page selection?

Let's recall that we have defined this map of available pages:

// a map of ("page name", WidgetBuilder) pairs final _availablePages = <String, WidgetBuilder>{ 'First Page': (_) => FirstPage(), 'Second Page': (_) => SecondPage(), };

The keys of this map represent the page names that we show on the menu.

So we can define a state variable for the selected page name, and use it to:

  • show a checkmark next to the selected page inside the AppMenu.
  • return the widget (FirstPage or SecondPage) that corresponds to the selected page as the home of the MaterialApp.

This variable represents some global application state because both the AppMenu and the root widget (MyApp) need access to it.

So, how do we manage this global state, and how can widgets get access to it?

Any of the existing state management packages would do, or we could even use some of the built-in Flutter APIs such as ValueNotifier.

But for this tutorial, we're going to use Riverpod as it helps us easily handle both state management and dependency injection.

For more info about Riverpod, see my Riverpod Essential Guide.

To get this up and running, we can add the latest version to our pubspec.yaml:

dependencies: flutter: sdk: flutter flutter_riverpod: 1.0.0-dev.6

And we need to add a parent ProviderScope to our root widget:

void main() { runApp(ProviderScope(child: MyApp())); }

Then, let's add a StateProvider to our app_menu.dart:

// this is a `StateProvider` so we can change its value final selectedPageNameProvider = StateProvider<String>((ref) { // default value return _availablePages.keys.first; });

As the name implies, this provider will give us access to the selected page name. By default, it returns the first key inside _availablePages.

We declared selectedPageNameProvider as a StateProvider so that we can change its value. In this case, a StateNotifierProvider is not necessary because there's no business logic or backing store for this variable.

Reading the selected page

Let's update the AppMenu widget to use this:

// 1. extend from ConsumerWidget class AppMenu extends ConsumerWidget { // 2. Add a WidgetRef argument @override Widget build(BuildContext context, WidgetRef ref) { // 3. watch the provider's state final selectedPageName = ref.watch(selectedPageNameProvider).state; return Scaffold( appBar: AppBar(title: Text('Menu')), body: ListView( children: <Widget>[ for (var pageName in _availablePages.keys) PageListTile( // 4. pass the selectedPageName as an argument selectedPageName: selectedPageName, pageName: pageName, ), ], ), ); } }

In the build() method we use ref.watch to get the selected page name, and pass this as an argument to the PageListTile widget.

With this change, we now get the checkmark next to the first page in the list:

Split View Example

Updating the selected page

Next, let's enable page selection by adding an onPressed callback handler to our PageListTile:

PageListTile( selectedPageName: selectedPageName, pageName: pageName, onPressed: () => _selectPage(context, ref, pageName), )

We can define the _selectPage method like so:

void _selectPage(BuildContext context, WidgetRef ref, String pageName) { // only change the state if we have selected a different page if (ref.read(selectedPageNameProvider).state != pageName) { ref.read(selectedPageNameProvider).state = pageName; } }

With this change, we can now switch between the first and second page and the AppMenu widget rebuilds because we're using ref.watch to observe state changes in the build() method. As a result, the checkmark is also updated:

Menu selection: the checkmark updates when a page is selected
Menu selection: the checkmark updates when a page is selected

Updating the content page

However, the content page on the right-hand side still doesn't change because we're still passing a hard-coded FirstPage() to the SplitView inside MaterialApp.

To address that, let's define a new provider:

final selectedPageBuilderProvider = Provider<WidgetBuilder>((ref) { // watch for state changes inside selectedPageNameProvider final selectedPageKey = ref.watch(selectedPageNameProvider).state; // return the WidgetBuilder using the key as index return _availablePages[selectedPageKey]!; });

This one is quite neat, because it watches for changes in the selectedPageNameProvider, and returns the corresponding WidgetBuilder from the _availablePages map.

Note how selectedPageBuilderProvider is just a simple Provider (not a StateProvider). But it still returns a new value whenever the selectedPageNameProvider's state changes.

Let's use it inside MyApp:

// 1. extend from ConsumerWidget class MyApp extends ConsumerWidget { // 2. add a WidgetRef argument @override Widget build(BuildContext context, WidgetRef ref) { // 3. watch selectedPageBuilderProvider final selectedPageBuilder = ref.watch(selectedPageBuilderProvider); return MaterialApp( ... home: SplitView( menu: AppMenu(), // 4. use the WidgetBuilder content: selectedPageBuilder(context), ), ); } }

And if we test the app in split view mode now, everything works:

Second page selection
The content page is updated when a new page is selected

Drawer Navigation on Mobile

On the other hand, on mobile we still have some work to do.

In fact, we already can reveal the drawer by swiping it open from the left side of the screen:

Drawer - swipe to reveal
Drawer - swipe to reveal

But where is the hamburger menu icon? Shouldn't Flutter automatically add this for us?

After all, we have added a Drawer to the Scaffold inside the SplitView:

Widget tree on mobile
Widget tree on mobile (SizedBox and Expanded widgets omitted for simplicity)

But the Scaffold inside the SplitView doesn't have an AppBar to show the hamburger icon.

And the Scaffold inside the FirstPage (or SecondPage) has an AppBar but doesn't have a Drawer.

Classic chicken and egg problem! 🐣

How can we solve it?

Well, here's how we could fix this on the FirstPage:

class FirstPage extends StatelessWidget { @override Widget build(BuildContext context) { // 1. look for an ancestor Scaffold final ancestorScaffold = Scaffold.maybeOf(context); // 2. check if it has a drawer final hasDrawer = ancestorScaffold != null && ancestorScaffold.hasDrawer; return Scaffold( appBar: AppBar( // 3. add a non-null leading argument if we have a drawer leading: hasDrawer ? IconButton( icon: Icon(Icons.menu), // 4. open the drawer if we have one onPressed: hasDrawer ? () => ancestorScaffold!.openDrawer() : null, ) : null, title: Text('First Page'), ), body: Center( child: Text('First Page', style: Theme.of(context).textTheme.headline4), ), ); } }

We can show the hamburger icon by adding a leading argument and use the onPressed callback to open the drawer of the ancestor Scaffold.

However, there is no guarantee that an ancestor Scaffold even exists (in fact we don't have one in split view mode). So we can use Scaffold.maybeOf(context) with some defensive code to account for this.

With these changes, we can run the app on mobile, see the hamburger menu, and use it to open the drawer.

Hamburger menu icon
Hamburger menu icon

But we don't want to copy-paste all this new code for each new page that we add.

Instead, let's create a reusable PageScaffold widget:

class PageScaffold extends StatelessWidget { const PageScaffold({ Key? key, required this.title, this.actions = const [], this.body, this.floatingActionButton, }) : super(key: key); final String title; final List<Widget> actions; final Widget? body; final Widget? floatingActionButton; @override Widget build(BuildContext context) { // 1. look for an ancestor Scaffold final ancestorScaffold = Scaffold.maybeOf(context); // 2. check if it has a drawer final hasDrawer = ancestorScaffold != null && ancestorScaffold.hasDrawer; return Scaffold( appBar: AppBar( // 3. add a non-null leading argument if we have a drawer leading: hasDrawer ? IconButton( icon: Icon(Icons.menu), // 4. open the drawer if we have one onPressed: hasDrawer ? () => ancestorScaffold!.openDrawer() : null, ) : null, title: Text(title), actions: actions, ), body: body, floatingActionButton: floatingActionButton, ); } }

Now that we have this, we can simplify our FirstPage and SecondPage widgets:

import 'package:flutter/material.dart'; import 'package:split_view_example_flutter/page_scaffold.dart'; class FirstPage extends StatelessWidget { @override Widget build(BuildContext context) { return PageScaffold( title: 'First Page', body: Center( child: Text('First Page', style: Theme.of(context).textTheme.headline4), ), ); } } // same for SecondPage

Much better.

We have only one thing left to do!

Dismissing the drawer when a new page is selected

Let's recall the _selectPage method inside our AppMenu:

void _selectPage(BuildContext context, WidgetRef ref, String pageName) { // only change the state if we have selected a different page if (ref.read(selectedPageNameProvider).state != pageName) { ref.read(selectedPageNameProvider).state = pageName; } }

This ensures that the content page is updated when we select a new page.

But it doesn't dismiss the drawer automatically. Let's fix this:

void _selectPage(BuildContext context, WidgetRef ref, String pageName) { // only change the state if we have selected a different page if (ref.read(selectedPageNameProvider).state != pageName) { ref.read(selectedPageNameProvider).state = pageName; // dismiss the drawer of the ancestor Scaffold if we have one if (Scaffold.maybeOf(context)?.hasDrawer ?? false) { Navigator.of(context).pop(); } } }

All done! If we try the app now, all the drawer-based navigation works as expected.

Drawer page selection
Drawer page selection

Wrap Up

Split view is a useful UX pattern that makes good use of the available screen space on bigger form factors.

But we need to ensure that drawer-based navigation still works as expected on mobile, and pay attention to details when working with nested Scaffolds.

The SplitView and PageScaffold widgets we have created are portable, and you should be able to use them "as-is" on your projects or tweak them as needed.

You can find the completed project for this tutorial on GitHub.

And if you end up using this in your projects, share your feedback on Twitter.

Where to go from here?

Adding a split view with a single layout breakpoint is a good step towards making your app responsive.

And in addition to what we have covered, Flutter offers many useful responsive layout widgets that can help you out:

For apps with more complex layouts, consider adding multiple layout breakpoints, and even have more than two horizontal sections on large screens:

Layout with three horizontal sections
Layout with three horizontal sections. See Dribbble for design inspiration.

Once you tackle complex responsive layouts, then it becomes advisable to look for packages that can do some of the heavy lifting for you. Here are a couple of good ones:

So go ahead my friend, and make your apps responsive! 🚀

Happy coding!

Want more?

Fast-track your Flutter learning with over 40 hours of in-depth content.