admin管理员组

文章数量:1394593

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:

  • The environment we're using has the Provider library availabe. We also use flutter_hooks but that's not required for the answer.
  • We have a top-level widget class that contains a PageView and a Continue button.
  • The PageView is not "swippable", the idea is that you navigate through some sort of onboarding using the "Continue" button. The PageView is coupled to a PageController.
  • Some of the individual pages have interactions. For example, each individual page could either / or:
    • Disable the "Continue" button until some action is performed.
    • Change the title of the "Continue" button, for example to "Skip".
    • Run a task when the continue button is clicked, which must be successful for the next page to be moved to.

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:

  • Each page to define the button's title and disabled state.
  • Each page to "tell" the top-level widget that a particular piece of logic should run when "Continue" is pressed - but that logic would be contained somewhere else.

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!

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:

  • The environment we're using has the Provider library availabe. We also use flutter_hooks but that's not required for the answer.
  • We have a top-level widget class that contains a PageView and a Continue button.
  • The PageView is not "swippable", the idea is that you navigate through some sort of onboarding using the "Continue" button. The PageView is coupled to a PageController.
  • Some of the individual pages have interactions. For example, each individual page could either / or:
    • Disable the "Continue" button until some action is performed.
    • Change the title of the "Continue" button, for example to "Skip".
    • Run a task when the continue button is clicked, which must be successful for the next page to be moved to.

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:

  • Each page to define the button's title and disabled state.
  • Each page to "tell" the top-level widget that a particular piece of logic should run when "Continue" is pressed - but that logic would be contained somewhere else.

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!

Share Improve this question edited Mar 27 at 10:56 Doodloo asked Mar 27 at 10:50 DoodlooDoodloo 9195 silver badges19 bronze badges 2
  • Wouldn't a callback for updating the state of the button which is supplied to the steps of the wizard be enough? Something like void Function(String buttonName, bool enabled, ...) – Jannik Commented Mar 27 at 14:52
  • @Jannik spot on - in the end this is how I solved the problem. – Doodloo Commented 2 days ago
Add a comment  | 

2 Answers 2

Reset to default 0

What you're suggesting is to send data upward in the widget tree. Flutter isn't designed to do that, so you're trying to work against the framework. Considering that the Continue/Skip-button is supposed to be so different for each individual PageView, I would move it as a child of the PageView instead of the top-level widget. You could do something like this.

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

  @override
  State<TopLevelWidget> createState() => _TopLevelWidgetState();
}

class _TopLevelWidgetState extends State<TopLevelWidget> {
  int currentPageIndex = 0;

  @override
  Widget build(BuildContext context) {
    var pageViewList = [
      PageView(goNextPage: goNextPage),
      PageView(goNextPage: goNextPage),
      PageView(goNextPage: goNextPage),
    ];

    return Scaffold(
      body: pageViewList[currentPageIndex],
    );
  }
  
  goNextPage() {
    setState(() {
      currentPageIndex = currentPageIndex + 1;
    });
  }
}

class PageView extends StatelessWidget {
  const PageView({super.key, required this.goNextPage});

  final Function() goNextPage;

  @override
  Widget build(BuildContext context) {
    return Column(
      mainAxisAlignment: MainAxisAlignment.spaceBetween
      children: [
        //put whatever here
        ElevatedButton(
          onPressed: isDisabled ? null : () {
            // do whatever operations you would like
            goNextPage();
          },
          child: const Text("Continue")),
      ]
    );
  }
}

Not sure if this code compiles, it is just a quick example and should just be used as inspiration. Personally I wouldn't implement the top-level widget entirely like this, but rather use a stream and stream controller to change the currentPageIndex. But this example at least gives you an idea of what it could look like.

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();
  }
}

本文标签: flutterIdiomatic way of checking the widget tree downwardStack Overflow