Flutter折叠屏适配实践

14 阅读18分钟

前言:

  • 最近在项目里做了一版 Flutter 折叠屏适配,一开始以为只是判断一下屏幕宽度,然后把页面拆成左右两栏就可以了。真正改下来才发现,折叠屏适配不是简单地把一个页面拉宽,也不是把原来的 Flutter 页面直接塞进一个 Row 里。
  • 对一个已经按照手机单屏设计的 App 来说,折叠屏适配最核心的问题其实是:单屏体验要保留,双屏空间要利用,原来的路由、尺寸适配、页面状态和返回逻辑还不能乱。
  • 这篇文章就结合当前这个 Demo,聊一聊在 Flutter 项目里如何做折叠屏适配。文章不单独讲某一个 API,而是从项目落地角度说清楚:用了哪些工具、怎么判断单屏和双屏、双屏时页面如何组织、右侧屏幕展示什么、适配过程中遇到了哪些问题,以及后续做折叠屏适配时建议遵守的一些标准。

正文:

这个 Demo 的折叠屏适配,主要从下面几个维度来理解:

  1. Flutter 折叠屏适配的整体思路

  2. 当前项目用了哪些工具和能力

  3. 单屏和双屏如何判断

  4. 双屏时左右两侧怎么布局

  5. 左侧如何保持原来的单屏样式

  6. 右侧为什么适合展示详情页

  7. 双屏时如何拦截路由,把详情页打开到右侧

  8. 适配过程中遇到的难点和坑

  9. 后续继续完善折叠屏适配时的建议

  • 折叠屏适配的基本思想

普通手机页面通常是单栈结构。

也就是说:

  • 一个页面占满整个屏幕

  • 点击列表 item 后 push 到详情页

  • 返回时 pop 回列表页

  • 页面宽度基本按手机设计稿来适配

折叠屏展开后,用户看到的是更大的横向空间。如果仍然沿用普通手机逻辑,页面只是被拉宽,体验反而可能变差。

更适合折叠屏的方式是:

  • 左侧保留原来的单屏 App 浏览体验

  • 右侧承载详情、预览、阅读器或辅助信息

  • 单屏时仍然按原来的路由 push

  • 双屏时点击列表 item 不再跳走,而是在右侧打开详情

  • 右侧返回时只清空右侧,不影响左侧列表状态

所以这次适配并不是简单做响应式布局,而是做了一套“单屏保留原交互,双屏升级为列表 + 详情”的结构。

可以把它理解成:

  • 单屏:手机模式

  • 双屏:主从模式

  • 左屏:主列表、Tab、原 App

  • 右屏:详情页、个人页、资讯详情

  • 当前项目使用的工具和能力

这次适配没有引入专门的折叠屏三方库,主要使用 Flutter 自带能力和项目里已有的适配工具。

项目里用到的核心能力有:

MediaQuery.of(context).displayFeatures

它可以读取系统上报的显示特征,比如折叠屏的铰链、折痕、挖孔等信息。

LayoutBuilder

它用于读取当前布局约束,判断当前可用宽度,并决定是否切换成双栏布局。

DisplayFeature

它是 Flutter 对折叠屏显示特征的描述。当前项目里主要关心竖向分割特征,用它判断是否存在左右两个显示区域。

flutter_screenutil

项目原本大量使用了 1.sw10.w20.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

在双屏右侧栏里,paddingmargin、按钮宽度和像素取整叠加后,就可能差 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.dartMaterialApp.builder 中包裹 FoldableAppLayout

  • FoldableAppLayout 通过 MediaQuery.displayFeatures 和宽度阈值判断单双屏

  • 单屏时直接返回原页面

  • 双屏时用 Row 切成左侧单屏区域和右侧详情区域

  • 左侧通过 SinglePaneScope 重新配置 MediaQueryScreenUtil

  • 右侧通过 FoldablePaneController 控制当前展示的详情页

  • 左侧点击详情入口时,NavigatorUtils.push 尝试拦截路由

  • 如果是双屏且路由支持右侧展示,就更新右侧内容

  • 如果不是双屏,继续走原来的 Fluro 路由跳转

  • 右侧详情页返回时,只清空右侧内容,不影响左侧页面栈

这套方案的核心是:折叠屏能力对业务页面尽量透明。

业务页面仍然写:

NavigatorUtils.push(context, HomeRouter.novelDetailPage, arguments: {...});

至于是单屏 push,还是双屏打开右侧,由底层统一决定。

  • 折叠屏适配的推荐标准

结合这次项目实践,我比较推荐下面这套标准:

  1. 折叠屏适配尽量放在全局布局层,不要散落到每个页面里

  2. 单屏逻辑必须保持原样,不能为了双屏破坏手机体验

  3. 双屏优先采用“左列表,右详情”的主从结构

  4. 使用 MediaQuery.displayFeatures 判断铰链或折痕,同时保留宽度阈值兜底

  5. 不要只用屏幕宽度判断折叠屏,真实设备中间可能有不可用区域

  6. 左右栏都要设置最小宽度,空间不足时宁愿退回单屏

  7. 如果项目使用 ScreenUtil,必须处理双栏下 1.sw 的尺寸来源

  8. 避免在 Row 里手算宽度,优先使用 ExpandedFlexibleLayoutBuilder

  9. 右侧只承载适合主从结构的页面,比如详情、预览、阅读器

  10. 登录、支付、授权、强流程页面不要轻易放右侧

  11. 双屏路由拦截最好集中在统一路由工具中处理

  12. 右侧返回行为要和左侧导航栈隔离

  13. 双屏打开不同详情时,要考虑页面 key 和状态重建

  14. 折叠、展开、旋转、窗口缩放都要测试

  15. 真机和模拟器都要测,因为 displayFeatures 上报可能不一致

  • 还可以继续优化的点

当前实现已经打通了核心链路,但如果要做得更完整,还可以继续补下面这些能力。

  • 优化1:右侧详情缓存

现在右侧每次点击都会重建详情页。

如果业务希望保留多个详情状态,可以把右侧做成一个小型 Navigator,或者维护一个右侧页面栈。

不过这样复杂度会明显上升,返回逻辑也要重新设计。

  • 优化2:右侧空状态更业务化

当前默认右侧是测试面板。

后续可以改成更贴近业务的空状态,比如:

  • “请选择一部漫画”

  • “点击左侧列表查看详情”

  • 展示最近阅读记录

  • 展示推荐作品

这样用户展开折叠屏后,不会觉得右侧只是一个开发测试区域。

  • 优化3:双屏下的详情页宽度适配

右侧详情页现在复用了原来的手机详情页。

这能快速落地,但不是最终形态。

后续可以让右侧详情页在宽屏下做进一步优化,比如:

  • 头图和简介左右排版

  • 推荐内容展示更多列

  • 阅读按钮固定到更合适的位置

  • 详情页顶部导航减少大面积空白

  • 优化4:单双屏切换时的详情迁移

如果用户双屏时右侧打开了详情,然后把设备折回单屏,当前实现会回到单屏主页面。

是否需要自动把右侧详情迁移成单屏 push 页面,要看业务。

有些 App 会选择迁移,保证用户正在看的内容不中断。

有些 App 会选择回到主页面,保证导航栈简单。

这个点没有绝对标准,要根据产品体验决定。

  • 优化5:针对平板和桌面窗口单独设计

当前宽度大于 600 会进入双栏,这对折叠屏和平板都比较友好。

但如果后续支持桌面端,可能需要更细的断点:

  • 手机单屏

  • 折叠屏双栏

  • 平板双栏

  • 桌面三栏

到那时就不只是 foldable layout,而是完整的 adaptive layout。

结束:

这篇文章就先写到这里。

相比普通手机适配,Flutter 折叠屏适配更考验项目结构。它不只是 UI 拉伸,也不只是判断宽度,而是要同时处理:

  • 屏幕分割判断

  • 左右栏布局

  • 原单屏页面的尺寸体系

  • 路由拦截

  • 右侧详情页状态

  • 返回行为

  • 页面内部 overflow

  • 折叠和展开过程中的状态变化

这次 Demo 的思路可以总结成一句话:

单屏保持原体验,双屏升级为左侧浏览、右侧详情。

在 Flutter 里做折叠屏适配时,真正重要的不是把 Row 写出来,而是先想清楚:

  • 哪些页面适合放左侧

  • 哪些页面适合放右侧

  • 单屏和双屏的路由关系是什么

  • 左侧是否还能保持原来的手机样式

  • 右侧返回是否会影响左侧

  • 项目里的尺寸适配工具是否支持局部屏幕宽度

这些问题理顺之后,折叠屏适配就会清晰很多。

技术最终还是服务体验。折叠屏的价值,不是让 App 看起来更“宽”,而是让用户在展开设备后,可以少跳转、少返回,在同一个视野里完成更多信息浏览和内容查看。

Demo下载地址 Demo