1

What would be a best practice for (provider based) state management of modal widgets in flutter, where when user makes an edit changes do not propagate to parent page until user confirms/closes modal widget. Optionally, user has a choice to discard the changes.

In a nutshell:

  • modal widget with OK and cancel actions, or
  • modal widget where changes are applied when modal is closed

Currently, my solution looks like this

  1. Create a copy of the current state
  2. Call flutter's show___() function and wrap widgets with a provider (using .value constructor) to expose copy of the state
  3. If needed, update original state when modal widget is closed

Example of case #2:

Future<void> showEditDialog() async {
  // Create a copy of the current state
  final orgState = context.read<MeState>();
  final tmpState = MeState.from(orgState);

  // show modal widget with new provider
  await showDialog<void>(
    context: context,
    builder: (_) => ChangeNotifierProvider<MeState>.value(
              value: tmpState,
              builder: (context, _) => _buildEditDialogWidgets(context)),
  );

  // update original state (no discard option to keep it simple)
  orgState.update(tmpState);
}

But there are issues with this, like:

  • Where should I dispose tmpState?
  • ProxyProvider doesn't have .value constructor.
  • If temporary state is created in Provider's create: instead, how can I safely access that temporary state when modal is closed?

UPDATE: In my current app I have a MultiProvider widget at the top of widget tree, that creates and provides multiple filter state objects. Eg. FooFiltersState, BarFiltersState and BazFiltersState. They are separate classes because each these three extends either ToggleableCollection<T> extends ChangeNotifier or ToggleableCollectionPickerState<T> extends ToggleableCollection<T> class. An abstract base classes with common properties and functions (like bool areAllSelected(), toggleAllSelection() etc.).

There is also FiltersState extends ChangeNotifier class that contains among other things activeFiltersCount, a value depended on Foo, Bar and Baz filters state. That's why I use

ChangeNotifierProxyProvider3<
                FooFiltersState,
                BarFilterState,
                BazFilterState,
                FiltersState>

to provide FiltersState instance.

User can edit these filters by opening modal bottom sheet, but changes to filters must not be reflected in the app until bottom sheet is closed by taping on the scrim. Changes are visible on the bottom sheet while editing.

Foo filters are displayed as chips on the bottom sheet. Bar and baz filters are edited inside a nested dialog windows (opened from the bottom sheet). While Bar or Baz filter collection is edited, changes must be reflected only inside the nested dialog window. When nested dialog is confirmed changes are now reflected on bottom sheet. If nested dialog is canceled changes are not transferred to the bottom sheet. Same as before, these changes are not visible inside the app until the bottom sheet is closed.

To avoid unnecessary widget rebuilds, Selector widgets are used to display filter values.

From discussion with yellowgray, I think that I should move all non-dependent values out of proxy provider. So that, temp proxy provider can create new temp state object that is completely independent of original state object. While for other objects temp states are build from original states and passed to value constructors like in the above example.

zigzag
  • 579
  • 5
  • 17

2 Answers2

2

1. Where should I dispose tmpState?

I think for your case, you don't need to worry about it. tmpState is like a temporary variabl inside function showEditDialog()

2. ProxyProvider doesn't have .value constructor.

It doesn't need to because it already is. ProxyProvider<T, R>: T is a provider that need to listen to. In your case it is the orgState. But I think the orgState won't change the value outside of this function, so I don't know why you need it.

3. If temporary state is created in Provider's create: instead, how can I safely access that temporary state when modal is closed?

you can still access the orgState inside _buildEditDialogWidgets and update it by context.read(). But I think you shouldn't use same type twice in the same provider tree (MeState)


Actually when I first see your code, I will think why you need to wrap tmpState as another provider (your _buildEditDialogWidgets contains more complicated sub-tree or something else that need to use the value in many different widget?). Here is the simpler version I can think of.

Future<void> showEditDialog() async {
 // Create a copy of the current state
 final orgState = context.read<MeState>();

 // show modal widget with new provider
 await showDialog<void>(
   context: context,
   builder: (_) => _buildEditDialogWidgets(context,MeState.from(orgState)),
 );
}

...

Widget _buildEditDialogWidgets(context, model){

  ...
  onSubmit(){
    context.read<MeState>().update(updatedModel)
  }
  ...
}
yellowgray
  • 4,006
  • 6
  • 28
  • 2. In the real code, I'm not using just one provider with one state object (I simplified the example), but MultiProvider with multiple provides and multiple state classes. One of them is a ProxyProvider that includes activeFiltersCount property and depends on others providers to calculate it. So, in my case of ChangeNotifierProxyProviderValue3 (when building temp states for modal) T is not orgState1, but tmpState1... – zigzag Feb 03 '21 at 11:38
  • 2. continued: The issue is with temp R state as it should include other properties from original R state. You gave me an idea however, to split ProxyProvider's R state into two state classes. Where one of them contains only activeFiltersCount, so that temp ProxyProvider's value doesn't depend on original R. The other class is then provided via normal provider. – zigzag Feb 03 '21 at 11:41
  • 3. Inside _buildEditDialogWidgets I don't always know when the modal is closed. For example in case of showModalBottomSheet, if the bottom sheet is closed by user taping on the scrim. – zigzag Feb 03 '21 at 11:48
  • Simpler version won't work because `final orgState = context.read();` doesn't create a copy, but returns a reference to current state. Also, passing state via parameter into a complex widget tree isn't recommended as it creates maintenance issues. Advantage of creating a new providers from temp states is that inside _buildEditDialogWidgets nothing needs to change. Widgets inside have no idea they are reading/updating temporary copies of state classes. – zigzag Feb 03 '21 at 12:01
  • I updated the simpler version. I didn't notice it pass only reference and you are right. – yellowgray Feb 03 '21 at 13:43
  • I am kind of curios, If you use `showDialog` to push a new tree of Navigator avoiding using same class `MeState` in the same tree? – yellowgray Feb 03 '21 at 13:50
  • For 3. Why `showModalBottomSheet` & `showDialog` show at the same time? I think they are stacked in order. – yellowgray Feb 03 '21 at 13:53
  • I tried forming a general question about state management for modal widgets, when inside modal you need to access and update some state of the app, but those changes shouldn't be reflected until modal is closed. Something that works for any modal widget (bottom sheet, dialog or any other). I'll update my question with more details. – zigzag Feb 03 '21 at 14:07
  • I added more detailed description to my question. I think it should answer your questions about the why, but I'm happy to provide you with more details, if needed. – zigzag Feb 03 '21 at 15:14
1

The simplest way is you can just provide a result when you pop your dialog and use that result when updating your provider.

import 'dart:collection';
import 'dart:math';

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

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

class Item {
  Item(this.name);
  String name;

  Item clone() => Item(name);
}

class MyState extends ChangeNotifier {
  List<Item> _items = <Item>[];

  UnmodifiableListView<Item> get items => UnmodifiableListView<Item>(_items);

  void add(Item item) {
    if (item == null) {
      return;
    }
    _items.add(item);
    notifyListeners();
  }

  void update(Item oldItem, Item newItem) {
    final int indexOfItem = _items.indexOf(oldItem);
    if (newItem == null || indexOfItem < 0) {
      return;
    }
    _items[indexOfItem] = newItem;
    notifyListeners();
  }
}

class MyApp extends StatelessWidget {
  @override
  Widget build(_) {
    return ChangeNotifierProvider<MyState>(
      create: (_) => MyState(),
      builder: (_, __) => MaterialApp(
        theme: ThemeData(
          primarySwatch: Colors.blue,
        ),
        home: Builder(
          builder: (BuildContext context) => Scaffold(
            body: SafeArea(
              child: Column(
                children: <Widget>[
                  FlatButton(
                    onPressed: () => _addItem(context),
                    child: const Text('Add'),
                  ),
                  Expanded(
                    child: Consumer<MyState>(
                      builder: (_, MyState state, __) {
                        final List<Item> items = state.items;

                        return ListView.builder(
                          itemCount: items.length,
                          itemBuilder: (_, int index) => GestureDetector(
                            onTap: () => _updateItem(context, items[index]),
                            child: ListTile(
                              title: Text(items[index].name),
                            ),
                          ),
                        );
                      },
                    ),
                  ),
                ],
              ),
            ),
          ),
        ),
      ),
    );
  }

  Future<void> _addItem(BuildContext context) async {
    final Item item = await showDialog<Item>(
      context: context,
      builder: (BuildContext context2) => AlertDialog(
        actions: <Widget>[
          FlatButton(
            onPressed: () => Navigator.pop(context2),
            child: const Text('Cancel'),
          ),
          FlatButton(
            onPressed: () => Navigator.pop(
              context2,
              Item('New Item ${Random().nextInt(100)}'),
            ),
            child: const Text('ADD'),
          ),
        ],
      ),
    );

    Provider.of<MyState>(context, listen: false).add(item);
  }

  Future<void> _updateItem(BuildContext context, Item item) async {
    final Item updatedItem = item.clone();
    final Item tempItem = await showModalBottomSheet<Item>(
      context: context,
      builder: (_) {
        final TextEditingController controller = TextEditingController();
        controller.text = updatedItem.name;

        return Container(
          height: 300,
          child: Column(
            children: <Widget>[
              Text('Original: ${item.name}'),
              TextField(
                controller: controller,
                enabled: false,
              ),
              TextButton(
                onPressed: () {
                  updatedItem.name = 'New Item ${Random().nextInt(100)}';
                  controller.text = updatedItem.name;
                },
                child: const Text('Change name'),
              ),
              TextButton(
                onPressed: () => Navigator.pop(context, updatedItem),
                child: const Text('UPDATE'),
              ),
              TextButton(
                onPressed: () => Navigator.pop(context, Item(null)),
                child: const Text('Cancel'),
              ),
            ],
          ),
        );
      },
    );

    if (tempItem != null && tempItem != updatedItem) {
      // Do not update if "Cancel" is pressed.
      return;
    }

    // Update if "UPDATE" is pressed or dimissed.
    Provider.of<MyState>(context, listen: false).update(item, updatedItem);
  }
}
rickimaru
  • 2,275
  • 9
  • 13
  • But how can I provide a result, for example in case of showModalBottomSheet, if the bottom sheet is closed by user taping on the scrim? – zigzag Feb 03 '21 at 10:53
  • @zigzag You mean when the bottom sheet is dismissed right? If yes, you want a result that is not equal to NULL? – rickimaru Feb 04 '21 at 01:26
  • Yes to both questions. – zigzag Feb 04 '21 at 09:39
  • @zigzag I updated my sample code. Please refer to `_updateItem(...)`. Is it enough to answer your question? – rickimaru Feb 04 '21 at 11:51
  • Issue with that answer is that inside a showModalBottomSheet you are not using Provider to access state anymore, but are passing a reference to state directly. Passing state via parameter into a complex widget tree isn't recommended as it creates maintenance issues. – zigzag Feb 04 '21 at 13:36
  • @zigzag Because you want something to be used when the `showModalBottomSheet` is dismissed (I'm assuming something similar to my sample). Of course you can directly access your state inside the `showModalBottomSheet` and have a temporary state to modify and will be saved to the actual state if bottom sheet's "UPDATE" button is pressed or when it is dismissed, and discard temporary state if cancelled. That totally depends on your use case. – rickimaru Feb 05 '21 at 01:16
  • I am afraid I don't see any real benefits of your example over my original example. The main difference is that in your example widgets can't use Provider to access temp state, but need to pass it around as parameter. It is nice for simple cases, though, where there aren't complex widget trees inside a modal. – zigzag Feb 05 '21 at 08:19