Flutter中的响应式布局:分割视图和抽屉式导航

580 阅读14分钟

Flutter作为一个多平台的UI工具包,允许你用一个代码库在移动、桌面和网络上构建应用。

然而,将一个移动应用的布局 "拉伸 "到大屏幕上,永远不会带来良好的用户体验。

宽屏上的简单垂直布局

相反,使用响应式布局,充分利用可用的屏幕空间会更好。

根据您的预期视觉层次,您可以混合和匹配许多不同的技术部件,使Flutter应用程序响应。

在这篇文章中,我们将专注于一种非常特殊的响应式布局,并学习如何在宽屏上创建一个看起来像这样的分割视图

分割视图示例应用程序(宽屏)

而在手机上则是这样的。

同样的应用程序在手机上有抽屉式导航

正如我们将看到的,这可以通过在窗口宽度超过某个阈值时改变你的应用程序的顶层布局来实现,使用一个所谓的布局断点

在移动端,所需的布局可以通过一个导航抽屉来实现,其中包含一个我们可以用来在不同页面之间切换的菜单。

默认情况下,我们应该能够。

  • 用左上角的汉堡包图标打开抽屉(用后退按钮关闭它)。
  • 用屏幕左侧边缘的交互式拖动手势来显示或取消它。

所有这些功能都内置在FlutterDrawer小组件内,我们将在移动端使用它。

但在大屏幕上,我们可以很容易地把菜单和内容并排放在一起,所以我们不需要一个Drawer

我们不会使用任何第三方软件包,因为没有必要。相反,我们将依靠内置的Flutter小工具,如MediaQueryDrawer

那么让我们来看看本教程的主要目标是什么。

目标#1:可重复使用的SplitView小部件

我们想实现一个自定义的SplitView widget,可以在任何应用程序中使用。因此,widget的API不应该对以下情况做出任何假设。

  • 应用程序中存在哪些页面
  • 页面选择菜单里有什么

换句话说,SplitView widget应该是可重复使用的,并将菜单和内容widget作为参数

目标#2:用Riverpod进行页面选择

我们想启用页面选择,通过从菜单中选择页面来切换。

为此,我们将引入一些全局应用状态并使用Riverpod包

目标#3:移动端的抽屉式导航

我们想启用抽屉式导航,这样内容页总是全屏的,我们可以在侧面打开菜单。

在这一过程中,我们会发现一些有趣的注意事项,即使用带有嵌套Scaffolds的汉堡包菜单

准备好了吗?开始吧!

初始项目

你可以从GitHub上的这个页面下载入门项目,并选择starter-project 分支。

让我们从一个简单的应用程序开始,它包含几个可供选择的页面,以及一个用于选择这些页面的AppMenu widget。

带有抽屉式导航的应用实例

这就是AppMenu 的样子。

// app_menu.dart
import 'package:flutter/material.dart';
import 'package:split_view_example_flutter/first_page.dart';
import 'package:split_view_example_flutter/second_page.dart';

// a map of ("page name", WidgetBuilder) pairs
final _availablePages = <String, WidgetBuilder>{
  'First Page': (_) => FirstPage(),
  'Second Page': (_) => SecondPage(),
};

class AppMenu extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text('Menu')),
      body: ListView(
        // Note: use ListView.builder if there are many items
        children: <Widget>[
          // iterate through the keys to get the page names
          for (var pageName in _availablePages.keys)
            PageListTile(
              pageName: pageName,
            ),
        ],
      ),
    );
  }
}

_availablePages 变量只是一个WidgetBuilder的地图,我们将根据选择的页面来建立FirstPageSecondPage

我们也有一个PageListTile widget,我们可以用它来表示列表中的每个项目。

class PageListTile extends StatelessWidget {
  const PageListTile({
    Key? key,
    this.selectedPageName,
    required this.pageName,
    this.onPressed,
  }) : super(key: key);
  final String? selectedPageName;
  final String pageName;
  final VoidCallback? onPressed;
  @override
  Widget build(BuildContext context) {
    return ListTile(
      // show a check icon if the page is currently selected
      // note: we use Opacity to ensure that all tiles have a leading widget
      // and all the titles are left-aligned
      leading: Opacity(
        opacity: selectedPageName == pageName ? 1.0 : 0.0,
        child: Icon(Icons.check),
      ),
      title: Text(pageName),
      onTap: onPressed,
    );
  }
}

这使用了一个ListTile widget和一个onPressed callback,我们可以用它来通知父widget当瓷砖被选中。

注意,onPressed 被声明为一个可忽略的 VoidCallback? 属性。如果你不熟悉这个语法,请看我的Dart Null Safety终极指南

这就是这两个内容页的实现方式。

// first_page.dart

// Just a simple placeholder widget page
// (in a real app you'd have something more interesting)
class FirstPage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text('First Page')),
      body: Center(
        child: Text('First Page', style: Theme.of(context).textTheme.headline4),
      ),
    );
  }
}
// SecondPage is identical, apart from the Text values

main.dart ,我们所做的就是返回FirstPage ,作为MaterialApp 的主页。

// main.dart
void main() {
  runApp(MyApp());
}

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      debugShowCheckedModeBanner: false,
      theme: ThemeData(
        primarySwatch: Colors.indigo,
      ),
      // just return `FirstPage` for now. We'll change this later
      home: FirstPage(),
    );
  }
}

实现SplitView

就目前而言,这个应用程序将FirstPage ,作为一个全屏的小部件,还没有任何页面选择代码。

因此,让我们来研究一下如何构建这个分屏视图布局。

分割视图示例应用程序(宽屏)

让我们创建一个简单的SplitView widget,有一个单一的布局断点。

// split_view.dart
import 'package:flutter/material.dart';
import 'package:split_view_example_flutter/app_menu.dart';
import 'package:split_view_example_flutter/first_page.dart';

class SplitView extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    final screenWidth = MediaQuery.of(context).size.width;
    const breakpoint = 600.0;
    if (screenWidth >= breakpoint) {
      // widescreen: menu on the left, content on the right
      return Row(
        children: [
          // use SizedBox to constrain the AppMenu to a fixed width
          SizedBox(
            width: 240,
            // TODO: make this configurable
            child: AppMenu(),
          ),
          // vertical black line as separator
          Container(width: 0.5, color: Colors.black),
          // use Expanded to take up the remaining horizontal space
          Expanded(
            // TODO: make this configurable
            child: FirstPage(),
          ),
        ],
      );
    } else {
      // narrow screen: show content, menu inside drawer
      return Scaffold(
        body: FirstPage(),
        // use SizedBox to contrain the AppMenu to a fixed width
        drawer: SizedBox(
          width: 240,
          child: Drawer(
            child: AppMenu(),
          ),
        ),
      );
    }
  }
}

其工作原理是将从MediaQuery 获得的屏幕宽度与一个常数断点进行比较。

  • 如果屏幕宽度大于600点,我们返回一个Row ,左边是AppMenu ,右边是FirstPage 的布局。
  • 否则,我们返回一个Scaffold ,以FirstPage 为主体,Drawer(child: AppMenu()) 为抽屉。

在这两种情况下,我们用一个固定宽度(240点)的SizedBox 来包裹AppMenu

如果你想让菜单的宽度与分割视图模式下的屏幕宽度成正比,请将SizedBox ,用一个 Expanded小组件,并调整两个Expanded 小组件的flex 值。


如果我们传递一个SplitView() 作为MaterialApp() 的主页,并在桌面上运行该应用程序,我们已经可以调整窗口的大小,并看到当我们越过600点的断点值时,顶层的布局发生了变化。

测试分割视图注意

当窗口大小改变时, SplitView widget会重新构建。这是因为我们在build() 方法中调用MediaQuery.of(context) 。来自MediaQuery.of的文档:

你可以使用这个函数来查询屏幕的大小和方向,以及其他媒体参数(更多例子见MediaQueryData)。当这些信息发生变化时,你的小组件将被计划重建,使你的小组件保持最新状态。

然而,这个小组件根本就不能重用,因为:

  • 菜单宽度断点都是硬编码的
  • 内容菜单小组件本身都是硬编码的我们

可以做得更好:

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

class SplitView extends StatelessWidget {
  const SplitView({
    Key? key,
    // menu and content are now configurable
    required this.menu,
    required this.content,
    // these values are now configurable with sensible default values
    this.breakpoint = 600,
    this.menuWidth = 240,
  }) : super(key: key);
  final Widget menu;
  final Widget content;
  final double breakpoint;
  final double menuWidth;

  @override
  Widget build(BuildContext context) {
    final screenWidth = MediaQuery.of(context).size.width;
    if (screenWidth >= breakpoint) {
      // widescreen: menu on the left, content on the right
      return Row(
        children: [
          SizedBox(
            width: menuWidth,
            child: menu,
          ),
          Container(width: 0.5, color: Colors.black),
          Expanded(child: content),
        ],
      );
    } else {
      // narrow screen: show content, menu inside drawer
      return Scaffold(
        body: content,
        drawer: SizedBox(
          width: menuWidth,
          child: Drawer(
            child: menu,
          ),
        ),
      );
    }
  }
}

通过引入两个Widget 属性(menucontent ),我们让父小组件决定什么应该放在SplitView 里面。而且breakpointmenuWidth 现在是可配置的属性,具有合理的默认值

home 因此,我们可以

更好地

更新MaterialApp

MaterialApp(
  ...
  home: SplitView(
    menu: AppMenu(),
    content: FirstPage(),
  )
)

的参数。

使用Riverpod的页面选择我们的

注意,在任何列表项上都没有复选标记。如果我们点击菜单上的 "第二页",什么也不会发生,因为我们还没有一个关于当前所选页面的概念。

那么,我们怎样才能实现页面选择呢?

让我们回顾一下,我们已经定义了这个可用页面的地图:

// a map of ("page name", WidgetBuilder) pairs
final _availablePages = <String, WidgetBuilder>{
  'First Page': (_) => FirstPage(),
  'Second Page': (_) => SecondPage(),
};

这个地图的键代表我们在菜单上显示的页面名称

所以我们可以为所选的页面名称定义一个状态变量,并使用它来:

  • AppMenu 内所选的页面旁边显示一个复选标记。
  • 返回与所选页面相对应的部件(FirstPageSecondPage ),作为MaterialApp 的主页。

这个变量代表一些全局应用程序的状态,因为AppMenu 和根部件(MyApp )都需要访问它。

那么,我们如何管理这个全局状态,以及widget如何能够访问它?

任何现有的状态管理包都可以,或者我们甚至可以使用一些内置的Flutter APIs,比如ValueNotifier

但在本教程中,我们将使用Riverpod,因为它可以帮助我们轻松处理状态管理依赖注入

关于Riverpod的更多信息,请看我的Riverpod基本指南

为了使其运行,我们可以将最新版本添加到我们的pubspec.yaml

dependencies:
  flutter:
    sdk: flutter
  flutter_riverpod: 1.0.0-dev.6

而我们需要添加一个父ProviderScope 到我们的根部件:

void main() {
  runApp(ProviderScope(child: MyApp()));
}

然后,让我们添加一个StateProvider 到我们的app_menu.dart

// this is a `StateProvider` so we can change its value
final selectedPageNameProvider = StateProvider<String>((ref) {
  // default value
  return _availablePages.keys.first;
});

顾名思义,这个提供者将给我们访问选定的页面名称。默认情况下,它返回_availablePages 中的第一个键。

我们把selectedPageNameProvider 声明为一个StateProvider ,这样我们就可以改变它的值。在这种情况下,StateNotifierProvider 是没有必要的,因为这个变量没有业务逻辑或后备存储。

读取选定的页面让我们

更新AppMenu widget,使用这个:

// 1. extend from ConsumerWidget
class AppMenu extends ConsumerWidget {
  // 2. Add a WidgetRef argument
  @override
  Widget build(BuildContext context, WidgetRef ref) {
    // 3. watch the provider's state
    final selectedPageName = ref.watch(selectedPageNameProvider.state).state;
    return Scaffold(
      appBar: AppBar(title: Text('Menu')),
      body: ListView(
        children: <Widget>[
          for (var pageName in _availablePages.keys)
            PageListTile(
              // 4. pass the selectedPageName as an argument
              selectedPageName: selectedPageName,
              pageName: pageName,
            ),
        ],
      ),
    );
  }
}

build() 方法中,我们使用ref.watch 来获取选定的页面名称,并将其作为参数传递给PageListTile widget。

有了这个改变,我们现在可以得到列表中第一页旁边的复选标记:

所选页面接下来

,让我们通过给我们的PageListTile 添加一个onPressed 回调处理程序来启用页面选择:

PageListTile(
  selectedPageName: selectedPageName,
  pageName: pageName,
  onPressed: () => _selectPage(context, ref, pageName),
)

我们可以这样定义_selectPage 方法:

void _selectPage(BuildContext context, WidgetRef ref, String pageName) {
  // only change the state if we have selected a different page
  if (ref.read(selectedPageNameProvider.state).state != pageName) {
    ref.read(selectedPageNameProvider.state).state = pageName;
  }
}

有了这个改变,我们现在可以在第一页和第二页之间切换,AppMenu widget会重建,因为我们正在使用ref.watch观察 build() 方法


状态变化。因此,复选标记也被更新了:

菜单选择:复选标记在选择页面时更新更新

内容页没有变化

,右侧的内容页仍然没有变化,因为我们仍然将一个硬编码的FirstPage() 传递给MaterialApp 内的SplitView

为了解决这个问题,让我们定义一个新的提供者:

final selectedPageBuilderProvider = Provider<WidgetBuilder>((ref) {
  // watch for state changes inside selectedPageNameProvider
  final selectedPageKey = ref.watch(selectedPageNameProvider.state).state;
  // return the WidgetBuilder using the key as index
  return _availablePages[selectedPageKey]!;
});

这个提供者非常整洁,因为它观察selectedPageNameProvider 的变化,并从_availablePages 地图返回相应的WidgetBuilder

请注意,selectedPageBuilderProvider 只是一个简单的Provider (不是StateProvider )。但每当selectedPageNameProvider 的状态发生变化时,它仍然返回一个新的值。

让我们在MyApp 内使用它:

// 1. extend from ConsumerWidget
class MyApp extends ConsumerWidget {
  // 2. add a WidgetRef argument
  @override
  Widget build(BuildContext context, WidgetRef ref) {
    // 3. watch selectedPageBuilderProvider
    final selectedPageBuilder = ref.watch(selectedPageBuilderProvider);
    return MaterialApp(
      ...
      home: SplitView(
        menu: AppMenu(),
        // 4. use the WidgetBuilder
        content: selectedPageBuilder(context),
      ),
    );
  }
}

如果我们现在在分割视图模式下测试该应用程序,一切都能正常工作:


当选择一个新的页面时,内容页就会被更新Drawer

Navigation on Mobile

另一方面,在移动端我们还有一些工作要做。

事实上,我们已经可以通过从屏幕左侧轻扫打开抽屉了:

抽屉--轻扫打开但是汉堡包菜单图标在哪里?Flutter不是应该自动为我们添加这个吗?

毕竟,我们已经在SplitView 内的Scaffold 添加了一个Drawer

移动端的小工具树(为了简单起见,省略了SizedBoxExpanded小工具)

但是SplitView 内的Scaffold 并没有一个AppBar 来显示汉堡包图标

FirstPage (或SecondPage )内的Scaffold 有一个AppBar ,但没有一个Drawer

典型的鸡和蛋的问题!🐣

我们如何解决这个问题?

好吧,我们可以在FirstPage 上解决这个问题:

class FirstPage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    // 1. look for an ancestor Scaffold
    final ancestorScaffold = Scaffold.maybeOf(context);
    // 2. check if it has a drawer
    final hasDrawer = ancestorScaffold != null && ancestorScaffold.hasDrawer;
    return Scaffold(
      appBar: AppBar(
        // 3. add a non-null leading argument if we have a drawer
        leading: hasDrawer
            ? IconButton(
                icon: Icon(Icons.menu),
                // 4. open the drawer if we have one
                onPressed:
                    hasDrawer ? () => ancestorScaffold!.openDrawer() : null,
              )
            : null,
        title: Text('First Page'),
      ),
      body: Center(
        child: Text('First Page', style: Theme.of(context).textTheme.headline4),
      ),
    );
  }
}

我们可以通过添加一个leading 参数来显示汉堡包的图标,并使用onPressed 回调来打开祖先 Scaffold 的抽屉。

然而,并不能保证一个祖先 Scaffold 甚至存在(事实上我们在分割视图模式下并没有一个祖先)。所以我们可以使用Scaffold.maybeOf(context) ,并加上一些防御性的代码来说明这一点。

有了这些变化,我们可以在移动端运行应用程序,看到汉堡包菜单,并使用它来打开抽屉。

汉堡包菜单图标但我们不想为每一个新添加的页面复制粘贴所有这些新代码。

相反,让我们创建一个可重复使用的PageScaffold小组件:

class PageScaffold extends StatelessWidget {
  const PageScaffold({
    Key? key,
    required this.title,
    this.actions = const [],
    this.body,
    this.floatingActionButton,
  }) : super(key: key);
  final String title;
  final List<Widget> actions;
  final Widget? body;
  final Widget? floatingActionButton;

  @override
  Widget build(BuildContext context) {
    // 1. look for an ancestor Scaffold
    final ancestorScaffold = Scaffold.maybeOf(context);
    // 2. check if it has a drawer
    final hasDrawer = ancestorScaffold != null && ancestorScaffold.hasDrawer;
    return Scaffold(
      appBar: AppBar(
        // 3. add a non-null leading argument if we have a drawer
        leading: hasDrawer
            ? IconButton(
                icon: Icon(Icons.menu),
                // 4. open the drawer if we have one
                onPressed:
                    hasDrawer ? () => ancestorScaffold!.openDrawer() : null,
              )
            : null,
        title: Text(title),
        actions: actions,
      ),
      body: body,
      floatingActionButton: floatingActionButton,
    );
  }
}

现在我们有了这个,我们可以简化我们的FirstPageSecondPage 小组件:

import 'package:flutter/material.dart';
import 'package:split_view_example_flutter/page_scaffold.dart';

class FirstPage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return PageScaffold(
      title: 'First Page',
      body: Center(
        child: Text('First Page', style: Theme.of(context).textTheme.headline4),
      ),
    );
  }
}
// same for SecondPage

好多。

我们只剩下一件事要做了!

当选择了一个新的页面时关闭抽屉让

我们回顾一下我们的AppMenu 里面的_selectPage 方法:

void _selectPage(BuildContext context, WidgetRef ref, String pageName) {
  // only change the state if we have selected a different page
  if (ref.read(selectedPageNameProvider.state).state != pageName) {
    ref.read(selectedPageNameProvider.state).state = pageName;
  }
}

这确保了当我们选择一个新的页面时,内容页被更新。

但它并没有自动关闭抽屉。让我们来解决这个问题:

void _selectPage(BuildContext context, WidgetRef ref, String pageName) {
  // only change the state if we have selected a different page
  if (ref.read(selectedPageNameProvider.state).state != pageName) {
    ref.read(selectedPageNameProvider.state).state = pageName;
    // dismiss the drawer of the ancestor Scaffold if we have one
    if (Scaffold.maybeOf(context)?.hasDrawer ?? false) {
      Navigator.of(context).pop();
    }
  }
}

全部完成!如果我们现在试试这个应用程序,所有基于抽屉的导航都能像预期那样工作。

Drawer page selectionsponsorCode

总结分页

视图是一种有用的用户体验模式,它能很好地利用较大外形尺寸上的可用屏幕空间。

但是我们需要确保基于抽屉的导航在移动端仍能正常工作,并在使用嵌套的Scaffolds时注意细节。

我们创建的SplitViewPageScaffold widgets是可移植的,你应该能够在你的项目中 "按原样 "使用它们,或根据需要对它们进行调整。

你可以在GitHub上找到本教程的完整项目。

如果你最终在你的项目中使用它,请在Twitter上分享你的反馈。

接下来该怎么做?

添加一个具有单一布局断点的分割视图是使你的应用程序具有响应性的一个好步骤。

而除了我们所涉及的,Flutter还提供了许多有用的响应式布局部件,可以帮助你:

布局更复杂的应用程序,可以考虑添加多个布局断点,甚至在大屏幕上有两个以上的水平部分:

Layout with three horizontal sections.请看Dribbble的设计灵感。

一旦你处理复杂的响应式布局,那么建议你寻找能够为你做一些繁重工作的软件包。这里有几个很好的软件包: