jb3rndt / PersistentBottomNavBarV2

A highly customizable persistent bottom navigation bar for Flutter
https://pub.dev/packages/persistent_bottom_nav_bar_v2
BSD 3-Clause "New" or "Revised" License
47 stars 48 forks source link

Bottom navbar item list is not growable. #156

Open RB-93 opened 2 months ago

RB-93 commented 2 months ago

Version

5.2.1

Flutter Doctor Output

PS C:\Users\rohit\VSCodeProjects\kisanlight> flutter doctor -v
[√] Flutter (Channel stable, 3.19.5, on Microsoft Windows [Version 10.0.19045.4355], locale en-IN)
    • Flutter version 3.19.5 on channel stable at C:\Users\rohit\Documents\Flutter SDK\flutter
    • Upstream repository https://github.com/flutter/flutter.git
    • Framework revision 300451adae (6 weeks ago), 2024-03-27 21:54:07 -0500
    • Engine revision e76c956498
    • Dart version 3.3.3
    • DevTools version 2.31.1

[√] Windows Version (Installed version of Windows is version 10 or higher)

[√] Android toolchain - develop for Android devices (Android SDK version 34.0.0)
    • Android SDK at C:\Users\rohit\AppData\Local\Android\sdk
    • Platform android-34, build-tools 34.0.0
    • Java binary at: C:\Program Files\Android\Android Studio\jbr\bin\java
    • Java version OpenJDK Runtime Environment (build 17.0.9+0--11185874) 
    • All Android licenses accepted.

[√] Chrome - develop for the web
    • Chrome at C:\Program Files\Google\Chrome\Application\chrome.exe

[X] Visual Studio - develop Windows apps
    X Visual Studio not installed; this is necessary to develop Windows apps.
      Download at https://visualstudio.microsoft.com/downloads/.
      Please install the "Desktop development with C++" workload, including all of its default components

[√] Android Studio (version 2023.2)
    • Android Studio at C:\Program Files\Android\Android Studio
    • Flutter plugin can be installed from:
       https://plugins.jetbrains.com/plugin/9212-flutter
    • Dart plugin can be installed from:
       https://plugins.jetbrains.com/plugin/6351-dart
    • Java version OpenJDK Runtime Environment (build 17.0.9+0--11185874)

[√] VS Code (version 1.89.0)
    • VS Code at C:\Users\rohit\AppData\Local\Programs\Microsoft VS Code
    • Flutter extension version 3.88.0

[√] Connected device (4 available)
    • Moto Z2 Play (mobile) • 192.168.29.253:5555 • android-arm    • Android 8.0.0 (API 26)
    • Windows (desktop)     • windows             • windows-x64    • Microsoft Windows [Version 10.0.19045.4355]
    • Chrome (web)          • chrome              • web-javascript • Google Chrome 124.0.6367.119
    • Edge (web)            • edge                • web-javascript • Microsoft Edge 124.0.2478.80

[√] Network resources
    • All expected network resources are available.

! Doctor found issues in 1 category.

What platforms are you seeing the problem on?

Android

What happened?

On a fresh run (reinstall with data clear) with this pkg build, It shows red screen with "cannot add to a fixed-length list" error.

My app use case: I have maintained bottom bar widget and kept a toggling boolean to one of the tab option to hide or show it on UI which is listening values using Provider state management.

I tried to set growable flag to true in addAll() method (PFA 1)

and here too (PFA 2)

But after this it throws this exception could be because focusNodes index doesn't get updated with provider updated values of tablist.

════════ Exception caught by widgets library ═══════════════════════════════════ RangeError (index): Invalid value: Not in inclusive range 0..1: 2 The relevant error-causing widget was: ════════════════════════════════════════════════════════════════════════════════

The list of tab options should support 'growable' list functionality, so that Tab list can handled dynamically in the code.

Steps to reproduce

Run provided code with where any one option should be handled with a boolean flag in the Firebase Realtime database listened using Provider state management.

Code to reproduce the problem

PersistentTabView code

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

  @override
  State<MainScreen> createState() => _MainScreenState();

  //final BottomTabsController controller;
}

class _MainScreenState extends State<MainScreen> {
@override
  Widget build(BuildContext context) {
    // final controller = context.read<BottomTabsController>();
    return Consumer2<MasterControlModel, BottomTabsController>(
            builder: (context, master, tabsController, child) {
              context.read<BottomTabsController>().initScreens(
                  showWebinar:
                      context.read<MasterControlModel>().showWebinarTile ??
                          false);
              return PopScope(
                canPop: false,
                onPopInvoked: (didPop) {
                  showDialog(
                    context: context,
                    builder: (context) => Dialog(
                      child: Center(
                        child: ElevatedButton(
                          child: const Text("Close"),
                          onPressed: () {
                            Navigator.pop(context);
                          },
                        ),
                      ),
                    ),
                  );
                },
                child: PersistentTabView(
                  //backgroundColor: white,
                  /* navBarOverlap: NavBarOverlap.custom(
                      overlap: navBarDecoration.exposedHeight()), */
                  //avoidBottomPadding: false,
                  controller: tabsController.tabController,
                  margin: settings.margin,
                  stateManagement: settings.stateManagement,
                  hideNavigationBar:
                      !context.watch<BottomTabsController>().showBottonTabbar,
                  tabs: tabsController.tabs.map(
                    (tabItem) {
                      return PersistentTabConfig(
                        screen: tabItem.screen,
                        item: ItemConfig(
                          icon: tabItem.icon,
                          title: loaded ? tabItem.name.tr() : '',
                          activeForegroundColor: white,
                          activeColorSecondary: Colors.transparent,
                          inactiveForegroundColor:
                              Colors.white.withOpacity(0.5),
                          textStyle: GoogleFonts.poppins(
                            color: white,
                            fontWeight: FontWeight.bold,
                          ),
                        ),
                      );
                    },
                  ).toList(),
                  navBarBuilder: (navBarConfig) => settings.navBarBuilder(
                    navBarConfig,
                    navBarDecoration,
                    const ItemAnimation(),
                    const NeumorphicProperties(),
                  ),
                ),
              );
            },
          )
}

-------------------
BottomTabController code

class BottomTabsController extends ChangeNotifier {
  PersistentTabController tabController = PersistentTabController();

  bool showBottonTabbar = true;

  void initScreens({bool showIOS = true}) {
    _tabseq.clear();
    _tabseq.addAll([
      KTab.home,
      if (showIOS) KTab.webinar,
      KTab.shortlisted,
      // KTab.windows,
    ]);
  }

  final List<KTab> _tabseq = [];

  List<BottomTab> get tabs {
    List<BottomTab> tablist = [];

    for (final val in _tabseq) {
      tablist.add(BottomTab.fromEnum(val));
    }

    return tablist;
  }

  KTab get currentTab {
    return _tabseq[tabController.index];
  }

  void changePage(KTab tab) {
    if (tab == currentTab) {
      return;
    }

    final tabIndex = _tabseq.indexOf(tab);

    tabController.jumpToTab(tabIndex);
  }

  void drawerChanged(bool isOpened) {
    if (isOpened) {
      showBottonTabbar = false;
    } else {
      showBottonTabbar = true;
    }
    notifyListeners();
  }
}

class BottomTab extends Equatable {
  final Widget icon;
  final String name;
  final KTab tabEnum;

  final Widget screen;
  const BottomTab({
    this.icon,
    required this.name,
    required this.tabEnum,
    required this.screen,
  });

  factory BottomTab.fromEnum(KTab val) {
    switch (val) {
      case KTab.android:
        {
          return const BottomTab(
              // icon: Icon(Icons.home_rounded),
              name: 'Android',
              tabEnum: KTab.android,
              screen: AndroidScreen());
        }
      case KTab.ios:
        {
          return const BottomTab(
            // icon: Icon(Icons.videocam_rounded),
            name: 'iOS',
            tabEnum: KTab.ios,
            screen: IOSScreen(),
          );
        }
      case KTab.web:
        {
          return const BottomTab(
            // icon: Icon(Icons.favorite),
            name: 'Web',
            tabEnum: KTab.shortlisted,
            screen: WebScreen(),
          );
        }
      case KTab.windows:
        {
          return const BottomTab(
            // icon: Icon(Icons.phone_callback_rounded),
            name: 'Windows',
            tabEnum: KTab.windows,
            screen: WindowsScreen(),
          );
        }
    }
  }

  @override
  List<Object?> get props => [tabEnum];
}

enum KTab {
  android,
  ios,
  web,
  windows,
}

The issue is in 
initScreen Method when the tabs are added using addAll() > PersistentTabViewScaffold (shouldBuildTab.addAll())

Relevant log output

After making changes in internal classes > made list to growable
as shown in attached screenshots

════════ Exception caught by widgets library ═══════════════════════════════════
RangeError (index): Invalid value: Not in inclusive range 0..1: 2
The relevant error-causing widget was:
════════════════════════════════════════════════════════════════════════════════

Screenshots

(PFA 1) image

(PFA 2) image

jb3rndt commented 1 month ago

Hi, thank you for opening this issue. You are right, dynamically chaning the number of tabs is not supported as of now. I assumed that a user expects a persistent bottom navigation bar to be static and not change while the app is running. Thus, changing the content of the navigation bar results in bad UX (see official Material 3 docs). Do you disagree or have a justifying use case to allow that?

lukehutch commented 1 month ago

I also need the ability to dynamically change the number of tabs in my app.

I assumed that a user expects a persistent bottom navigation bar to be static and not change while the app is running.

It was my expectation that the tab bar should indeed be persistent, i.e. not need rebuilding from scratch ever, while also supporting dynamically changing numbers of tabs.

It is standard practice in Flutter to allow different numbers of items in any sort of container between different calls to build, and the PersistentTabView API basically looks like the ListView.builder API, in other words, requiring the user to specify the number of items, and the content of each item, each time build is called.

In ListView, if items are deleted or inserted into the list, the widgets that are rendered may be structurally identical, so you may need to use a ValueKey for the updates to actually be detected and rendered. Nevertheless, the builder doesn't care about how many items were in the list last time build was called.

With PersistentBottomNavBarV2, a lot of things assume the number of tabs never changes, so I got all sorts of errors (mostly ! used on a null value, but I think also an array index out of bounds issue) when I tried changing the number of tabs dynamically. I was starting to work through all these issues to fix them, and instead, I ended up wrapping the whole PersistentTabView in a ValueListenableBuilder, also assigning a new unique key in PersistentTabView every time the ValueListenableBuilder was rebuilt, to force the entire PersistentTabView to be torn down and rebuilt. This is inefficient, but it's the only solution to the problem I could come up with.

jb3rndt commented 1 month ago

Yes it will indeed need quite some amount of work to do that, so that will probably take some time, sorry... I'll try looking into fixing that. But to be honest I am still concerned about the UX aspect, so personally I would advise against changing the tabs dynamically.

lukehutch commented 1 month ago

UX is always contextual. In my app, continuing to show a tab for a feature that the user just manually disabled doesn't make any sense.