Dart Tutorial

Flutter Lesson 34 of 83 3 min read

Passing Data Between Screens in Flutter

Send data to a new screen through its constructor and return a result with Navigator.pop in Flutter, with typed results and examples.

On this page

Data flows in two directions between screens: forward when you open one, and back when it closes.

Forward: through the constructor #

A page is a widget, and widgets take constructor arguments.

import 'package:flutter/material.dart';

class Product {
  const Product(this.name, this.price);
  final String name;
  final int price;
}

const products = [
  Product('Notebook', 120),
  Product('Backpack', 1800),
  Product('Water bottle', 450),
];

void main() => runApp(const MaterialApp(home: ProductListPage()));

class ProductListPage extends StatelessWidget {
  const ProductListPage({super.key});

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Products')),
      body: ListView(
        children: [
          for (final product in products)
            ListTile(
              title: Text(product.name),
              trailing: const Icon(Icons.chevron_right),
              onTap: () => Navigator.push(
                context,
                MaterialPageRoute<void>(
                  builder: (context) => ProductPage(product: product),
                ),
              ),
            ),
        ],
      ),
    );
  }
}

class ProductPage extends StatelessWidget {
  const ProductPage({super.key, required this.product});

  final Product product;

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text(product.name)),
      body: Center(child: Text('Rs. ${product.price}', style: const TextStyle(fontSize: 32))),
    );
  }
}

This is type safe. Forget the argument, or pass the wrong type, and the code does not compile.

Back: a result from pop #

Navigator.push returns a Future that completes when the pushed screen closes. The value you give pop becomes the result.

import 'package:flutter/material.dart';

void main() => runApp(const MaterialApp(home: ProfilePage()));

class ProfilePage extends StatefulWidget {
  const ProfilePage({super.key});

  @override
  State<ProfilePage> createState() => _ProfilePageState();
}

class _ProfilePageState extends State<ProfilePage> {
  String _city = 'Not set';

  Future<void> _chooseCity() async {
    final result = await Navigator.push<String>(
      context,
      MaterialPageRoute(builder: (context) => const CityPickerPage()),
    );

    if (result == null) return; // the user went back without choosing
    setState(() => _city = result);
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Profile')),
      body: ListTile(
        title: const Text('City'),
        subtitle: Text(_city),
        trailing: const Icon(Icons.edit),
        onTap: _chooseCity,
      ),
    );
  }
}

class CityPickerPage extends StatelessWidget {
  const CityPickerPage({super.key});

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Choose a city')),
      body: ListView(
        children: [
          for (final city in ['Kathmandu', 'Pokhara', 'Dharan'])
            ListTile(
              title: Text(city),
              onTap: () => Navigator.pop(context, city), // send it back
            ),
        ],
      ),
    );
  }
}

Two things to remember:

  • State the result type: Navigator.push<String>(...).
  • The result is nullable. The user can always leave with the back button, which gives null.

Returning more than one value #

Return a record or an object.

// In the editing screen
Navigator.pop(context, (name: _name.text, age: int.parse(_age.text)));

// In the caller
final result = await Navigator.push<({String name, int age})>(
  context,
  MaterialPageRoute(builder: (_) => const EditPersonPage()),
);
if (result != null) {
  debugPrint('${result.name}, ${result.age}');
}

Refreshing a list after an edit #

A common pattern: open an edit screen, then reload when it reports that something changed.

Future<void> _openEditor(Note note) async {
  final changed = await Navigator.push<bool>(
    context,
    MaterialPageRoute(builder: (_) => EditNotePage(note: note)),
  );
  if (changed == true) {
    await _reloadNotes();
  }
}

// In EditNotePage, after saving:
Navigator.pop(context, true);

Pass an id or the whole object? #

PassWhen
The whole objectYou already have it, and the details screen shows the same data
Only an idThe details screen loads fresh data, or the screen can be opened from a link

With URL-based routing you can only put simple values in a URL, so screens usually receive an id and load the rest themselves.

When constructor passing gets painful #

If you find yourself passing the same object through three or four screens that do not use it, only so that a distant screen can, that data should live in shared state above the navigator. See state management.

Try it yourself #

Build a contacts app with two screens. The list screen has an “Add” button that opens a form. The form returns a record with a name and phone number, and the list screen adds it and shows a SnackBar.

Practise in the playground Updated by Santosh Adhikari