Simple Responsive Master-Detail View in Flutter with BLoC

In this tutorial I show you do get a responsive layout that supports phones and tablets.

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

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
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

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
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 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

1
2
3
export 'master_detail_bloc.dart';
export 'master_detail_event.dart';
export 'master_detail_state.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

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
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

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
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

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
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 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

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
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 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

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
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

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
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 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

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
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.

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.

Built with Hugo
Theme based on Stack designed by Jimmy