Is there a neat way to pop a page and schedule a callback to be called after navigation is complete?

Viewed 36

I want to call Navigator.of(context).pop() one or several times and then run a callback after navigation has completed, but I have struggled to find a neat solution. I've put together an example app to illustrate the problem I'm having:

  • Screens A, B, and C all access a nullable value on the Model Provider
  • ScreenA can set value to a non-null value
  • ScreenB requires value to be non-null to build
  • ScreenC can set value to null and pop you back to ScreenA

When you press the button on ScreenC to go back to ScreenA, it navigates successfully (the app doesn't crash) but you throw an Error because it tries to build ScreenB after the first pop.

import 'dart:math';

import 'package:flutter/material.dart';
import 'package:provider/provider.dart';

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

class MyApp extends StatelessWidget {
  const MyApp({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return ChangeNotifierProvider(
      create: (_) => Model(),
      child: MaterialApp(
        theme: ThemeData(
          primarySwatch: Colors.blue,
        ),
        home: const ScreenA(),
      ),
    );
  }
}

class Model extends ChangeNotifier {
  int? value = 0;

  Future<void> updateValue(int? newValue) async {
    await Future.delayed(const Duration(milliseconds: 30));
    value = newValue;
    notifyListeners();
  }
}

class ScreenA extends StatelessWidget {
  const ScreenA({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: centredScreenContent(
        [
          Text('ScreenA - value: ${context.watch<Model>().value}'),
          ElevatedButton(
            child: const Text('Set value'),
            onPressed: () => context.read<Model>().updateValue(Random().nextInt(100)),
          ),
          ElevatedButton(
            child: const Text('Go to B'),
            onPressed: () async => await Navigator.of(context).push(
              MaterialPageRoute(
                builder: (BuildContext context) => ScreenB(
                  nonNullValue: context.watch<Model>().value ?? (throw Error()),
                ),
              ),
            ),
          ),
        ],
      ),
    );
  }
}

class ScreenB extends StatelessWidget {
  const ScreenB({Key? key, required this.nonNullValue}) : super(key: key);
  final int nonNullValue;

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: centredScreenContent(
        [
          Text('ScreenB - value: $nonNullValue'),
          ElevatedButton(
            child: const Text('Set value'),
            onPressed: () => context.read<Model>().updateValue(Random().nextInt(100)),
          ),
          ElevatedButton(
            child: const Text('Go to C'),
            onPressed: () async => await Navigator.of(context).push(
              MaterialPageRoute(
                builder: (BuildContext context) => const ScreenC(),
              ),
            ),
          ),
        ],
      ),
    );
  }
}

class ScreenC extends StatelessWidget {
  const ScreenC({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: centredScreenContent(
        [
          const Spacer(),
          const Text('ScreenC'),
          ElevatedButton(
              onPressed: () {
                Navigator.of(context).pop();
                Navigator.of(context).pop();
                context.read<Model>().updateValue(null);
              },
              child: const Text('Reset app')),
          const Spacer(),
        ],
      ),
    );
  }
}

Widget centredScreenContent(List<Widget> widgets) => Center(
      child: Column(
        mainAxisSize: MainAxisSize.min,
        children: widgets,
      ),
    );

I've found two solutions, but neither feels neat:

  1. Make ScreenB take a nullable value in its constructor, and have its build return something like value == null ? Container() : ActualContents(nonNullValue: value!). I don't like this though. If we know that in BAU use, ScreenB cannot be built while value == null, then we'd like to log an error if that happens in production so we can investigate the problem. We can't do this if our navigation back from ScreenC also hits this state though.
  2. Add a sufficiently long delay to the callback so that it runs after the navigation is completed, e.g. in the example app, if you change Model.updateValue to have a 300ms delay, then it doesn't error. This also feels like an unpleasant solution, if the delay is too long we risk the app behaving sluggishly, if it's too short then we don't solve the problem at all.
1 Answers

I would make ScreenB(int? nullableParam) and handle the widget builder with additional assert nullableParam == null just to log the error.

But what i think the real solution you are looking for is context.read<Model>().value instead of watch - i can't think of a scenario where you want to page parameter depend on any listenable state

solution

          ElevatedButton(
            child: const Text('Go to B'),
            onPressed: () async => await Navigator.of(context).push(
              MaterialPageRoute(
                builder: (BuildContext context) => ScreenB(
                  nonNullValue: context.read<Model>().value ?? (throw Error()),

This way the page after first build will not be rebuild with null when popped.

@Edit

I see 2 problems:

  • passing Listenable value to Page parameter
  • purposely setting value to null where other part of application purposely is not handling it

The first one can be solved with the solution above

The second you have to either assure the passed value will not be null on Navigator.pop() - the solution above does that. Or handle the null value in the ScreenB widget (as you suggested with conditional build)

Related