Tutorial: Simple Responsive Master-Detail View in Flutter

I have a confession to make. Most of the time when developing apps, I did not think about supporting tablets. I did not bother thinking about how to structure my app that it also can be used on a larger screen.

Mostly, the reason I did not add tablet support to my apps was because I thought it would take me far more time to implement. I won’t say that supporting multiple screens won’t require more work, but it is also simpler than you think.

In this tutorial I want to demonstrate how to create a simple responsive master-detail view application in Flutter. The app will contain a list of items each of them can be clicked to show more detail.

You can also checkout the complete project from github.

Prerequisite

Like in my other tutorials about creating a splash screen and using Sembast as your data storage solution, we start this tutorial by creating a new Flutter project and cleaning up all the comments from pubspec.yaml and main.dart.

We let our main.dart file be for this moment and start by adding the required dependencies to our pubspec.yaml.

pubspec.yaml

name: master_detail description: Responsive Master/Detail publish_to: 'none' version: 1.0.0+1 environment: sdk: ">=2.7.0 <3.0.0" dependencies: flutter: sdk: flutter equatable: ^1.1.1 flutter_bloc: ^4.0.0 flutter: uses-material-design: true

This should look familiar to you. The only thing I added here are the dependencies to equatable and flutter_bloc.

Equatable will help us to compare objects by its values rather than by reference, and flutter_bloc will provide us with the framework we use for state management.

A simple data class

First, we need a class containing the data we want to show in the app. We keep this to an absolute minimum and create a class Item under a new directory data that has two attributes: name and detail.

data/item.dart

import 'package:equatable/equatable.dart'; class Item extends Equatable { final String name; final String detail; Item(this.name, this.detail); factory Item.fromItem(Item item){ if (item == null){ return null; } else { return Item(item.name, item.detail); } } @override List<Object> get props => [name, detail]; }

Lines 9 – 15 define a copy constructor that does noting else than creating a new instance of Item with the same attribute values as the instance passed via the parameter.

Line 18 overrides the props getter that is required since Item extends Equatable. All elements of the list returned as props are used when comparing instances of Item.

The Logic using the Bloc Pattern

We are using the bloc (business logic component) pattern in this example. We already included flutter_bloc in the pubspec.yaml before. To generate the boilerplate code, I use the Bloc Code Generator in Android Studio. This reduces the amount of code I have to write, but it is not required to use this plugin.

First, create a new directory and call it bloc. In Android Studio right click on the directory → New → Bloc Generator → New Bloc and enter “master_detail” as the name. Check “Do you want to use equatable” and click on ok. This will generate 4 files: bloc.dart, master_detail_event.dart, master_detail_state.dart, and master_detail_bloc.dart.

bloc/bloc.dart

export 'master_detail_bloc.dart'; export 'master_detail_event.dart'; export 'master_detail_state.dart';

This file simply allows to import these three files using one import statement: If you import bloc.dart, these files listed here are imported.

bloc/master_detail_event.dart

import 'package:equatable/equatable.dart'; import 'package:master_detail/data/item.dart'; abstract class MasterDetailEvent extends Equatable { const MasterDetailEvent(); } class LoadItemsEvent extends MasterDetailEvent { @override List<Object> get props => []; } class AddItemEvent extends MasterDetailEvent { final Item element; AddItemEvent(this.element); @override List<Object> get props => [element]; } class SelectItemEvent extends MasterDetailEvent { final Item selected; SelectItemEvent(this.selected); @override List<Object> get props => [selected]; }

In this file we create three Events that can be sent to our bloc to request a state change. All three Events extend our abstract class MasterDetailEvent and we again use Equatable for comparison.

The LoadItemsEvent notifies the bloc to load all existing items. AddItemEvent requests to add a new item specified by the event’s attribute element. SelectItemEvent will be used to notify the bloc that an Item has been selected in the list.

bloc/master_detail_state.dart

import 'package:equatable/equatable.dart'; import 'package:master_detail/data/item.dart'; abstract class MasterDetailState extends Equatable { const MasterDetailState(); } class LoadingItemsState extends MasterDetailState { @override List<Object> get props => []; } class NoItemsState extends MasterDetailState { @override List<Object> get props => []; } class LoadedItemsState extends MasterDetailState { final List<Item> elements; final Item selectedElement; LoadedItemsState(this.elements, this.selectedElement); @override List<Object> get props => [selectedElement, ...elements]; }

Where an event is the input to our bloc, a state is what our bloc will give us in return. For this example our app will have 3 different states: LoadingItemsState, NoItemsState and LoadedItemsState.

Depending on the state we will display different UI elements in our app. Where LoadingItemsState and NoItemsState do not contain any additional information, the LoadedItemsState holds a list of Items and the currently selected Item.

bloc/master_detail_bloc.dart

import 'dart:async'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:master_detail/data/item.dart'; import 'bloc.dart'; class MasterDetailBloc extends Bloc<MasterDetailEvent, MasterDetailState> { List<Item> _items = []; Item _selected; @override MasterDetailState get initialState => LoadingItemsState(); @override Stream<MasterDetailState> mapEventToState( MasterDetailEvent event, ) async* { if (event is AddItemEvent) { _items.add(event.element); } else if (event is SelectItemEvent) { _selected = event.selected; } yield* _loadItems(); } Stream<MasterDetailState> _loadItems() async* { if (_items.isEmpty) { yield NoItemsState(); } else { final newState = LoadedItemsState([..._items], Item.fromItem(_selected)); yield newState; } } }

This class is the heart of our business logic. For the sake of simplicity we do not use any kind of database in this example, but we are going to store the list of items and the selected item as attributes of the bloc (lines 9 & 10).

If you want to know how to use a real database storage for your app have a look at my tutorial “Sembast as local data storage in Flutter”. Let me know in the comments if you would like another tutorial about Sembast, using the bloc pattern instead of Flutter’s default state management.

Let’s have a look at the mapEventToState function. Here we receive all events that are sent to our block. The aim of the function is to figure out which state the app should have depending of the received events.

First, we check if the event is an AddItemEvent. In this case we add the received item (passed through the event as a parameter) to our list. Second, we check if the event is a SelectItemEvent. If this is the case we assign the selected element to our variable.

After handling any of the events we call the _loadItems function. All it does is to create a new state depending if there are no items (NoItemsState) or there are some items stored in the bloc (LoadedItemsState).

Note that we do not explicitly handle the LoadItemsEvent. Since _loadItems is called regardless of which event we receive, we do not need any other functionality to be executed when a LoadItemsEvent is received.

The responsive Master-Detail UI

Finally we are going to build the UI for our application. For the UI files create a new directory called ui. First we have a look at the files master.dart and detail.dart.

ui/master.dart

import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:master_detail/bloc/bloc.dart'; import 'package:master_detail/data/item.dart'; import 'package:master_detail/ui/detail.dart'; class Master extends StatefulWidget { @override _MasterState createState() => _MasterState(); } class _MasterState extends State<Master> { int elementCount = 0; MasterDetailBloc _bloc; @override void initState() { super.initState(); _bloc = BlocProvider.of(context); _bloc.add(LoadItemsEvent()); } @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( title: Text("Master"), actions: <Widget>[ IconButton(icon: Icon(Icons.add), onPressed: _addItem) ], ), backgroundColor: Color(0xffefefef), body: BlocBuilder( bloc: _bloc, builder: (context, state) { if (state is LoadingItemsState) { return Center(child: CircularProgressIndicator()); } else if (state is NoItemsState) { return Center(child: Text("No Items")); } else if (state is LoadedItemsState) { return ListView.builder( itemCount: state.elements.length, itemBuilder: (context, index) { final item = state.elements[index]; return ListTile( title: Text(item.name), selected: item == state.selectedElement, onTap: () => _selectItem(context, item), ); }, ); } throw Exception("unexpected state $state"); }, ), ); } _addItem() { final newItem = Item( "name $elementCount", "This is the detail for element $elementCount", ); _bloc.add(AddItemEvent(newItem)); elementCount++; } _selectItem(BuildContext context, Item item) { _bloc.add(SelectItemEvent(item)); if (MediaQuery.of(context).size.shortestSide < 768) { final route = MaterialPageRoute(builder: (context) => Detail()); Navigator.push(context, route); } } }

If you have worked with bloc before, this should look familiar. We have a stateful widget Master here. In its state we retrieve the MasterDetailBloc we have created earlier (line 19) and use it in a BlocBuilder to create the UI depending on its state.

What is interesting here – from the perspective of building a responsive app – is line 70: Here we check if the screen is smaller than 768 (which we will then classify as a smartphone). When selecting an item on a small screen size like this, we need to navigate to a different view to show the detailed information of the item. For a tablet this is not necessary, since we have plenty of space to show this information right next to the Master list.

ui/detail.dart

import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:master_detail/bloc/bloc.dart'; class Detail extends StatefulWidget { @override _DetailState createState() => _DetailState(); } class _DetailState extends State<Detail> { MasterDetailBloc _bloc; @override void initState() { super.initState(); _bloc = BlocProvider.of<MasterDetailBloc>(context); } @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( title: Text("Detail"), ), body: BlocBuilder( bloc: _bloc, builder: (context, state) { if (state is LoadedItemsState) { return Center( child: Text(state.selectedElement?.detail ?? "No item selected"), ); } else { return Container(); } }, ), ); } }

The Detail widget looks very similar to the Master widget. It also retrieves the MasterDetailBloc and uses BlocBuilder to build a state-dependent UI. Here we use the LoadedItemsState‘s selectedElement attribute to get the detailed information to display.

ui/home_page.dart

import 'package:flutter/material.dart'; import 'package:master_detail/ui/detail.dart'; import 'package:master_detail/ui/master.dart'; class HomePage extends StatelessWidget { @override Widget build(BuildContext context) { return LayoutBuilder( builder: (context, constraints) { if (constraints.maxWidth > 768) { return _TabletHomePage(); } else { return _MobileHomePage(); } }, ); } } class _MobileHomePage extends StatelessWidget { @override Widget build(BuildContext context) { return Master(); } } class _TabletHomePage extends StatelessWidget { @override Widget build(BuildContext context) { return Row( children: <Widget>[ Container(width: 300, child: Master()), Expanded(child: Detail()) ], ); } }

For our main screen (HomePage) we use a LayoutBuilder to decide if we want to display the tablet or the mobile version of our screen. As in the master.dart file we set our breakpoint to 768 pixel to decide whether or not the device is a tablet.

For the mobile version of the HomePage (_MobileHomePage) we only display the Master widget. For the tablet version (_TabletHomePage) we show both: Master and Detail. We limit the width of the Master widget to 300px and let the Detail take the rest of the space.

main.dart

import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:master_detail/bloc/bloc.dart'; import 'package:master_detail/ui/home_page.dart'; void main() => runApp(MyApp()); class MyApp extends StatelessWidget { @override Widget build(BuildContext context) { return BlocProvider( create: (context) => MasterDetailBloc(), child: MaterialApp(home: HomePage()), ); } }

Finally, all that is left is to adapt the main.dart file to run our application. We use BlocProvider to make our MasterDetailBloc available throughout the widget tree and create a very basic MaterialApp showing our HomePage.

Now when we run the app on a smartphone the app uses two separate views to display the master (overview) widget and the detail widget. On a tablet we are making use of the additional screen space to display both widgets next to each other.

Flutter Responsive Master-Detail Mobile
On smaller screens the application is displayed using two separate screens to show the master and the detail view.
Flutter Responsive Master-Detail Tablet
On tables we have both views combined in one screen.

Summary

In this tutorial we had a look on how to create a responsive master-detail view app in Flutter. By using the bloc pattern it is very easy to have independent widgets reacting on a common state of the app.

As mentioned before, you can find the complete project on github.

I hope this tutorial helps you to make your Flutter apps also available for larger screens without putting too much additional effort in the development process. Leave a comment if you like this tutorial and if you have any remarks on building a responsive Flutter app.

2 responses to “Tutorial: Simple Responsive Master-Detail View in Flutter”

  1. Like!! Great article post.Really thank you! Really Cool.

Leave a Reply

Your email address will not be published. Required fields are marked *