13

I currently have an interesting issue related to displaying of a snackbar based on a user action.

Above sounds very trivial, but lets elaborate:

I have 2 screens:

  1. list of employees
  2. Add employee

The application uses the bloc pattern (with streams/rxdart).

Here is what I want:

  • User clicks on add employee FAB button in list of employees screen and is navigated to the Add Employee screen (works perfectly fine)
  • User fills in employee details and clicks save
  • Upon save, employee is added to the stream, and updated the list of employees screen (works fine)
  • User is navigated BACK to list of employees (works fine)
  • Snackbar is displayed stating that the employee has successfully been added (This is the problem)

I tried several ways of implementing this:

Add a new stream (employeeAdded), and when adding an employee to the employees stream, additionally push a boolean to employee added.

In the list of employees, add a new stream builder and in the builder logic add the snackbar.

This gives all sorts of problems, for example trying to display the snackbar before the page has been (re)build, and so on.

The question is twofold: What is good UX practice for this scenario, and what would be a good solution for this problem?

(will post code on request)

Thanks for the help!

Joey Roosing
  • 2,145
  • 5
  • 25
  • 42

6 Answers6

14

There is ready recipe for showing Snackbar in Bloc. Basically BlocListener has been created for that purpose. Please look on https://felangel.github.io/bloc/#/recipesfluttershowsnackbar

class Home extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    final dataBloc = BlocProvider.of<DataBloc>(context);
    return Scaffold(
      appBar: AppBar(title: Text('Home')),
      body: BlocListener(
        bloc: dataBloc,
        listener: (BuildContext context, DataState state) {
          if (state is Success) {
            Scaffold.of(context).showSnackBar(
              SnackBar(
                backgroundColor: Colors.green,
                content: Text('Success'),
              ),
            );
          }
        },
        child: YourChild()
      ),
   );
  }
}
konstantin_doncov
  • 2,725
  • 4
  • 40
  • 100
Tomasz Białecki
  • 1,041
  • 6
  • 10
2

@joey I have found a solution but I'm not sure that it's the best. I'm changing my app to bloc pattern now, the same problem as yours occurred so I did this:

In my bloc file I put a controller for the scaffoldState:

ScaffoldState scaffold;

StreamController<ScaffoldState> _scaffoldController = StreamController<ScaffoldState>();
StreamSink<ScaffoldState> get setScaffold => _scaffoldController.sink;

In the constructor of the bloc file I listen to that stream:

LoginBloc(){
    _scaffoldController.stream.listen((sc){ scaffold = sc;});
}

In the widget I sink the scaffoldState whenever I want to use it:

onPressed:() {
   bloc.setScaffold.add(Scaffold.of(context));
}

so then, I can show snackbars as usual from the bloc:

scaffold.showSnackBar(SnackBar(content: Text(hi world'),),);

UPDATE

I think the best way would for separating the ui and the bloc would be to listen for the stream on the ui (either on initState or didChangeDependencies if you are using a blocprovider).

Jose Jet
  • 1,460
  • 1
  • 12
  • 13
  • 4
    Oh, an interesting approach. The only problem I have with this is you are kind of mixing UI logic with your bloc. If you want to reuse your business logic in for example an angular app, this bloc will give errors because ScaffoldState doesn't exist. I do like the idea though, thanks for the insight! – Joey Roosing Jan 09 '19 at 11:06
2

Wrap your body (the child of your Scaffold, in your case returned by _buildBody) with a Builder so you get to write some "free" code with the context.

Now you can listen to the stream with a handler you provide (like you already did), but this time you register the listener only once per Scaffold build (and not registering a listener at each event occurred in employeesAdded stream).

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: Builder(
        builder: (BuildContext context) {
          bloc.employeesAdded.listen(
            (_) {
              Scaffold.of(context).showSnackBar(
                SnackBar(
                  content: Text('Employee added'),
                ), // SnackBar
              );
            },
          );

          return _buildBody(bloc);
        },
      ), // Builder
    ); // Scaffold
  }
idow09
  • 504
  • 1
  • 5
  • 15
  • The problem is that there is multiple routes that are listening to the same event and that the Bloc pattern does not solve this problem See my reply – Hlulani Aug 16 '20 at 13:19
1

Although its not my favourite answer, I am also not sure if there is a better alternative; this is what I did as a temporary fix for future watchers:

Create a new stream (in my case employeeAdded), upon adding employees, also create an entry into the stream:

  final _employees = BehaviorSubject<List<Employee>>(seedValue: List<Employee>());
  final _employeeAdded = BehaviorSubject();

  // streams (out)
  Observable<List<Employee>> get employees => _employees.stream;
  Observable<dynamic> get employeesAdded => _employeeAdded.stream;

  addEmployee(Employee employee) async {
    final newList = <Employee>[]..addAll(_employees.value)..add(employee);
    await _employeeRepo.upsertEmployee(employee);
    _employees.add(newList);

    _employeeAdded.add(true);
    //FIXME: Save to db
  }

Note there is no seedValue in employeeAdded, this is to prevent the snackbar from showing on initial load.

In my screen / page I have a scaffold; it's body calls another method which should explain the rest of the code:

 Widget _buildBody(EmployeeBloc bloc) {
    return StreamBuilder(
      stream: bloc.employees,
      builder: (context, snapshot) {
        if (!snapshot.hasData) {
          bloc.employeesAdded.listen(
            (_) => Scaffold.of(context).showSnackBar(
                  SnackBar(
                    content: Text('Employee added'),
                  ),
                ),
          );
          bloc.seedEmployees();
          return Center(
            child: Text("No employees"),
          );
        }
        return _buildList(bloc, snapshot.data);
      },
    );
  }

Note the listen on the bloc with the hasData if.

This works for now, but would like to know if there is a more neat example.

Joey Roosing
  • 2,145
  • 5
  • 25
  • 42
0

I've struggle with this for 2 weeks and I suspect that there is a misunderstanding of what the actual problem is.

Problem

There are multiple routes (pages) that are listening to the same broadcast event and we cannot pause and resume those events as the user navigates forward and backwards in a clean way. The multiple routes in this scenario is caused by user when he/she navigates to a new screen, the old screen is still available in the background and receives the event but CANNOT animate to show that SnackBar until the user Taps the Back button.

Solution

Show the SnackBar only on the top-most route (this would be the route that the user is looking at) using the following in a StatefulWidget:

...
  @override
  void initState() {
    _subscription = employeeStream.listen((event) {
      if (ModalRoute.of(context).isCurrent) { // Check the top most route
        _l.fine('Only showing message on top');
        Scaffold.of(context).showSnackBar(...);
      } else { // Ignore all other routes
        _l.fine('Ignoring event');
      }
    });
    super.initState();
  }
...
Hlulani
  • 419
  • 4
  • 12
-2

You can try this inside the builder:

WidgetsBinding.instance.addPostFrameCallback((_) => showSnackBar(message));