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
的实现如下:
// 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
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
:
// 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 :
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 width 和 breakpoint 是 hard-coded
- content 和 menu 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
具体显示的内容。并且 breakpoint
和 menuWidth
也是可配置的
接下来,我们更新下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
引入:
dependencies:
flutter:
sdk: flutter
flutter_riverpod: 1.0.0-dev.6
我们使用 ProviderScope
包裹MyApp widget:
void main() {
runApp(ProviderScope(child: MyApp()));
}
接着在 app_menu.dart
中添加 StateProvider
:
// 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 如下:
// 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
:
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;
}
}
更新内容页面
现在需要在点击菜单后,页面同步更新。
我们需要定义一个新的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
:
// 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
.
虽然在FirstPage
有 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
参数来显示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:
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
方法:
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