前言:
-
最近在项目里做了一版 Flutter 折叠屏适配,一开始以为只是判断一下屏幕宽度,然后把页面拆成左右两栏就可以了。真正改下来才发现,折叠屏适配不是简单地把一个页面拉宽,也不是把原来的 Flutter 页面直接塞进一个 Row 里。
-
对一个已经按照手机单屏设计的 App 来说,折叠屏适配最核心的问题其实是:单屏体验要保留,双屏空间要利用,原来的路由、尺寸适配、页面状态和返回逻辑还不能乱。
-
这篇文章就结合当前这个 Demo,聊一聊在 Flutter 项目里如何做折叠屏适配。文章不单独讲某一个 API,而是从项目落地角度说清楚:用了哪些工具、怎么判断单屏和双屏、双屏时页面如何组织、右侧屏幕展示什么、适配过程中遇到了哪些问题,以及后续做折叠屏适配时建议遵守的一些标准。
正文:
这个 Demo 的折叠屏适配,主要从下面几个维度来理解:
-
Flutter 折叠屏适配的整体思路
-
当前项目用了哪些工具和能力
-
单屏和双屏如何判断
-
双屏时左右两侧怎么布局
-
左侧如何保持原来的单屏样式
-
右侧为什么适合展示详情页
-
双屏时如何拦截路由,把详情页打开到右侧
-
适配过程中遇到的难点和坑
-
后续继续完善折叠屏适配时的建议
-
折叠屏适配的基本思想
普通手机页面通常是单栈结构。
也就是说:
-
一个页面占满整个屏幕
-
点击列表 item 后 push 到详情页
-
返回时 pop 回列表页
-
页面宽度基本按手机设计稿来适配
折叠屏展开后,用户看到的是更大的横向空间。如果仍然沿用普通手机逻辑,页面只是被拉宽,体验反而可能变差。
更适合折叠屏的方式是:
-
左侧保留原来的单屏 App 浏览体验
-
右侧承载详情、预览、阅读器或辅助信息
-
单屏时仍然按原来的路由 push
-
双屏时点击列表 item 不再跳走,而是在右侧打开详情
-
右侧返回时只清空右侧,不影响左侧列表状态
所以这次适配并不是简单做响应式布局,而是做了一套“单屏保留原交互,双屏升级为列表 + 详情”的结构。
可以把它理解成:
-
单屏:手机模式
-
双屏:主从模式
-
左屏:主列表、Tab、原 App
-
右屏:详情页、个人页、资讯详情
-
当前项目使用的工具和能力
这次适配没有引入专门的折叠屏三方库,主要使用 Flutter 自带能力和项目里已有的适配工具。
项目里用到的核心能力有:
MediaQuery.of(context).displayFeatures
它可以读取系统上报的显示特征,比如折叠屏的铰链、折痕、挖孔等信息。
LayoutBuilder
它用于读取当前布局约束,判断当前可用宽度,并决定是否切换成双栏布局。
DisplayFeature
它是 Flutter 对折叠屏显示特征的描述。当前项目里主要关心竖向分割特征,用它判断是否存在左右两个显示区域。
flutter_screenutil
项目原本大量使用了 1.sw、10.w、20.h 这类写法,所以双屏适配时必须处理 ScreenUtil 的尺寸来源问题。
InheritedNotifier + ChangeNotifier
这次用它们实现了右侧面板的状态控制。左侧点击详情入口时,不直接 push 页面,而是更新右侧面板状态。
NavigatorUtils
项目原本的页面跳转都集中在 NavigatorUtils.push。这对折叠屏适配很有帮助,因为我们可以在这里统一拦截详情路由,而不是去每个页面里改 onTap。
最终新增的几个核心文件是:
lib/base/foldable_app_layout.dart
lib/base/foldable_pane_controller.dart
lib/base/single_pane_scope.dart
lib/base/pane_divider.dart
lib/base/foldable_test_panel.dart
其中最核心的是 FoldableAppLayout。它是整个 App 的折叠屏外壳。
-
全局接入方式
折叠屏适配放在了 MaterialApp.builder 里:
builder: (context, widget) {
return MediaQuery(
data: MediaQuery.of(
context,
).copyWith(textScaler: TextScaler.linear(1.0)),
child: FlutterEasyLoading(
child: FoldableAppLayout(child: widget ?? const SizedBox.shrink()),
),
);
}
这样做的好处是:
-
不需要每个页面单独包一层
-
App 内所有页面都可以被统一放进折叠屏布局
-
单屏时直接返回原页面,不影响原逻辑
-
双屏时统一切成左右两栏
同时项目里放开了横屏方向:
await SystemChrome.setPreferredOrientations([
DeviceOrientation.portraitUp,
DeviceOrientation.portraitDown,
DeviceOrientation.landscapeLeft,
DeviceOrientation.landscapeRight,
]);
这个点很重要。
如果 App 仍然强制竖屏,很多折叠屏展开后的横向双栏形态根本触发不了。折叠屏适配不是只写布局代码,还要允许系统进入适合双栏展示的方向。
-
如何判断当前是单屏还是双屏
当前项目使用了两层判断。
第一层是系统级折叠特征:
final DisplayFeature? hinge = _findVerticalHinge(
mediaQuery.displayFeatures,
screenSize,
);
如果系统上报了竖向铰链或折痕,并且它位于屏幕中间区域,就认为当前可以进入双屏模式。
判断逻辑大致是:
final bool splitsVertically =
bounds.top <= 0 &&
bounds.bottom >= screenSize.height &&
bounds.height > bounds.width;
final bool isBetweenPanes =
bounds.left > 0 && bounds.right < screenSize.width;
这里重点判断两件事:
-
这个显示特征是否从顶部贯穿到底部
-
它是否位于屏幕左右两侧之间
如果满足,就认为它是一个竖向分割,适合左右双栏。
第二层是宽度兜底判断:
static const double _dualModeBreakpoint = 600;
final bool shouldUseDualPane =
hinge != null || availableWidth >= _dualModeBreakpoint;
为什么还需要宽度兜底?
因为不是所有设备或模拟器都会稳定上报 displayFeatures。有些折叠屏、平板、桌面窗口,可能没有铰链信息,但宽度已经足够展示双栏。
所以项目里采用了更稳的策略:
-
如果系统上报了竖向折叠特征,进入双栏
-
如果没有上报,但可用宽度大于等于 600,也进入双栏
-
如果左右栏空间不足,退回单屏
左右栏还有最小宽度保护:
static const double _minPhonePaneWidth = 320;
static const double _minRightPaneWidth = 240;
如果左侧低于 320,或者右侧低于 240,就不强行展示双栏。
这一步是为了避免某些窗口尺寸尴尬时,双栏反而把两个页面都挤坏。
-
双屏时左右两侧怎么布局
双屏时,整体结构是一个 Row:
Row(
children: [
leftPane,
PaneDivider(width: math.max(dividerWidth, 1)),
Expanded(child: rightPane),
],
)
左侧宽度优先使用铰链左边区域:
final double leftPaneWidth = hinge?.bounds.left ?? availableWidth / 2;
如果没有铰链,就简单按屏幕一半切分。
右侧宽度则是:
final double rightPaneWidth =
availableWidth - leftPaneWidth - dividerWidth;
中间的 PaneDivider 用于模拟或展示左右两侧之间的分割线。如果设备有实体铰链,就使用铰链宽度;如果没有,就至少给 1px 的分割线。
-
左侧为什么不能直接放原页面
一开始最容易想到的写法是:
Row(
children: [
SizedBox(width: 375, child: child),
Expanded(child: rightPane),
],
)
但这个项目里不能这么简单处理。
原因是项目大量使用了 flutter_screenutil:
width: 1.sw
padding: EdgeInsets.all(10.w)
height: 100.h
1.sw 依赖的是 ScreenUtil 当前保存的屏幕宽度。如果 App 在外层已经按整块双屏宽度初始化过 ScreenUtil,那么左侧虽然只有 375 宽,但内部页面拿到的 1.sw 可能仍然是整块屏幕宽度。
结果就是:
-
左侧页面横向撑出去了
-
Grid 或 Banner 宽度不对
-
Row 出现 overflow
-
双屏右侧可能被左侧内容覆盖
所以项目里新增了 SinglePaneScope:
class SinglePaneScope extends StatelessWidget {
final double width;
final double height;
final Widget child;
const SinglePaneScope({
super.key,
required this.width,
required this.height,
required this.child,
});
@override
Widget build(BuildContext context) {
final MediaQueryData mediaQuery = MediaQuery.of(context);
final MediaQueryData paneMediaQuery = mediaQuery.copyWith(
size: Size(width, height),
);
ScreenUtil.configure(
data: paneMediaQuery,
designSize: const Size(375, 812),
minTextAdapt: true,
splitScreenMode: true,
);
return MediaQuery(data: paneMediaQuery, child: child);
}
}
它做了两件事:
-
把左侧子树的
MediaQuery.size改成左栏尺寸 -
手动调用
ScreenUtil.configure,让1.sw按左栏宽度计算
这里没有继续嵌套 ScreenUtilInit,是因为 ScreenUtilInit 内部会读取真实 View 尺寸,不一定使用我们传入的左栏 MediaQuery。对于这种“同一个 App 内局部模拟单屏尺寸”的场景,手动 ScreenUtil.configure 更直接。
左侧外面还加了:
ClipRect(
child: SinglePaneScope(...)
)
这是为了防止已有页面中某些固定宽度或绘制内容溢出到右侧。
-
右侧为什么展示详情页
这个项目的页面结构很适合做“左列表,右详情”。
左侧主要有几个入口:
-
MessageModulePage:资讯列表 -
CartoonPage:漫画宫格列表 -
SignalsStaggeredGridPage:瀑布流列表
这些页面点击后会进入:
-
InformationDetailPage -
NovelDetailPage -
ProfilePage
所以右侧最适合展示详情页,而不是再放一个新的列表。
这样双屏体验会更自然:
-
左侧继续浏览列表
-
右侧查看当前选中的详情
-
列表滚动位置不会丢
-
用户可以快速切换不同 item 预览
-
不需要频繁进入和退出页面
当前右侧支持的路由包括:
supportedRoutes: const {
HomeRouter.messageDetailPage,
HomeRouter.novelDetailPage,
HomeRouter.profilePage,
}
右侧内容根据路由切换:
switch (_paneController.routePath) {
case HomeRouter.messageDetailPage:
return InformationDetailPage(entityId: entityId);
case HomeRouter.novelDetailPage:
return NovelDetailPage(imageUrl: imageUrl);
case HomeRouter.profilePage:
return ProfilePage(imageUrl: imageUrl);
default:
return const FoldableTestPanel();
}
默认情况下,右侧仍然展示一个占位测试面板。用户从左侧点击详情后,右侧才切换到真实页面。
-
双屏时如何拦截路由
项目原本通过 NavigatorUtils.push 统一跳转:
NavigatorUtils.push(
context,
HomeRouter.novelDetailPage,
arguments: {"imageUrl": items[index].image},
);
这对折叠屏适配非常关键。
因为我们不需要去每一个页面里判断单双屏,只要在 NavigatorUtils.push 里统一处理即可。
当前逻辑是:
final FoldablePaneController? paneController =
FoldablePaneScope.maybeOf(context) ??
FoldablePaneController.activeController;
if (paneController?.openRoute(path, arguments: arguments) ?? false) {
return Future.value();
}
也就是说:
-
如果当前是双屏
-
并且这个路由支持在右侧展示
-
就不再执行
Routes.router.navigateTo -
而是把路由和参数交给右侧面板控制器
如果不满足条件,就走原来的跳转逻辑:
return Routes.router.navigateTo(...);
这样单屏逻辑完全不受影响。
这里还做了一个全局活动控制器兜底:
FoldablePaneController.activeController
原因是实际项目里,不是每个点击回调拿到的 BuildContext 都一定能读到 InheritedWidget。如果只依赖:
FoldablePaneScope.maybeOf(context)
可能会出现左侧点击后右侧没有反应,仍然显示测试页。
所以当前策略是:
-
优先从当前 context 读取
FoldablePaneScope -
读不到时使用当前全局活动的
FoldablePaneController
这可以让路由拦截更稳定。
-
右侧返回如何处理
双屏时,右侧详情页的返回行为不能直接 pop 整个 App。
如果用户在右侧详情页点返回,更合理的行为是:
-
清空右侧详情
-
回到右侧默认占位面板
-
左侧列表保持不变
所以项目里加了一个 FoldableRightPaneScope:
class FoldableRightPaneScope extends InheritedWidget {
const FoldableRightPaneScope({super.key, required super.child});
}
右侧内容外层会包这个 scope。
然后在 NavigatorUtils.pop 里判断:
if (FoldableRightPaneScope.maybeOf(context)) {
final FoldablePaneController? paneController =
FoldablePaneScope.maybeOf(context) ??
FoldablePaneController.activeController;
paneController?.clear();
return;
}
Navigator.pop(context, result);
这样右侧详情页里的返回按钮,实际执行的是清空右侧,而不是影响左侧页面栈。
-
适配过程中遇到的难点
-
难点1:折叠屏不是单纯的大屏
大屏适配一般关注“宽了以后怎么排版”,而折叠屏还要关注“屏幕中间是否有铰链或折痕”。
有些设备展开后看起来是一个大屏,但中间存在不可交互或不适合展示内容的区域。这个区域不能简单忽略。
所以适配时不能只写:
if (width > 600) {
return Row(...);
}
还要结合:
MediaQuery.displayFeatures
去判断是否存在竖向分割。
当然,实际设备上 displayFeatures 并不总是稳定上报,所以还需要宽度兜底。
-
难点2:
ScreenUtil是全局状态
这个项目最大的问题之一是 flutter_screenutil。
它很好用,但它的尺寸配置是全局的。双屏时左侧想保持手机宽度,右侧又要使用自己的宽度,这就会出现冲突。
如果不处理,左侧页面里的:
1.sw
可能拿到的是整块双屏宽度。
解决方式是给左侧创建 SinglePaneScope,手动配置左侧尺寸。
这个问题也提醒我们:做折叠屏适配时,项目里的尺寸体系必须先梳理清楚。如果全项目都依赖一个全局屏幕宽度,双栏布局就很容易出现越界。
-
难点3:右侧详情页不能破坏原来的路由
单屏时,用户点击漫画必须正常跳到 NovelDetailPage。
双屏时,用户点击漫画应该在右侧打开 NovelDetailPage。
如果在每个页面里写:
if (isDualPane) {
openRightPane();
} else {
Navigator.push();
}
代码会很快变散。
所以当前选择在 NavigatorUtils.push 统一拦截。这个方案的好处是:
-
页面层不用关心折叠屏
-
原来的 onTap 基本不用改
-
单屏和双屏逻辑集中在一个地方
-
后续新增右侧支持路由也比较方便
-
难点4:右侧页面也会有自己的适配问题
把详情页放到右侧后,不代表详情页天然能工作。
比如 NovelDetailPage 的导航栏曾经出现过:
A RenderFlex overflowed by 1.00 pixels on the right.
原因是它用手算宽度:
width: offset >= 150.h ? 1.sw - 150.w : 1.sw - 110.w
在双屏右侧栏里,padding、margin、按钮宽度和像素取整叠加后,就可能差 1px。
解决方式是不要手算 Row 子项宽度,而是用 Expanded:
Row(
children: [
Expanded(
child: Text(
title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
if (showFollowButton) ...[
SizedBox(width: 10.w),
followButton,
],
],
)
这类问题很常见。
折叠屏适配不是只改外层容器,很多内部页面里原本“在手机上刚好没问题”的手算布局,到了双栏宽度下都会暴露出来。
-
难点5:右侧页面的生命周期和重建
右侧点击不同 item 时,要让详情页重新创建,而不是复用旧状态。
项目里用:
KeyedSubtree(
key: ValueKey<int>(_paneController.version),
child: _buildRightPaneContent(),
)
每次打开新右侧路由时,version 增加,右侧页面会按新的 key 重建。
这可以避免:
-
详情页状态残留
-
上一个 item 的数据还显示在页面里
-
StatefulWidget 没有重新执行初始化逻辑
-
难点6:单双屏切换时状态要合理
折叠屏设备可能会在运行过程中折叠或展开。
所以适配逻辑不能只在 App 启动时判断一次。
当前 FoldableAppLayout 使用 LayoutBuilder,每次布局约束变化都会重新判断:
if (!shouldUseDualPane) {
_paneController.updateDualPane(false);
return widget.child;
}
进入双屏时:
_paneController.updateDualPane(true);
这样可以跟随窗口尺寸变化更新单双屏状态。
不过这里后续还有优化空间,比如从双屏折回单屏时,如果右侧已经打开了某个详情页,可以考虑是否自动 push 到单屏页面,或者直接丢弃右侧状态。这个要根据业务决定。
-
难点7:不是所有页面都适合放到右侧
折叠屏右侧不是垃圾桶,不能什么页面都往里塞。
适合放右侧的页面通常有这些特点:
-
由左侧列表 item 触发
-
是当前选中内容的详情
-
可以独立展示
-
不强依赖全屏沉浸式体验
-
返回时可以只关闭右侧
不太适合放右侧的页面包括:
-
登录页
-
强流程页面
-
支付页
-
权限授权页
-
需要独占全屏的页面
当前项目里比较适合右侧展示的是:
-
漫画详情
NovelDetailPage -
个人/作品主页
ProfilePage -
资讯详情
InformationDetailPage
这些都符合“左侧列表,右侧详情”的结构。
-
当前项目的实现链路总结
当前 Demo 的折叠屏链路大致是:
-
main.dart在MaterialApp.builder中包裹FoldableAppLayout -
FoldableAppLayout通过MediaQuery.displayFeatures和宽度阈值判断单双屏 -
单屏时直接返回原页面
-
双屏时用
Row切成左侧单屏区域和右侧详情区域 -
左侧通过
SinglePaneScope重新配置MediaQuery和ScreenUtil -
右侧通过
FoldablePaneController控制当前展示的详情页 -
左侧点击详情入口时,
NavigatorUtils.push尝试拦截路由 -
如果是双屏且路由支持右侧展示,就更新右侧内容
-
如果不是双屏,继续走原来的 Fluro 路由跳转
-
右侧详情页返回时,只清空右侧内容,不影响左侧页面栈
这套方案的核心是:折叠屏能力对业务页面尽量透明。
业务页面仍然写:
NavigatorUtils.push(context, HomeRouter.novelDetailPage, arguments: {...});
至于是单屏 push,还是双屏打开右侧,由底层统一决定。
-
折叠屏适配的推荐标准
结合这次项目实践,我比较推荐下面这套标准:
-
折叠屏适配尽量放在全局布局层,不要散落到每个页面里
-
单屏逻辑必须保持原样,不能为了双屏破坏手机体验
-
双屏优先采用“左列表,右详情”的主从结构
-
使用
MediaQuery.displayFeatures判断铰链或折痕,同时保留宽度阈值兜底 -
不要只用屏幕宽度判断折叠屏,真实设备中间可能有不可用区域
-
左右栏都要设置最小宽度,空间不足时宁愿退回单屏
-
如果项目使用
ScreenUtil,必须处理双栏下1.sw的尺寸来源 -
避免在 Row 里手算宽度,优先使用
Expanded、Flexible、LayoutBuilder -
右侧只承载适合主从结构的页面,比如详情、预览、阅读器
-
登录、支付、授权、强流程页面不要轻易放右侧
-
双屏路由拦截最好集中在统一路由工具中处理
-
右侧返回行为要和左侧导航栈隔离
-
双屏打开不同详情时,要考虑页面 key 和状态重建
-
折叠、展开、旋转、窗口缩放都要测试
-
真机和模拟器都要测,因为
displayFeatures上报可能不一致
-
还可以继续优化的点
当前实现已经打通了核心链路,但如果要做得更完整,还可以继续补下面这些能力。
-
优化1:右侧详情缓存
现在右侧每次点击都会重建详情页。
如果业务希望保留多个详情状态,可以把右侧做成一个小型 Navigator,或者维护一个右侧页面栈。
不过这样复杂度会明显上升,返回逻辑也要重新设计。
-
优化2:右侧空状态更业务化
当前默认右侧是测试面板。
后续可以改成更贴近业务的空状态,比如:
-
“请选择一部漫画”
-
“点击左侧列表查看详情”
-
展示最近阅读记录
-
展示推荐作品
这样用户展开折叠屏后,不会觉得右侧只是一个开发测试区域。
-
优化3:双屏下的详情页宽度适配
右侧详情页现在复用了原来的手机详情页。
这能快速落地,但不是最终形态。
后续可以让右侧详情页在宽屏下做进一步优化,比如:
-
头图和简介左右排版
-
推荐内容展示更多列
-
阅读按钮固定到更合适的位置
-
详情页顶部导航减少大面积空白
-
优化4:单双屏切换时的详情迁移
如果用户双屏时右侧打开了详情,然后把设备折回单屏,当前实现会回到单屏主页面。
是否需要自动把右侧详情迁移成单屏 push 页面,要看业务。
有些 App 会选择迁移,保证用户正在看的内容不中断。
有些 App 会选择回到主页面,保证导航栈简单。
这个点没有绝对标准,要根据产品体验决定。
-
优化5:针对平板和桌面窗口单独设计
当前宽度大于 600 会进入双栏,这对折叠屏和平板都比较友好。
但如果后续支持桌面端,可能需要更细的断点:
-
手机单屏
-
折叠屏双栏
-
平板双栏
-
桌面三栏
到那时就不只是 foldable layout,而是完整的 adaptive layout。
结束:
这篇文章就先写到这里。
相比普通手机适配,Flutter 折叠屏适配更考验项目结构。它不只是 UI 拉伸,也不只是判断宽度,而是要同时处理:
-
屏幕分割判断
-
左右栏布局
-
原单屏页面的尺寸体系
-
路由拦截
-
右侧详情页状态
-
返回行为
-
页面内部 overflow
-
折叠和展开过程中的状态变化
这次 Demo 的思路可以总结成一句话:
单屏保持原体验,双屏升级为左侧浏览、右侧详情。
在 Flutter 里做折叠屏适配时,真正重要的不是把 Row 写出来,而是先想清楚:
-
哪些页面适合放左侧
-
哪些页面适合放右侧
-
单屏和双屏的路由关系是什么
-
左侧是否还能保持原来的手机样式
-
右侧返回是否会影响左侧
-
项目里的尺寸适配工具是否支持局部屏幕宽度
这些问题理顺之后,折叠屏适配就会清晰很多。
技术最终还是服务体验。折叠屏的价值,不是让 App 看起来更“宽”,而是让用户在展开设备后,可以少跳转、少返回,在同一个视野里完成更多信息浏览和内容查看。