flutteridioms

Idiomatic way of checking the widget tree downward


Please note that the example we'll be looking at is purelly for illustration purposes. If you would like to answer using a different example that's ok, as we're trying to get to an idiomatic way of solving this type of problem rather that this problem.

Setup:

Currently, it's the top-level widget that controls this continue button... And it's very cumbersome, as every time the page index changes, the conditions are in the top-level widget to change the title of the button, to disable it, run the async actions between pages, etc.

We are looking for a cleaner way of doing this - for example, what would be very nice would be to design an architecture that would instead allow:

Something like this:

  1. When the current page changes, the top-level widget would query the page and ask "What is the title for the button? What is the disabled state? Are there actions you need to run when the button is pressed?" and the page itself would be able to tell.
  2. Upon pressing the button, it would tell the page "I've been clicked" upon which the page would decide to run async actions or not, disable the button, set it to a loading state, etc.

The communication from the pages to the top-level widget is easy - a context.read<OnboardingProvider>().doSomething() does the trick. It's the other way around which seems difficult.

The idea would be to have each page have some degree of control (both visual and business logic) over the top-level widget button.

Any ideas? Thanks!


Solution

  • In the end this is what we came up with:

    We created an interface for all pages to comply with. Something like:

    typedef ContinueCallback = Future<void> Function(BuildContext);
    
    @immutable
    abstract class IOnboardingView extends StatelessWidget {
      const IOnboardingView({super.key});
    
      ContinueCallback get onContinue;
      String buttonLabel(BuildContext context);
    }
    

    The idea is to make sure that all children pages implement it, and can provide both a callback to provide validation rules when the button is pushed (Or null to disable the button), and the title of the button itself.

    On the top-level page, the build function can now look like this:

      @override
      Widget build(BuildContext context) {
        final state = context.watch<ob_state.OnboardingState>();
        final pageIndex = state.index;
        final pagerMessage = state.message;
        final page = state.pages[state.index];
        void nextPage() => state.nextPage(context);
    
        return Column(
          mainAxisSize: MainAxisSize.min,
          children: [
            Pager(total: _OnboardingPage.values.length, index: pageIndex),
            if (pagerMessage != null)
              Padding(
                padding: const EdgeInsets.symmetric(vertical: AppTheme.spaceS),
                child: Text(pagerMessage),
              ),
            Container(
              width: double.infinity,
              height: 50,
              margin: const EdgeInsets.all(20),
              child: FilledButton(
                onPressed: state.enabled ? nextPage : null,
                child: Text(page.buttonLabel(context)),
              ),
            ),
          ],
        );
      }
    

    Note that the state is provided via a context provider (See context.watch<ob_state.OnboardingState>()).

    The provider looks like something like this (Note how all children pages are List<IOnboardingView> pages and therefore "provide" what the top-level page needs to know):

    ChangeNotifierProvider<OnboardingState> provider() => ChangeNotifierProvider(create: (context) => OnboardingState());
    
    class OnboardingState with ChangeNotifier {
      bool _disposed = false;
      bool _enabled = true;
      String? _message;
      final List<IOnboardingView> pages = [
        const SafetyView(),
        const PreferencesView(),
        const PermissionsPage(),
        const VerifyAccountView()
      ];
      final PageController pageCtrl = PageController();
      int _index = 0;
    
      OnboardingState();
    
      @override
      void dispose() {
        _disposed = true;
        pageCtrl.dispose();
        super.dispose();
      }
    
      bool get enabled => _enabled;
      set enabled(bool value) => value ? enable() : disable();
    
      void enable() {
        if (_enabled && _message == null) return;
        _enabled = true;
        _message = null;
        _maybeNotifyListeners();
      }
    
      void disable([String? message]) {
        if (!_enabled && _message == message) return;
        _enabled = false;
        _message = message;
        _maybeNotifyListeners();
      }
    
      void nextPage(BuildContext context) {
        disable();
        page
            .onContinue(context)
            .then((value) => pageCtrl.nextPage(
                  duration: const Duration(milliseconds: 300),
                  curve: Curves.easeInOut,
                ))
            .whenComplete(enable);
      }
    
      String? get message => _message;
      IOnboardingView get page => pages[_index];
    
      int get index => _index;
      set index(int i) {
        if (_index == i) return;
        _index = i;
        _maybeNotifyListeners();
      }
    
      void _maybeNotifyListeners() {
        if (_disposed) return;
        notifyListeners();
      }
    }