【问题标题】:Flutter web Bottom Navigation Bar with url updating and hyperlink support带有 url 更新和超链接支持的 Flutter web 底部导航栏
【发布时间】:2021-02-03 23:37:50
【问题描述】:

我一直在网上搜索支持以下功能的 Flutter 应用示例:

  1. 永久底部导航栏
  2. 对每个保持状态的底部导航栏项的导航器支持
  3. 导航器在导航时更新 Flutter Web 中的 URL
  4. 导航器支持超链接到 Flutter Web 中的特定页面。例如,如果用户键入 www.myappdomain.com/#/profile,他们会被导航到个人资料页面。或者他们输入www.myappdomain.com/#/profile?id=123,他们会被导航到用户 123 的个人资料页面。

我已经能够使用以下代码实现第 1 点和第 2 点:

import 'package:flutter/material.dart';

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

class BaseApp extends StatefulWidget {
  @override
  _BaseAppState createState() => _BaseAppState();
}

class _BaseAppState extends State<BaseApp> {
  // define all navigation tabs for user
  static List<NavigationTabModel> _navigationTabs = [
    NavigationTabModel(
      title: 'Home',
      icon: Icons.home,
      url: '/home',
      navigatorKey: GlobalKey<NavigatorState>(),
    ),
    NavigationTabModel(
      title: 'Search',
      icon: Icons.search,
      url: '/search',
      navigatorKey: GlobalKey<NavigatorState>(),
    ),
    NavigationTabModel(
      title: 'Profile',
      icon: Icons.person,
      url: '/profile',
      navigatorKey: GlobalKey<NavigatorState>(),
    ),
  ];

  // route generator used to build all material page routes
  RouteGenerator _routeGenerator = RouteGenerator();

  // set the current tab to the home page
  int _currentIndex = 0;

  void _select(int index) {
    if (index == _currentIndex) {
      // case 1 - if user presses on currently selected tab
      // pop to first route - i.e. ensure no routes are over laid on top of the current route
      _navigationTabs[_currentIndex]
          .navigatorKey
          .currentState
          .popUntil((route) {
        return route.isFirst;
      });
    } else {
      // case 2 - user selects any other tab
      // rebuild application state with the newly selected navigation tab
      setState(() {
        _currentIndex = index;
      });
    }
  }

  /// generate a list of navigators that will have their state persisted in an
  /// indexed stack.
  List<Widget> _getPersistantStack() {
    return _navigationTabs.map((tab) {
      return WillPopScope(
        onWillPop: () async {
          return !await tab.navigatorKey.currentState.maybePop();
        },
        child: Navigator(
          key: tab.navigatorKey,
          initialRoute: tab.url,
          onGenerateRoute: _routeGenerator.generateRoute,
        ),
      );
    }).toList();
  }

  @override
  Widget build(BuildContext context) {
    /// ********************* HOLD POINT *********************
    /// MaterialApp contains our top-level Navigator. Top level navigator is
    /// required to enable navigation via urls in flutter web. Likely that this
    /// section requires refractoring in some way to enable url updates from the
    /// nested navigators and hyperlinking from web browsers to specific pages
    return MaterialApp(
      debugShowCheckedModeBanner: false,
      home: Scaffold(
        body: SafeArea(
          child: IndexedStack(
            children: _getPersistantStack(),
            index: _currentIndex,
          ),
        ),
        bottomNavigationBar: BottomNavigationBar(
          items: _navigationTabs.map((tab) {
            return BottomNavigationBarItem(
              label: tab.title,
              icon: Icon(tab.icon),
            );
          }).toList(),
          onTap: (int index) {
            _select(index);
          },
          currentIndex: _currentIndex,
          type: BottomNavigationBarType.fixed,
          // hide titles on navigation bar
          showSelectedLabels: false,
          showUnselectedLabels: false,
        ),
      ),
    );
  }
}

class NavigationTabModel {
  final String title;
  final IconData icon;
  final String url;
  final GlobalKey<NavigatorState> navigatorKey;

  NavigationTabModel({
    this.title,
    this.icon,
    this.url,
    this.navigatorKey,
  });
}

class RouteGenerator {
  Route<dynamic> generateRoute(RouteSettings settings) {
    // Widget builder (function that returns a widget) to construct the route page
    WidgetBuilder builder;

    // build different route (page) based on the route passed to the navigator
    switch (settings.name) {
      case '/home':
        builder = (BuildContext context) {
          return SamplePage(name: 'home');
        };
        break;
      case '/search':
        builder = (BuildContext context) {
          return SamplePage(name: 'search');
        };
        break;
      case '/profile':
        builder = (BuildContext context) {
          return SamplePage(name: 'profile');
        };
        break;
      case '/':
        builder = null;
        break;
      default:
        // If there is no such named route in the switch statement
        builder = (BuildContext context) {
          return SamplePage();
        };
    }
    // prevent page being added to default '/' route
    if (builder == null) {
      return null;
    }
    return MaterialPageRoute(
      builder: builder,
      settings: settings,
    );
  }
}

class SamplePage extends StatelessWidget {
  final String name;

  SamplePage({
    this.name,
  });

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text('$name'),
      ),
      body: Padding(
        padding: const EdgeInsets.all(16.0),
        child: Column(
          children: [
            RaisedButton(
              child: Text('push new route'),
              onPressed: () {
                Navigator.of(context).pushNamed('/$name');
              },
            ),
            SizedBox(
              height: 16,
            ),
            Expanded(
              child: ListView.builder(
                itemCount: 100,
                itemBuilder: (context, index) {
                  return Card(
                    child: Text(
                      index.toString(),
                    ),
                  );
                },
              ),
            ),
          ],
        ),
      ),
    );
  }
}

但是我不知道如何更新此应用程序以实现第 3 点和第 4 点。有人知道如何实现吗?

【问题讨论】:

    标签: flutter flutter-web


    【解决方案1】:

    虽然 Navigator 2.0 一开始有点吓人,但当您(主要)针对 Web 应用程序时,这是非常值得的,因为您有很多选项可以从深层链接恢复状态。

    在@Lulupointu 已经链接到的official introduction 中,有一个示例与您正在寻找的内容完全相同(但在文章的最底部有点隐藏)。

    In this gist 是一个为不同选项卡使用不同导航器堆栈的工作示例,为了完整起见,我将其发布在下面。它适用于频道测试版,1.23.0-18.1.pre

    import 'package:flutter/material.dart';
    
    void main() {
      runApp(NestedRouterDemo());
    }
    
    class Book {
      final String title;
      final String author;
    
      Book(this.title, this.author);
    }
    
    class NestedRouterDemo extends StatefulWidget {
      @override
      _NestedRouterDemoState createState() => _NestedRouterDemoState();
    }
    
    class _NestedRouterDemoState extends State<NestedRouterDemo> {
      BookRouterDelegate _routerDelegate = BookRouterDelegate();
      BookRouteInformationParser _routeInformationParser =
          BookRouteInformationParser();
    
      @override
      Widget build(BuildContext context) {
        return MaterialApp.router(
          title: 'Books App',
          routerDelegate: _routerDelegate,
          routeInformationParser: _routeInformationParser,
        );
      }
    }
    
    class BooksAppState extends ChangeNotifier {
      int _selectedIndex;
    
      Book _selectedBook;
    
      final List<Book> books = [
        Book('Stranger in a Strange Land', 'Robert A. Heinlein'),
        Book('Foundation', 'Isaac Asimov'),
        Book('Fahrenheit 451', 'Ray Bradbury'),
      ];
    
      BooksAppState() : _selectedIndex = 0;
    
      int get selectedIndex => _selectedIndex;
    
      set selectedIndex(int idx) {
        _selectedIndex = idx;
        notifyListeners();
      }
    
      Book get selectedBook => _selectedBook;
    
      set selectedBook(Book book) {
        _selectedBook = book;
        notifyListeners();
      }
    
      int getSelectedBookById() {
        if (!books.contains(_selectedBook)) return 0;
        return books.indexOf(_selectedBook);
      }
    
      void setSelectedBookById(int id) {
        if (id < 0 || id > books.length - 1) {
          return;
        }
    
        _selectedBook = books[id];
        notifyListeners();
      }
    }
    
    class BookRouteInformationParser extends RouteInformationParser<BookRoutePath> {
      @override
      Future<BookRoutePath> parseRouteInformation(
          RouteInformation routeInformation) async {
        final uri = Uri.parse(routeInformation.location);
    
        if (uri.pathSegments.isNotEmpty && uri.pathSegments.first == 'settings') {
          return BooksSettingsPath();
        } else {
          if (uri.pathSegments.length >= 2) {
            if (uri.pathSegments[0] == 'book') {
              return BooksDetailsPath(int.tryParse(uri.pathSegments[1]));
            }
          }
          return BooksListPath();
        }
      }
    
      @override
      RouteInformation restoreRouteInformation(BookRoutePath configuration) {
        if (configuration is BooksListPath) {
          return RouteInformation(location: '/home');
        }
        if (configuration is BooksSettingsPath) {
          return RouteInformation(location: '/settings');
        }
        if (configuration is BooksDetailsPath) {
          return RouteInformation(location: '/book/${configuration.id}');
        }
        return null;
      }
    }
    
    class BookRouterDelegate extends RouterDelegate<BookRoutePath>
        with ChangeNotifier, PopNavigatorRouterDelegateMixin<BookRoutePath> {
      final GlobalKey<NavigatorState> navigatorKey;
    
      BooksAppState appState = BooksAppState();
    
      BookRouterDelegate() : navigatorKey = GlobalKey<NavigatorState>() {
        appState.addListener(notifyListeners);
      }
    
      @override
      BookRoutePath get currentConfiguration {
        if (appState.selectedIndex == 1) {
          return BooksSettingsPath();
        } else {
          if (appState.selectedBook == null) {
            return BooksListPath();
          } else {
            return BooksDetailsPath(appState.getSelectedBookById());
          }
        }
      }
    
      @override
      Widget build(BuildContext context) {
        return Navigator(
          key: navigatorKey,
          pages: [
            MaterialPage(
              child: AppShell(appState: appState),
            ),
          ],
          onPopPage: (route, result) {
            if (!route.didPop(result)) {
              return false;
            }
    
            if (appState.selectedBook != null) {
              appState.selectedBook = null;
            }
            notifyListeners();
            return true;
          },
        );
      }
    
      @override
      Future<void> setNewRoutePath(BookRoutePath path) async {
        if (path is BooksListPath) {
          appState.selectedIndex = 0;
          appState.selectedBook = null;
        } else if (path is BooksSettingsPath) {
          appState.selectedIndex = 1;
        } else if (path is BooksDetailsPath) {
          appState.selectedIndex = 0;
          appState.setSelectedBookById(path.id);
        }
      }
    }
    
    // Routes
    abstract class BookRoutePath {}
    
    class BooksListPath extends BookRoutePath {}
    
    class BooksSettingsPath extends BookRoutePath {}
    
    class BooksDetailsPath extends BookRoutePath {
      final int id;
    
      BooksDetailsPath(this.id);
    }
    
    // Widget that contains the AdaptiveNavigationScaffold
    class AppShell extends StatefulWidget {
      final BooksAppState appState;
    
      AppShell({
        @required this.appState,
      });
    
      @override
      _AppShellState createState() => _AppShellState();
    }
    
    class _AppShellState extends State<AppShell> {
      InnerRouterDelegate _routerDelegate;
      ChildBackButtonDispatcher _backButtonDispatcher;
    
      void initState() {
        super.initState();
        _routerDelegate = InnerRouterDelegate(widget.appState);
      }
    
      @override
      void didUpdateWidget(covariant AppShell oldWidget) {
        super.didUpdateWidget(oldWidget);
        _routerDelegate.appState = widget.appState;
      }
    
      @override
      void didChangeDependencies() {
        super.didChangeDependencies();
        // Defer back button dispatching to the child router
        _backButtonDispatcher = Router.of(context)
            .backButtonDispatcher
            .createChildBackButtonDispatcher();
      }
    
      @override
      Widget build(BuildContext context) {
        var appState = widget.appState;
    
        // Claim priority, If there are parallel sub router, you will need
        // to pick which one should take priority;
        _backButtonDispatcher.takePriority();
    
        return Scaffold(
          appBar: AppBar(),
          body: Router(
            routerDelegate: _routerDelegate,
            backButtonDispatcher: _backButtonDispatcher,
          ),
          bottomNavigationBar: BottomNavigationBar(
            items: [
              BottomNavigationBarItem(icon: Icon(Icons.home), label: 'Home'),
              BottomNavigationBarItem(
                  icon: Icon(Icons.settings), label: 'Settings'),
            ],
            currentIndex: appState.selectedIndex,
            onTap: (newIndex) {
              appState.selectedIndex = newIndex;
            },
          ),
        );
      }
    }
    
    class InnerRouterDelegate extends RouterDelegate<BookRoutePath>
        with ChangeNotifier, PopNavigatorRouterDelegateMixin<BookRoutePath> {
      final GlobalKey<NavigatorState> navigatorKey = GlobalKey<NavigatorState>();
      BooksAppState get appState => _appState;
      BooksAppState _appState;
      set appState(BooksAppState value) {
        if (value == _appState) {
          return;
        }
        _appState = value;
        notifyListeners();
      }
    
      InnerRouterDelegate(this._appState);
    
      @override
      Widget build(BuildContext context) {
        return Navigator(
          key: navigatorKey,
          pages: [
            if (appState.selectedIndex == 0) ...[
              FadeAnimationPage(
                child: BooksListScreen(
                  books: appState.books,
                  onTapped: _handleBookTapped,
                ),
                key: ValueKey('BooksListPage'),
              ),
              if (appState.selectedBook != null)
                MaterialPage(
                  key: ValueKey(appState.selectedBook),
                  child: BookDetailsScreen(book: appState.selectedBook),
                ),
            ] else
              FadeAnimationPage(
                child: SettingsScreen(),
                key: ValueKey('SettingsPage'),
              ),
          ],
          onPopPage: (route, result) {
            appState.selectedBook = null;
            notifyListeners();
            return route.didPop(result);
          },
        );
      }
    
      @override
      Future<void> setNewRoutePath(BookRoutePath path) async {
        // This is not required for inner router delegate because it does not
        // parse route
        assert(false);
      }
    
      void _handleBookTapped(Book book) {
        appState.selectedBook = book;
        notifyListeners();
      }
    }
    
    class FadeAnimationPage extends Page {
      final Widget child;
    
      FadeAnimationPage({Key key, this.child}) : super(key: key);
    
      Route createRoute(BuildContext context) {
        return PageRouteBuilder(
          settings: this,
          pageBuilder: (context, animation, animation2) {
            var curveTween = CurveTween(curve: Curves.easeIn);
            return FadeTransition(
              opacity: animation.drive(curveTween),
              child: child,
            );
          },
        );
      }
    }
    
    // Screens
    class BooksListScreen extends StatelessWidget {
      final List<Book> books;
      final ValueChanged<Book> onTapped;
    
      BooksListScreen({
        @required this.books,
        @required this.onTapped,
      });
    
      @override
      Widget build(BuildContext context) {
        return Scaffold(
          body: ListView(
            children: [
              for (var book in books)
                ListTile(
                  title: Text(book.title),
                  subtitle: Text(book.author),
                  onTap: () => onTapped(book),
                )
            ],
          ),
        );
      }
    }
    
    class BookDetailsScreen extends StatelessWidget {
      final Book book;
    
      BookDetailsScreen({
        @required this.book,
      });
    
      @override
      Widget build(BuildContext context) {
        return Scaffold(
          body: Padding(
            padding: const EdgeInsets.all(8.0),
            child: Column(
              crossAxisAlignment: CrossAxisAlignment.start,
              children: [
                FlatButton(
                  onPressed: () {
                    Navigator.of(context).pop();
                  },
                  child: Text('Back'),
                ),
                if (book != null) ...[
                  Text(book.title, style: Theme.of(context).textTheme.headline6),
                  Text(book.author, style: Theme.of(context).textTheme.subtitle1),
                ],
              ],
            ),
          ),
        );
      }
    }
    
    class SettingsScreen extends StatelessWidget {
      @override
      Widget build(BuildContext context) {
        return Scaffold(
          body: Center(
            child: Text('Settings screen'),
          ),
        );
      }
    }
    

    如果您希望主页导航按钮链接到主页而不保持状态,那么您必须将 set selectedIndex 替换为:

      set selectedIndex(int idx) {
        _selectedIndex = idx;
        if (_selectedIndex == 1) {
          // Remove this line if you want to keep the selected book when navigating
          // between "settings" and "home" which book was selected when Settings is
          // tapped.
          selectedBook = null;
        }
        notifyListeners();
      }
    

    在编写自己的 RouteInformationParser 时,您可能想看看如何提取查询参数: Parsing a URI to extract query parameters, with Dart

    下面是另一个示例,您可能想看看以了解 Navigator 2.0:https://github.com/flutter/flutter/pull/63424

    【讨论】:

    • 非常感谢您提供的信息! :) 我确实从@Lulupointu 提供的原始答案中查看了这个示例。虽然它确实实现了带有 URL 更新的持久状态底部导航栏,但它在深度链接方面存在问题。例如,如果我在 /settings 路由上,然后键入 /book/1,则不会加载 book 路由。关于如何更新该示例以解决此问题的任何想法?
    • 刚刚在上面的答案中解决了这个问题 - 问题出在 setNewRoutePath 方法中 - 在解析详细信息页面时,您所在的选项卡的全局应用程序状态未更新。您可能还想在 BottomBarButtons 上添加一个“弹出”命令,作为在堆栈中向后移动的一种方式(新的 Apple Music 应用程序就是一个很好的例子,说明如何做到这一点)
    • 这太棒了!现在一切似乎都在工作,除了一件小事,即此应用程序生成的浏览器历史记录不会像典型网站那样显示每个导航页面的完整 URL,而是只显示应用程序名称,在这种情况下是“图书应用程序”。另外顺便说一句,您是否碰巧知道在这样配置时,Google 是否会正确索引应用程序的所有页面?
    • 我还尝试按照建议将 navigator.pop() 方法添加到底部导航栏,但它会导致应用程序出现一些奇怪的行为。 onTap: (newIndex) { appState.selectedIndex = newIndex; Navigator.of(context).pop(); }
    • 到目前为止,Flutter 应用程序对 SEO 不友好,所以我怀疑任何页面都被它们的爬虫索引(可能很快会改变),但这将是堆栈溢出的一个很好的新问题,所以其他人也可以找到它;) - 关于奇怪的行为,我还建议提出一个新问题,但我很高兴在其他地方继续对话(在推特上找到我,DM 是开放的)。如果我的回答解决了你最初的问题,如果你能接受它并奖励赏金那就太好了:)
    【解决方案2】:

    自 2020 年 9 月 30 日起,flutter introduced Navigator 2.0

    您可以查看完整教程的链接。跳过关于 Navigator 1.0 的部分,转到 Navigator 2.0。

    基本上两个主要的小部件是RouteInformationParserRouterDelegate

    我强烈建议您阅读这篇文章以及有关路由的许多其他信息。

    也就是说,这是他们描述的代码,您可以在网络上尝试一下,看看它是否实现了您的第 3 点和第 4 点。

    import 'package:flutter/material.dart';
    
    void main() {
      runApp(BooksApp());
    }
    
    class Book {
      final String title;
      final String author;
    
      Book(this.title, this.author);
    }
    
    class BooksApp extends StatefulWidget {
      @override
      State<StatefulWidget> createState() => _BooksAppState();
    }
    
    class _BooksAppState extends State<BooksApp> {
      BookRouterDelegate _routerDelegate = BookRouterDelegate();
      BookRouteInformationParser _routeInformationParser =
      BookRouteInformationParser();
    
      @override
      Widget build(BuildContext context) {
        return MaterialApp.router(
          title: 'Books App',
          routerDelegate: _routerDelegate,
          routeInformationParser: _routeInformationParser,
        );
      }
    }
    
    class BookRouteInformationParser extends RouteInformationParser<BookRoutePath> {
      @override
      Future<BookRoutePath> parseRouteInformation(
          RouteInformation routeInformation) async {
        final uri = Uri.parse(routeInformation.location);
        // Handle '/'
        if (uri.pathSegments.length == 0) {
          return BookRoutePath.home();
        }
    
        // Handle '/book/:id'
        if (uri.pathSegments.length == 2) {
          if (uri.pathSegments[0] != 'book') return BookRoutePath.unknown();
          var remaining = uri.pathSegments[1];
          var id = int.tryParse(remaining);
          if (id == null) return BookRoutePath.unknown();
          return BookRoutePath.details(id);
        }
    
        // Handle unknown routes
        return BookRoutePath.unknown();
      }
    
      @override
      RouteInformation restoreRouteInformation(BookRoutePath path) {
        if (path.isUnknown) {
          return RouteInformation(location: '/404');
        }
        if (path.isHomePage) {
          return RouteInformation(location: '/');
        }
        if (path.isDetailsPage) {
          return RouteInformation(location: '/book/${path.id}');
        }
        return null;
      }
    }
    
    class BookRouterDelegate extends RouterDelegate<BookRoutePath>
        with ChangeNotifier, PopNavigatorRouterDelegateMixin<BookRoutePath> {
      final GlobalKey<NavigatorState> navigatorKey;
    
      Book _selectedBook;
      bool show404 = false;
    
      List<Book> books = [
        Book('Stranger in a Strange Land', 'Robert A. Heinlein'),
        Book('Foundation', 'Isaac Asimov'),
        Book('Fahrenheit 451', 'Ray Bradbury'),
      ];
    
      BookRouterDelegate() : navigatorKey = GlobalKey<NavigatorState>();
    
      BookRoutePath get currentConfiguration {
        if (show404) {
          return BookRoutePath.unknown();
        }
        return _selectedBook == null
            ? BookRoutePath.home()
            : BookRoutePath.details(books.indexOf(_selectedBook));
      }
    
      @override
      Widget build(BuildContext context) {
        return Navigator(
          key: navigatorKey,
          pages: [
            MaterialPage(
              key: ValueKey('BooksListPage'),
              child: BooksListScreen(
                books: books,
                onTapped: _handleBookTapped,
              ),
            ),
            if (show404)
              MaterialPage(key: ValueKey('UnknownPage'), child: UnknownScreen())
            else if (_selectedBook != null)
              BookDetailsPage(book: _selectedBook)
          ],
          onPopPage: (route, result) {
            if (!route.didPop(result)) {
              return false;
            }
    
            // Update the list of pages by setting _selectedBook to null
            _selectedBook = null;
            show404 = false;
            notifyListeners();
    
            return true;
          },
        );
      }
    
      @override
      Future<void> setNewRoutePath(BookRoutePath path) async {
        if (path.isUnknown) {
          _selectedBook = null;
          show404 = true;
          return;
        }
    
        if (path.isDetailsPage) {
          if (path.id < 0 || path.id > books.length - 1) {
            show404 = true;
            return;
          }
    
          _selectedBook = books[path.id];
        } else {
          _selectedBook = null;
        }
    
        show404 = false;
      }
    
      void _handleBookTapped(Book book) {
        _selectedBook = book;
        notifyListeners();
      }
    }
    
    class BookDetailsPage extends Page {
      final Book book;
    
      BookDetailsPage({
        this.book,
      }) : super(key: ValueKey(book));
    
      Route createRoute(BuildContext context) {
        return MaterialPageRoute(
          settings: this,
          builder: (BuildContext context) {
            return BookDetailsScreen(book: book);
          },
        );
      }
    }
    
    class BookRoutePath {
      final int id;
      final bool isUnknown;
    
      BookRoutePath.home()
          : id = null,
            isUnknown = false;
    
      BookRoutePath.details(this.id) : isUnknown = false;
    
      BookRoutePath.unknown()
          : id = null,
            isUnknown = true;
    
      bool get isHomePage => id == null;
    
      bool get isDetailsPage => id != null;
    }
    
    class BooksListScreen extends StatelessWidget {
      final List<Book> books;
      final ValueChanged<Book> onTapped;
    
      BooksListScreen({
        @required this.books,
        @required this.onTapped,
      });
    
      @override
      Widget build(BuildContext context) {
        return Scaffold(
          appBar: AppBar(),
          body: ListView(
            children: [
              for (var book in books)
                ListTile(
                  title: Text(book.title),
                  subtitle: Text(book.author),
                  onTap: () => onTapped(book),
                )
            ],
          ),
        );
      }
    }
    
    class BookDetailsScreen extends StatelessWidget {
      final Book book;
    
      BookDetailsScreen({
        @required this.book,
      });
    
      @override
      Widget build(BuildContext context) {
        return Scaffold(
          appBar: AppBar(),
          body: Padding(
            padding: const EdgeInsets.all(8.0),
            child: Column(
              crossAxisAlignment: CrossAxisAlignment.start,
              children: [
                if (book != null) ...[
                  Text(book.title, style: Theme.of(context).textTheme.headline6),
                  Text(book.author, style: Theme.of(context).textTheme.subtitle1),
                ],
              ],
            ),
          ),
        );
      }
    }
    
    class UnknownScreen extends StatelessWidget {
      @override
      Widget build(BuildContext context) {
        return Scaffold(
          appBar: AppBar(),
          body: Center(
            child: Text('404!'),
          ),
        );
      }
    }
    

    【讨论】:

    • 感谢您的信息!虽然这确实实现了第 3 点和第 4 点,但困难的部分是将其与其余所需功能合并,即具有状态持久性的底部导航栏?关于我如何实现这一目标的任何指导?同样在阅读本文后,navigator 2.0 似乎不必要地复杂......
    【解决方案3】:

    来自此gist 的工作底部导航与 Flutter sdk >= 2.12 兼容

    import 'package:flutter/material.dart';
    
    void main() {
      runApp(NestedRouterDemo());
    }
    
    class Book {
      final String title;
      final String author;
    
      Book(this.title, this.author);
    }
    
    class NestedRouterDemo extends StatefulWidget {
      @override
      _NestedRouterDemoState createState() => _NestedRouterDemoState();
    }
    
    class _NestedRouterDemoState extends State<NestedRouterDemo> {
      BookRouterDelegate _routerDelegate = BookRouterDelegate();
      BookRouteInformationParser _routeInformationParser = BookRouteInformationParser();
    
      @override
      Widget build(BuildContext context) {
        return MaterialApp.router(
          title: 'Books App',
          routerDelegate: _routerDelegate,
          routeInformationParser: _routeInformationParser,
        );
      }
    }
    
    class BooksAppState extends ChangeNotifier {
      int _selectedIndex;
    
      Book? _selectedBook;
    
      final List<Book> books = [
        Book('Stranger in a Strange Land', 'Robert A. Heinlein'),
        Book('Foundation', 'Isaac Asimov'),
        Book('Fahrenheit 451', 'Ray Bradbury'),
      ];
    
      BooksAppState() : _selectedIndex = 0;
    
      int get selectedIndex => _selectedIndex;
    
      set selectedIndex(int idx) {
        _selectedIndex = idx;
        notifyListeners();
      }
    
      Book? get selectedBook => _selectedBook;
    
      set selectedBook(Book? book) {
        _selectedBook = book;
        notifyListeners();
      }
    
      int? getSelectedBookById() {
        if (_selectedBook == null || !books.contains(_selectedBook)) return null;
        return books.indexOf(_selectedBook!);
      }
    
      void setSelectedBookById(int id) {
        if (id < 0 || id > books.length - 1) {
          return;
        }
    
        _selectedBook = books[id];
        notifyListeners();
      }
    }
    
    class BookRouteInformationParser extends RouteInformationParser<BookRoutePath> {
      @override
      Future<BookRoutePath> parseRouteInformation(RouteInformation routeInformation) async {
        final uri = Uri.parse(routeInformation.location ?? '');
    
        if (uri.pathSegments.isNotEmpty && uri.pathSegments.first == 'settings') {
          return BooksSettingsPath();
        } else {
          if (uri.pathSegments.length >= 2) {
            if (uri.pathSegments[0] == 'book') {
              return BooksDetailsPath(int.tryParse(uri.pathSegments[1]));
            }
          }
          return BooksListPath();
        }
      }
    
      @override
      RouteInformation restoreRouteInformation(BookRoutePath configuration) {
        if (configuration is BooksListPath) {
          return RouteInformation(location: '/home');
        }
        if (configuration is BooksSettingsPath) {
          return RouteInformation(location: '/settings');
        }
        if (configuration is BooksDetailsPath) {
          return RouteInformation(location: '/book/${configuration.id}');
        }
        return RouteInformation();
      }
    }
    
    class BookRouterDelegate extends RouterDelegate<BookRoutePath>
        with ChangeNotifier, PopNavigatorRouterDelegateMixin<BookRoutePath> {
      final GlobalKey<NavigatorState> navigatorKey;
    
      BooksAppState appState = BooksAppState();
    
      BookRouterDelegate() : navigatorKey = GlobalKey<NavigatorState>() {
        appState.addListener(notifyListeners);
      }
    
      BookRoutePath get currentConfiguration {
        if (appState.selectedIndex == 1) {
          return BooksSettingsPath();
        } else {
          if (appState.selectedBook == null) {
            return BooksListPath();
          } else {
            return BooksDetailsPath(appState.getSelectedBookById());
          }
        }
      }
    
      @override
      Widget build(BuildContext context) {
        return Navigator(
          key: navigatorKey,
          pages: [
            MaterialPage(
              child: AppShell(appState: appState),
            ),
          ],
          onPopPage: (route, result) {
            if (!route.didPop(result)) {
              return false;
            }
    
            if (appState.selectedBook != null) {
              appState.selectedBook = null;
            }
            notifyListeners();
            return true;
          },
        );
      }
    
      @override
      Future<void> setNewRoutePath(BookRoutePath path) async {
        if (path is BooksListPath) {
          appState.selectedIndex = 0;
          appState.selectedBook = null;
        } else if (path is BooksSettingsPath) {
          appState.selectedIndex = 1;
        } else if (path is BooksDetailsPath && path.id != null) {
          appState.setSelectedBookById(path.id!);
        }
      }
    }
    
    // Routes
    abstract class BookRoutePath {}
    
    class BooksListPath extends BookRoutePath {}
    
    class BooksSettingsPath extends BookRoutePath {}
    
    class BooksDetailsPath extends BookRoutePath {
      final int? id;
    
      BooksDetailsPath(this.id);
    }
    
    // Widget that contains the AdaptiveNavigationScaffold
    class AppShell extends StatefulWidget {
      final BooksAppState appState;
    
      AppShell({
        required this.appState,
      });
    
      @override
      _AppShellState createState() => _AppShellState();
    }
    
    class _AppShellState extends State<AppShell> {
      late InnerRouterDelegate _routerDelegate;
      late ChildBackButtonDispatcher _backButtonDispatcher;
    
      void initState() {
        super.initState();
        _routerDelegate = InnerRouterDelegate(widget.appState);
      }
    
      @override
      void didUpdateWidget(covariant AppShell oldWidget) {
        super.didUpdateWidget(oldWidget);
        _routerDelegate.appState = widget.appState;
      }
    
      @override
      void didChangeDependencies() {
        super.didChangeDependencies();
        // Defer back button dispatching to the child router
        _backButtonDispatcher = Router.of(context).backButtonDispatcher!.createChildBackButtonDispatcher();
      }
    
      @override
      Widget build(BuildContext context) {
        var appState = widget.appState;
    
        // Claim priority, If there are parallel sub router, you will need
        // to pick which one should take priority;
        _backButtonDispatcher.takePriority();
    
        return Scaffold(
          appBar: AppBar(),
          body: Router(
            routerDelegate: _routerDelegate,
            backButtonDispatcher: _backButtonDispatcher,
          ),
          bottomNavigationBar: BottomNavigationBar(
            items: [
              BottomNavigationBarItem(icon: Icon(Icons.home), label: 'Home'),
              BottomNavigationBarItem(icon: Icon(Icons.settings), label: 'Settings'),
            ],
            currentIndex: appState.selectedIndex,
            onTap: (newIndex) {
              appState.selectedIndex = newIndex;
            },
          ),
        );
      }
    }
    
    class InnerRouterDelegate extends RouterDelegate<BookRoutePath>
        with ChangeNotifier, PopNavigatorRouterDelegateMixin<BookRoutePath> {
      final GlobalKey<NavigatorState> navigatorKey = GlobalKey<NavigatorState>();
      BooksAppState get appState => _appState;
      BooksAppState _appState;
      set appState(BooksAppState value) {
        if (value == _appState) {
          return;
        }
        _appState = value;
        notifyListeners();
      }
    
      InnerRouterDelegate(this._appState);
    
      @override
      Widget build(BuildContext context) {
        return Navigator(
          key: navigatorKey,
          pages: [
            if (appState.selectedIndex == 0) ...[
              FadeAnimationPage(
                child: BooksListScreen(
                  books: appState.books,
                  onTapped: _handleBookTapped,
                ),
                key: ValueKey('BooksListPage'),
              ),
              if (appState.selectedBook != null)
                MaterialPage(
                  key: ValueKey(appState.selectedBook),
                  child: BookDetailsScreen(book: appState.selectedBook!),
                ),
            ] else
              FadeAnimationPage(
                child: SettingsScreen(),
                key: ValueKey('SettingsPage'),
              ),
          ],
          onPopPage: (route, result) {
            appState.selectedBook = null;
            notifyListeners();
            return route.didPop(result);
          },
        );
      }
    
      @override
      Future<void> setNewRoutePath(BookRoutePath path) async {
        // This is not required for inner router delegate because it does not
        // parse route
        assert(false);
      }
    
      void _handleBookTapped(Book book) {
        appState.selectedBook = book;
        notifyListeners();
      }
    }
    
    class FadeAnimationPage extends Page {
      final Widget child;
    
      FadeAnimationPage({required LocalKey key, required this.child}) : super(key: key);
    
      Route createRoute(BuildContext context) {
        return PageRouteBuilder(
          settings: this,
          pageBuilder: (context, animation, animation2) {
            var curveTween = CurveTween(curve: Curves.easeIn);
            return FadeTransition(
              opacity: animation.drive(curveTween),
              child: child,
            );
          },
        );
      }
    }
    
    // Screens
    class BooksListScreen extends StatelessWidget {
      final List<Book> books;
      final ValueChanged<Book> onTapped;
    
      BooksListScreen({
        required this.books,
        required this.onTapped,
      });
    
      @override
      Widget build(BuildContext context) {
        return Scaffold(
          body: ListView(
            children: [
              for (var book in books)
                ListTile(
                  title: Text(book.title),
                  subtitle: Text(book.author),
                  onTap: () => onTapped(book),
                )
            ],
          ),
        );
      }
    }
    
    class BookDetailsScreen extends StatelessWidget {
      final Book book;
    
      BookDetailsScreen({
        required this.book,
      });
    
      @override
      Widget build(BuildContext context) {
        return Scaffold(
          body: Padding(
            padding: const EdgeInsets.all(8.0),
            child: Column(
              crossAxisAlignment: CrossAxisAlignment.start,
              children: [
                ElevatedButton(
                  onPressed: () {
                    Navigator.of(context).pop();
                  },
                  child: Text('Back'),
                ),
                Text(book.title, style: Theme.of(context).textTheme.headline6),
                Text(book.author, style: Theme.of(context).textTheme.subtitle1),
              ],
            ),
          ),
        );
      }
    }
    
    class SettingsScreen extends StatelessWidget {
      @override
      Widget build(BuildContext context) {
        return Scaffold(
          body: Center(
            child: Text('Settings screen'),
          ),
        );
      }
    }
    

    【讨论】:

    • 这很好用。谢谢。但这似乎太复杂了。如果您简要了解其背后的逻辑,将不胜感激。特别是,如何在单击不同的底部导航按钮时更改浏览器中的 url。再次感谢。
    猜你喜欢
    • 2021-10-02
    • 2020-07-30
    • 1970-01-01
    • 2020-07-13
    • 1970-01-01
    • 2023-02-25
    • 1970-01-01
    • 1970-01-01
    相关资源
    最近更新 更多