logo
icon_menu
  1. Home
  2. Categories
  3. Flutter

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
Code language: YAML (yaml)

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]; }
Code language: Dart (dart)

Lines 9 – 15 define a copy constructor that does nothing 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';
Code language: Dart (dart)

This file simply allows importing 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]; }
Code language: Dart (dart)

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]; }
Code language: Dart (dart)

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; } } }
Code language: Dart (dart)

This class is the heart of our business logic. For 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 on 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 on 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); } } }
Code language: Dart (dart)

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 about 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(); } }, ), ); } }
Code language: Dart (dart)

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()) ], ); } }
Code language: Dart (dart)

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 pixels to decide whether 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()), ); } }
Code language: Dart (dart)

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 at how to create a responsive master-detail view app in Flutter. By using the bloc pattern, it is 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.

Comments

ปั้มไลค์ says:

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

Stefan Galler says:

Thank you. I’m glad you like it 🙂

Sam says:

This is great arctical , simple and clear

Stefan Galler says:

Thank you 🙂

David says:

Hey
I try to implement this with an auth bloc and theme_bloc (MultiBlocProvider), I can login, choose my theme, but when I tap on an item I have nothing, State is “Init”.

https://github.com/Climberdav/flutter_bloc_test

Kuro says:

This is great. I mean, tablets like Galaxy Tab and iOS, and now, web, certainly more appropriate to be presented with this kind of view. In fact, I was thinking that this should be included in the main library on Flutter as an optional approach.

Ladislav Zítka says:

Hi, thx for the article, I like BLOC pattern and trying to addpt it for my first application. The thing/issue I have is that I use async http call, so my fetch service looks like this:
Future<List> fetchStorageObjects() async {
final response = await http
.get(Uri.parse(‘http://localhost:8080/api/storage/v1/objects?bucketName=prokyon-systems-document-storage’));
if (response.statusCode == 200) {
// If the server did return a 200 OK response,
// then parse the JSON.
return compute(parseStorageObjects,response.body);
} else {
// If the server did not return a 200 OK response,
// then throw an exception.
throw Exception(‘Failed to load album’);
}
}

Now I tried to integrate the service call into LoadedItemsState like this, but I think I lack deep understanding of what am I doing at moment:
class LoadedItemsState extends MasterDetailState {
List elements;
final StorageObject selectedElement;

LoadedItemsState(this.elements, this.selectedElement);

void getData() async {
elements = await StorageService().fetchStorageObjects();
}

@override
List get props => [selectedElement, …elements];
}

So this doesn’t work.

Also the code is complaining about this:
StorageObject _selected; // StorageObject is model class like your item, looks like this:
import ‘package:equatable/equatable.dart’;

class StorageObject extends Equatable{
final String key;
final int size;
final String owner;
String url = “”;

StorageObject({
required this.key,
required this.size,
required this.owner,
});

factory StorageObject.fromItem(StorageObject storageObject){
return StorageObject(
key: storageObject.key,
size: storageObject.size,
owner: storageObject.owner);
}

factory StorageObject.fromJson(Map json) {
return StorageObject(
key: json[‘key’] as String,
size: json[‘size’] as int,
owner: json[‘owner’] as String,
);
}

@override
List get props => [key, size, owner];
}

Intellij Idea is enforcing me to add late, but then I get:
Uncaught (in promise) Error: Unhandled error LateInitializationError: Field ‘_selected’ has not been initialized. occurred in bloc Instance of ‘MasterDetailBloc’.

Stefan says:

Note, that this tutorial was written with a Flutter version before null-safety was introduced.
Simply use “StorageObject? _selected”, so it is a null-able variable like before.

Leave a Reply

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