You've successfully subscribed to developer.school
Great! Next, complete checkout for full access to developer.school
Welcome back! You've successfully signed in.
Success! Your account is fully activated, you now have access to all content.
Success! Your billing info is updated.
Billing info update failed.

Using the Master-Detail Pattern with Flutter

Paul Halliday
Paul Halliday

By now, you'll have used many applications which follow the one of the most popular UX patterns - Master > Detail views. You may not know it by name, but it's essentially comprised of:

  1. A list of some elements, let's call it a list of emails with partial information (a title and a small excerpt of the content).
  2. On selecting an email, we're taken to a page with the full content of that email.

This doesn't always mean we have to essentially navigate away; the UI can open up to a side-by-side view, a dialog can overlay the current content and countless other examples.

We'll be looking at how to implement this using some mock data and the variety of options available to us.

Project Setup

We'll be starting with a new Flutter project. Head over to your terminal and run:

$ flutter create ds_master_detail

$ cd ds_master_detail

$ code . (or your favourite editor)

Don't open it on a device or simulator just yet - we'll need to add dartz to our pubspec.yaml:

dependencies:
  flutter:
    sdk: flutter

  dartz: ^0.9.2

dev_dependencies:
  flutter_test:
    sdk: flutter

I've also elected to add dartz here, as this gives us access to the Either<X, Y> type which we're using inside of our StateNotifier. This can just as easily be omitted though; so feel free to write something similar without it.

Product Model

Let's start off by defining what a Product is, and then we'll make a List<Contact> which contains a standard set of products:

import 'package:flutter/foundation.dart';
import 'package:flutter/widgets.dart';

extension PrintDate on DateTime {
  String toNiceString() {
    return "${this.year}/${this.month}/${this.day}";
  }
}

@immutable
class Product {
  final int id;
  final String title;

  final DateTime _dateCreated;
  String get dateCreated => _dateCreated.toNiceString();

  final String _description;
  String description({bool shortened = false}) => shortened
      ? _description.replaceRange(
          _description.length > 150 ? 150 : _description.length,
          _description.length,
          "...")
      : _description;

  const Product({
    @required this.id,
    @required this.title,
    @required String description,
    @required DateTime dateCreated,
  })  : _dateCreated = dateCreated,
        _description = description;

  @override
  String toString() {
    return "Product $id: $title";
  }
}
src/models/product_model.dart

I've added an extension on DateTime which allows us to call toNiceString() on any DateTime elements. This'll be used later down the line inside of our UI when seeing more information about a specific product.

Product List

We can then define a Product list constant which contains some reasonable defaults that we can use inside of our upcoming ProductService:

final List<Product> kProductList = [
  Product(
    id: 0,
    title: "Magic Wand",
    dateCreated: DateTime(2020, 11, 16),
    description:
        "Bacon ipsum dolor amet ham hock turducken t-bone, pork chop brisket picanha venison cupim pork meatloaf pig short ribs",
  ),
  Product(
    id: 1,
    title: "Eye of Newt",
    dateCreated: DateTime(2020, 11, 17),
    description:
        "Ground round venison brisket, swine pork loin turducken rump burgdoggen",
  ),
  Product(
      id: 2,
      title: "Staff of Power",
      dateCreated: DateTime(2020, 11, 18),
      description:
          "Ham hock cow landjaeger pork loin brisket beef ribs pancetta pastrami tri-tip spare ribs chuck kevin porchetta picanha sausage. Pork chop turkey leberkas rump, ground round ham boudin short loin capicola"),
  Product(
    id: 3,
    title: "Crystal Ball",
    dateCreated: DateTime(2020, 11, 19),
    description:
        "Ribeye biltong boudin venison meatloaf rump fatback cow prosciutto strip steak pork loin burgdoggen. Ham hock chuck shoulder jowl. Drumstick salami shoulder pork chop short ribs kielbasa sirloin frankfurter. Leberkas drumstick kielbasa, jowl chicken pork chop frankfurter prosciutto fatback shankle shoulder buffalo sirloin swine beef",
  ),
  Product(
    id: 4,
    title: "Magic Cloak",
    dateCreated: DateTime(2020, 11, 20),
    description:
        "Ribeye biltong boudin venison meatloaf rump fatback cow prosciutto strip steak pork loin burgdoggen. Ham hock chuck shoulder jowl. Drumstick salami shoulder pork chop short ribs kielbasa sirloin frankfurter. Leberkas drumstick kielbasa, jowl chicken pork chop frankfurter prosciutto fatback shankle shoulder buffalo sirloin swine beef",
  ),
];
src/constants/product.dart

ProductService

We can use this inside of our ProductService:

class NetworkException {
  final String message;

  NetworkException(this.message);
}

class ProductService {
  Future<List<Product>> getProducts() async {
    try {
      await _shouldError("Couldn't fetch product list.");

      return kProductList;
    } on NetworkException catch (e) {
      return Future.error(e.message);
    }
  }

  Future<Product> getProductById(int id) async {
    try {
      await _shouldError("Couldn't fetch product by ID.");

      return kProductList.firstWhere(
        (element) => element.id == id,
        orElse: () => throw NetworkException("Couldn't fetch product by ID."),
      );
    } on NetworkException catch (e) {
      return Future.error(e.message);
    }
  }

  Future<void> _shouldError(String errorMessage) async {
    final _random = Random();
    final error = _random.nextBool();

    return error
        ? Future.delayed(Duration(seconds: 1),
            () => Future.error(NetworkException(errorMessage)))
        : Future.delayed(Duration(seconds: 1), null);
  }
}
src/services/product_service.dart

We used a similar _shouldError function inside of our article on the freezed library:

How to use Freezed with Flutter
If you’re new to Flutter or haven’t used immutable classes before, you may notsee an immediate value in using freezed. This article is here to show you whyyou should consider thinking about this topic and then how to implement thiswith freezed. If you haven’t read Part 1 of this series where we …

Essentially, this just mocks a real-world API call as it has a 50% chance to error. You can set this to false when you're happy that you UI is handling errors appropriately.

Master: Product List Page

Let's start with a basic Product List Page which gets the list of Products from the ProductService by using a FutureBuilder:

class ProductListPage extends StatelessWidget {
  static const routeName = "/products";
  final ProductService _productService = ProductService();

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text("Master Page"),
      ),
      floatingActionButton: FloatingActionButton.extended(
        label: Text("Direct Navigation"),
        onPressed: () => {},
      ),
      body: FutureBuilder(
        future: _productService.getProducts(),
        builder: (BuildContext context, AsyncSnapshot<List<Product>> snapshot) {
          if (snapshot.hasError) {
            return ErrorMessage(snapshot.error);
          }

          if (snapshot.hasData) {
            return ProductList(
              productList: snapshot.data,
              onTap: (Product product) => {},
            );
          }

          return Loading();
        },
      ),
    );
  }
}
src/pages/product_list_page.dart

Before we investigate this further, we'll need to add in a few other Widgets that have been imported/used inside of the ProductListPage.

ErrorMessage

The ErrorMessage Widget shows some text in the Center of the screen:

class ErrorMessage extends StatelessWidget {
  final String message;

  const ErrorMessage(
    this.message, {
    Key key,
  }) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return Center(child: Text(message));
  }
}
src/widgets/error_message.dart

ProductList

The ProductList Widget will use ListView.builder to display ListTiles based on the passed in productList:

class ProductList extends StatelessWidget {
  const ProductList({
    Key key,
    @required this.productList,
    @required this.onTap,
  }) : super(key: key);

  final List<Product> productList;
  final Function(Product) onTap;

  @override
  Widget build(BuildContext context) {
    return ListView.builder(
      itemCount: productList.length,
      itemBuilder: (BuildContext context, int index) {
        final product = productList[index];
        return Padding(
          padding: const EdgeInsets.symmetric(vertical: 6.0),
          child: ListTile(
            title: Text(product.title),
            subtitle: Text(product.description(shortened: true),
            onTap: () => onTap(product),
          ),
        );
      },
    );
  }
}
src/widgets/product_list.dart

Loading

The Loading Widget is a simple centered spinner:

import 'package:flutter/material.dart';

class Loading extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Center(
      child: CircularProgressIndicator(),
    );
  }
}
src/widgets/loading.dart

Updating main.dart

Finally, we can update main.dart to load the ProductListPage as the initialRoute for our application:

import 'package:ds_master_detail/src/pages/product_list_page.dart';
import 'package:flutter/material.dart';

void main() {
  runApp(MyApp());
}

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Master > Detail',
      theme: ThemeData(
        primarySwatch: Colors.deepPurple,
      ),
      debugShowCheckedModeBanner: false,
      initialRoute: ProductListPage.routeName,
      routes: {
        ProductListPage.routeName: (context) => ProductListPage(),
      },
    );
  }
}
main.dart

If we've done everything correctly, we should be able to see a list of Products inside of a ListView:

Detail View

By selecting an item from the ProductList, we should be navigated to the ProductDetailPage. We could just straight-up send the Product to the next page, but we may be in a position where we don't have this information, and only have the id.

Can you think of a time which that might happen?

If a user has navigated using the Web browser URL such as the way we looked at inside of the Fluro article, they should be able to select a /products/:id using the address bar.

For more information on that, check it out here:

How to use Fluro with Flutter (Custom Routing/Transitions)
Routing is one of the most important parts of an application. It’s easy tooverlook, but especially as the stable release of Flutter Web gets ever closer;the URL bar should represent the current application state. Have you ever built or used a SPA where, after refreshing the page, you lose thecur…

ProductDetailArgs

I've defined ProductDetailArgs which contains a selectedProductOrId which (as it sounds) can either be a Product or int that represents a Product id:

import 'package:dartz/dartz.dart';
import 'package:ds_master_detail/src/models/product_model.dart';
import 'package:flutter/foundation.dart';

@immutable
class ProductDetailArgs {
  final Either<Product, int> selectedProductOrId;

  const ProductDetailArgs(this.selectedProductOrId);
}
src/models/product_detail_args.dart

With that created, go ahead and update the onTap callback from ProductList by passing a Left(product) into the ProductDetailArgs on navigation:

// Left(x) and Right(x) comes from here:
import 'package:dartz/dartz.dart';

return ProductList(
  productList: snapshot.data,
  onTap: (Product product) => Navigator.of(context).pushNamed(
    ProductDetailPage.routeName,
    arguments: ProductDetailArgs(
      Left(product),
    ),
  ),
);

The simplistic way to think about this is:

a. If you want to send a Product to ProductDetailArgs send: Left(product)
b. If you want to send an int over to ProductDetailArgs send: Right(productId)

The Left and Right both come from dartz, and represent the Left and Right types of our Either<Product, int>.

We can also update our FloatingActionButton to send the user directly to a specific id. For now we're using a hardcoded id of 2, but this will emulate the aspect of navigating by a Web URL:

floatingActionButton: FloatingActionButton.extended(
  label: Text("Direct Navigation"),
  onPressed: () => Navigator.of(context).pushNamed(
    ProductDetailPage.routeName,
    arguments: ProductDetailArgs(
      Right(2),
    ),
  ),
),

ProductDetailPage

Let's bring this all together with the ProductDetailPage. This starts by creating a ProductDetail widget which can be used to display full information about a selected Product:

import 'package:ds_master_detail/src/models/product_model.dart';
import 'package:flutter/material.dart';

class ProductDetail extends StatelessWidget {
  final Product product;

  const ProductDetail({Key key, this.product}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return Padding(
      padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 6),
      child: ListView(
        children: [
          ListTile(
            contentPadding: EdgeInsets.zero,
            title: Text(
              product.title,
              style: Theme.of(context).textTheme.headline6,
            ),
            subtitle: Text(
              product.dateCreated,
            ),
          ),
          Text(
            product.description(),
          )
        ],
      ),
    );
  }
}
src/widgets/product_detail.dart

Finally, we can finish this off with the ProductDetailPage widget itself:

extension ToProductService on int {
  Future<Product> toProduct() async {
    final ProductService _productService = ProductService();
    final productId = this;

    return _productService.getProductById(productId);
  }
}

class ProductDetailPage extends StatelessWidget {
  static const routeName = "/products/detail";

  const ProductDetailPage();

  @override
  Widget build(BuildContext context) {
    final ProductDetailArgs args = ModalRoute.of(context).settings.arguments;

    return Scaffold(
      appBar: AppBar(
        title: const Text("Detail Page"),
      ),
      body: args.selectedProductOrId.fold(
        _buildFromProduct,
        _buildFromInt,
      ),
    );
  }

  Widget _buildFromProduct(Product product) {
    return ProductDetail(product: product);
  }

  Widget _buildFromInt(int productId) {
    return FutureBuilder(
      future: productId.toProduct(),
      builder: (BuildContext context, AsyncSnapshot<Product> snapshot) {
        if (snapshot.hasError) {
          return ErrorMessage(snapshot.error);
        }

        if (snapshot.hasData) {
          return ProductDetail(product: snapshot.data);
        }

        return Loading();
      },
    );
  }
}

This widget is relatively similar to the ProductListPage, however:

  1. We've continued to have a little fun with extensions as a way to further understand their potential (if you haven't used them much).

    This means that we can call toProduct() on any int and it'll automatically return us the Future<Product> for that id.
  2. We're using fold on the Either<Product, int> that's part of the ProductDetailArgs, this essentially splits our Either down the middle and gives us access to the Left and Right callbacks:
return Scaffold(
  appBar: AppBar(
    title: const Text("Detail Page"),
  ),
  body: args.selectedProductOrId.fold(
    _buildFromProduct,
    _buildFromInt,
  ),
);

Finally, update your main.dart with the new route:

return MaterialApp(
  title: 'Master > Detail',
  theme: ThemeData(
    primarySwatch: Colors.deepPurple,
  ),
  debugShowCheckedModeBanner: false,
  initialRoute: ProductListPage.routeName,
  routes: {
    ProductListPage.routeName: (context) => ProductListPage(),
    
    // NEW:
    ProductDetailPage.routeName: (context) => ProductDetailPage(),
  },
);

This then gives us a Detail view which looks like:

I'd advise going through and checking that the application appropriately handles errors inside of the UI for both the ProductListPage and ProductDetailPage:

When you're done, you can turn these off like so:

Future<void> _shouldError(String errorMessage) async {
  final error = false;

  return error
      ? Future.delayed(Duration(seconds: 1),
          () => Future.error(NetworkException(errorMessage)))
      : Future.delayed(Duration(seconds: 1), null);
}

Wrapping Up

We've now build the foundations of a fairly solid Master > Detail view within Flutter. Not only can we pass a Product between our two routes, but if we pass an id across, it'll get that from our mock database.

The premise of a Master Detail view is to give the user enough information to find the specific item they're looking for without overcrowding the view. This will be different for each application, so keep this in mind as you design your applications from a UX point of view.

You can find the code for this article here: https://github.com/PaulHalliday/ds_master_detail

Flutter

Paul Halliday

👋 Want to see more content? Head over to the YouTube channel: https://youtube.com/c/paulhalliday!