flutter中的响应式布局

2022-09-20 16:46:05 浏览数 (1)

Flutter是一个跨平台的UI框架, 我们能够一次编程就可以手机、PC、web上多端使用。

那么,我们如何做到一次编码就可以适配不同的屏幕呢?总不能只适配手机尺寸,在PC端就可能看起来很丑了,这样用户体验就非常的差了,如下图:

代码语言:javascript复制
                      大屏幕上显示手机版布局

很显然,这不是我们希望看到的结果,这时候就轮到我们的响应式布局(responsive layout)上场了。

在flutter中,我们可以根据UI设计的效果,通过使用不同的技术、widgets和第三方包,轻松的实现响应式

In this article, we'll focus on one very specific type of responsive layout and learn how to create a split view that looks like this on a widescreen:

本文将聚焦一种特殊的响应式布局,并介绍如何在大屏幕和手机上使用如下的布局方式:

大屏幕

手机屏幕使用drawer

就像我们看到的,在不同屏幕尺寸,我们需要不同的布局方式,是我们的界面更加友好。在web开发中我们可以使用css很容易实现这种效果。下面我们就来看看在flutter中是如何实现的吧!

我们将实现如下的简单功能:

  • 点击左上角icon打开(点击返回按钮关闭).
  • 根据手势来关闭或打开drawer.

在手机上我们通过flutter的Flutter Drawer widget实现,而在PC上我们就不需要使用Drawer,直接显示所有菜单即可.

目前我们直接使用flutter提供的MediaQuery and Drawer即可实现,不需要使用任何第三方的包.

学习本文,我们将实现如下几个小目标:

目标 #1: 可复用的 SplitView widget

我们将实现一个能在任何APP使用的自定义**SplitView widget**。所以呢这个 widget API 需要在任何场景下都适用。也就是说,我们需要将菜单和内容作为SplitView的参数,至于菜单和内容具体包含哪些,我们并不关心。

目标 #2: 通过 Riverpod实现页面切换

我们需要通过菜单来切换页面,所以我们使用 Riverpod package来实现全局的应用状态管理,当然我们也可以使用其他的状态管理。

目标 #3: 学会Drawer Navigation

我们将实现 drawer navigation 的效果

Ready? Let's go!

项目实现

我们通过AS或VS Code来创建一个flutter项目吧。

我们先来创建几个简单的页面,以便我们通过 AppMenu widget 来选择这些页面:

drawer navigation

AppMenu的实现如下:

代码语言:javascript复制
// 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类型的map,用来存储菜单对应的页面.

接下来我们在添加一个菜单项的widget: PageListTile widget

代码语言:javascript复制
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 回调通知parent widget菜单被选中。

这里需要注意下,onPressed是非必传参数

下面是两个页面的实现:

代码语言:javascript复制
// 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 :

代码语言:javascript复制
// 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 :

代码语言:javascript复制
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,我们就使用大屏幕布局,否则就使用手机布局。

现在我们将MaterialApp的home参数替换成SplitView,我们将看到如下效果:

Testing the split view

注意: 当屏幕大小改变时SplitView widget 会rebuild. 我们可以看看官方文档对MediaQuery.of:

You can use this function to query the size and orientation of the screen, as well as other media parameters (see MediaQueryData for more examples). When that information changes, your widget will be scheduled to be rebuilt, keeping your widget up-to-date.

但是,现在SplitView还不是我们想要的能够复用:

  • menu widthbreakpoint 是 hard-coded
  • contentmenu widgets 也是 hard-coded

我们进行下优化:

代码语言:javascript复制
// 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 属性 (menu and content), 让 parent widget 决定 SplitView具体显示的内容。并且 breakpointmenuWidth 也是可配置的

接下来,我们更新下MaterialApp的home参数:

代码语言:javascript复制
MaterialApp(
  ...
  home: SplitView(
    menu: AppMenu(),
    content: FirstPage(),
  )
)

下面是大屏幕上的 widget tree图:

Widget tree (省略了SizedBox and Expanded widgets )

Widget tree (手机版) (省略了SizedBox and Expanded widgets )

Riverpod实现页面切换

目前我们的app还不支持页面切换,以及选中标记。

我们先看看之前我们定义的map:

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

现在我们需要使用一个状态变量来记住我们所选的页面:

这个变量必须是全局的,应为 AppMenu 和 root widget (MyApp) 必须能访问到.

现在我们需要一个状态管理工具,有很多第三方包,或者Flutter内置的API,如ValueNotifier也可以实现。

我们这里使用riverpod,在 pubspec.yaml引入:

代码语言:javascript复制
dependencies:
  flutter:
    sdk: flutter
  flutter_riverpod: 1.0.0-dev.6

我们使用 ProviderScope 包裹MyApp widget:

代码语言:javascript复制
void main() {
  runApp(ProviderScope(child: MyApp()));
}

接着在 app_menu.dart中添加 StateProvider :

代码语言:javascript复制
// this is a `StateProvider` so we can change its value
final selectedPageNameProvider = StateProvider<String>((ref) {
  // default value
  return _availablePages.keys.first;
});

这个 provider 能够提供 选中页面 的名字. 并且默认选中 _availablePages中的第一个

这里我们使用 StateProvider即可,因为我们这里没有其他业务逻辑不必使用 StateNotifierProvider.

读取选中的页面

现在我们的 AppMenu widget 如下:

代码语言:javascript复制
// 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

更新选择页面

接下来修改 PageListTile:

代码语言:javascript复制
PageListTile(
  selectedPageName: selectedPageName,
  pageName: pageName,
  onPressed: () => _selectPage(context, ref, pageName),
)

_selectPage 的实现如下:

代码语言:javascript复制
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;
  }
}

更新内容页面

现在需要在点击菜单后,页面同步更新。

我们需要定义一个新的provider:

代码语言:javascript复制
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 map中返回 WidgetBuilder .

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

现在修改下 MyApp:

代码语言:javascript复制
// 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

现在我们看看手机端,会发现缺少了Icon

flutter不是应该自动添加图标的吗?

我们看看我们的widget tree

SplitView 中添加了Scaffold但是没有 AppBar .

虽然在FirstPageScaffoldAppBar 但是没有 Drawer.

先有鸡还是先有蛋呢?

下面让我们来解决这个问题吧, FirstPage:

代码语言:javascript复制
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参数来显示Icon,并使用onPressed回调打开祖先 Scaffold的drawer。

但是,不能保证祖先 Scaffold存在(实际上我们在拆分视图模式下没有祖先)。所以我们可以使用Scaffold.maybeOf(context)一些防御性代码来解决这个问题。

通过这些更改,我们可以在移动设备上运行该应用程序,查看菜单Icon,并使用它打开drawer。

但是我们不想为每个页面都复制粘贴同样的代码,我们现在进行下重构,创建一个通用的 PageScaffold widget:

代码语言:javascript复制
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,
    );
  }
}

现在可以简化我们的 FirstPage and SecondPage widgets:

代码语言:javascript复制
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

现在只剩下关闭drawer了!

切换页面时关闭drawer

先看看 AppMenu_selectPage方法:

代码语言:javascript复制
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;
  }
}

现在需要加上关闭drawer的方法

代码语言:javascript复制
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();
    }
  }
}

好了,现在我们的APP都可以正常工作了!

关于flutter中的一些API

flutter实现响应式布局,可能需要的API,大家可以自行查看

  • MediaQuery
  • LayoutBuilder
  • OrientationBuilder
  • Expanded and Flexible
  • FractionallySizedBox
  • FittedBox
  • AspectRatio

另外,一些第三方库也可以参考:

  • layout
  • responsive_builder

0 人点赞