Persistent widget at the bottom of every page

Issue

I’m making a music app and want the now playing status to show at the bottom of most pages. I’ve kind of done this by using bottomNavigationBar: const NowPlayingBar() on every scaffold that needed the bar. This has 2 issues:

  1. This technically makes multiple copies of the nav bar for every route that has it
  2. The nav bar doesn’t "stay above" page transitions (video below)

The only real way that I’ve found of doing this is with the persistent_bottom_nav_bar package, but that doesn’t seem to allow custom widgets (there’s this but NavBarStyle.custom doesn’t seem to exist). Is there a way of constantly showing the bar on all pages?

Here’s the video showing issue 2: https://streamable.com/gxcswk

And here’s my now playing bar widget (it’s basically just a list tile that listens to changes):

import 'package:audio_service/audio_service.dart';
import 'package:flutter/material.dart';
import 'package:get_it/get_it.dart';

import '../components/AlbumImage.dart';
import '../services/mediaStateStream.dart';
import '../services/FinampSettingsHelper.dart';
import '../services/processArtist.dart';
import '../services/MusicPlayerBackgroundTask.dart';

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

  @override
  Widget build(BuildContext context) {
    // BottomNavBar's default elevation is 8 (https://api.flutter.dev/flutter/material/BottomNavigationBar/elevation.html)
    const elevation = 8.0;
    final color = Theme.of(context).bottomNavigationBarTheme.backgroundColor;

    final audioHandler = GetIt.instance<MusicPlayerBackgroundTask>();

    return Material(
      color: color,
      elevation: elevation,
      child: SafeArea(
        child: StreamBuilder<MediaState>(
          stream: mediaStateStream,
          builder: (context, snapshot) {
            if (snapshot.hasData) {
              final playing = snapshot.data!.playbackState.playing;

              // If we have a media item and the player hasn't finished, show
              // the now playing bar.
              if (snapshot.data!.mediaItem != null) {
                return SizedBox(
                  width: MediaQuery.of(context).size.width,
                  child: Dismissible(
                    key: const Key("NowPlayingBar"),
                    confirmDismiss: (direction) async {
                      if (direction == DismissDirection.endToStart) {
                        audioHandler.skipToNext();
                      } else {
                        audioHandler.skipToPrevious();
                      }
                      return false;
                    },
                    background: Padding(
                      padding: const EdgeInsets.symmetric(horizontal: 16.0),
                      child: Row(
                        mainAxisAlignment: MainAxisAlignment.spaceBetween,
                        children: const [
                          AspectRatio(
                            aspectRatio: 1,
                            child: FittedBox(
                              fit: BoxFit.fitHeight,
                              child: Padding(
                                padding: EdgeInsets.symmetric(vertical: 8.0),
                                child: Icon(Icons.skip_previous),
                              ),
                            ),
                          ),
                          AspectRatio(
                            aspectRatio: 1,
                            child: FittedBox(
                              fit: BoxFit.fitHeight,
                              child: Padding(
                                padding: EdgeInsets.symmetric(vertical: 8.0),
                                child: Icon(Icons.skip_next),
                              ),
                            ),
                          ),
                        ],
                      ),
                    ),
                    child: ListTile(
                      onTap: () =>
                          Navigator.of(context).pushNamed("/nowplaying"),
                      // We put the album image in a ValueListenableBuilder so that it reacts to offline changes
                      leading: ValueListenableBuilder(
                        valueListenable:
                            FinampSettingsHelper.finampSettingsListener,
                        builder: (context, _, widget) => AlbumImage(
                          itemId: snapshot.data!.mediaItem!.extras!["parentId"],
                        ),
                      ),
                      title: Text(
                        snapshot.data!.mediaItem!.title,
                        softWrap: false,
                        maxLines: 1,
                        overflow: TextOverflow.fade,
                      ),
                      subtitle: Text(
                        processArtist(snapshot.data!.mediaItem!.artist),
                        maxLines: 1,
                        overflow: TextOverflow.ellipsis,
                      ),
                      trailing: Row(
                        mainAxisSize: MainAxisSize.min,
                        children: [
                          if (snapshot.data!.playbackState.processingState !=
                              AudioProcessingState.idle)
                            IconButton(
                              // We have a key here because otherwise the
                              // InkWell moves over to the play/pause button
                              key: const ValueKey("StopButton"),
                              icon: const Icon(Icons.stop),
                              onPressed: () => audioHandler.stop(),
                            ),
                          playing
                              ? IconButton(
                                  icon: const Icon(Icons.pause),
                                  onPressed: () => audioHandler.pause(),
                                )
                              : IconButton(
                                  icon: const Icon(Icons.play_arrow),
                                  onPressed: () => audioHandler.play(),
                                ),
                        ],
                      ),
                    ),
                  ),
                );
              } else {
                return const SizedBox(
                  width: 0,
                  height: 0,
                );
              }
            } else {
              return const SizedBox(
                width: 0,
                height: 0,
              );
            }
          },
        ),
      ),
    );
  }
}

Solution

What you could do is build another navigator which contains the now playing bar as such:

(This is a simplified version of this solution for your use case)

Here is a video of how it would look

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

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter Demo',
      theme: ThemeData(
        primarySwatch: Colors.blue,
      ),
      home: VisiblePage(),
    );
  }
}

class VisiblePage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {

    /* You could also make the body of the scaffold a stack, column, 
    etc. that has the the buildNavigator and the now playing bar as 
    its children instead of setting the NowPlayingBar as the bottom 
    nav bar and the buildNavigator as the body. */

    return Scaffold(
      backgroundColor: Colors.white,
      body: _buildNavigator(context),
      bottomNavigationBar: NowPlayingBar(),
    );
  }

  Map<String, WidgetBuilder> _routeBuilders(BuildContext context, Map args) {
    return {
      "/": (context) {
        return MainPage();
      },
      '/page1': (context) {
        return Page1();
      },
      '/page2': (context) {
        return Page2();
      }
    };
  }

  Widget _buildNavigator(BuildContext context) {
    return Navigator(
      onGenerateRoute: (settings) {
        final args = settings.arguments ?? {};
        var routeBuilders = _routeBuilders(context, args as Map);
        return MaterialPageRoute(
          fullscreenDialog: true,
          settings: settings,
          builder: (context) {
            return routeBuilders[settings.name]!(context);
          },
        );
      },
    );
  }
}

To navigate to page 1 simply use:

Navigator.of(context).pushNamed('/page1');

For page 2 (which doesn’t contain the bar) set rootNavigator to true:

Navigator.of(context, rootNavigator: true)
                      .push(MaterialPageRoute(builder: (context) => Page2())); 

This uses the initial navigator in the "MyApp" page instead. You could also make this a named route by adding named routes to the ‘MyApp’ page.

Answered By – Shaveen

Answer Checked By – Clifford M. (FlutterFixes Volunteer)

Leave a Reply

Your email address will not be published. Required fields are marked *