上一篇提前带大家预览了Flutter 鸿蒙 3.41版本功能
Flutter 鸿蒙 3.41 版本即将发布:社区适配周期缩短近一半,这个速度值得点赞!
这次主要是讲下其中最大的亮点:分栏功能
在平板、折叠屏设备上,大多 App 只是把手机界面拉大了事,左右大片留白,信息密度反而更低,分栏功能的思路是这样:横屏或展开态时,左边放主页列表,右边放详情内容,当竖屏或折叠时,恢复单栏。
下面是官方Demo
https://gitcode.com/CPF-Flutter/flutter_samples/tree/master/ohos/split_view_sample里面覆盖了 8 种路由方案,从最基础的 Navigator 到 GoRouter 都跑通了,我们先从最简单的配置开始
不用写一行 Dart 代码,在工程里放一个 JSON 文件就行:
ohos/entry/src/main/resources/rawfile/split_config.json内容如下:
{// 两个开关控制两种屏幕形态,可以只开一个"splitOptions": {// 宽屏(横屏,宽高比 > 1.2,宽高均 > 600dp)⇢ 分栏"enableWideWindowSplit": true,// 方屏(两折叠展开态,宽高比均 < 1.2)⇢ 分栏"enableSquareWindowSplit": true,// 告诉 SDK "/" = 主页,Router/GoRouter 模式下必须指定"homePage": "/",// 这些路径的页面不参与分栏,强制全屏"fullScreenPages": ["/video"],// true: MediaQuery 宽度返回屏幕一半,组件自然适配半屏"enableReducedContainerSize": true,// true: 页面申请横屏时自动退出分栏,全屏显示"supportLandscapeFullscreen": true}}
几个关键配置说一下:
enableWideWindowSplit | |
enableSquareWindowSplit | |
homePage | |
fullScreenPages | |
enableReducedContainerSize | MediaQuery.of(context).size.width 返回屏幕一半宽度 |
supportLandscapeFullscreen |
SDK 在应用启动时自动加载这个文件,不需要初始化代码,开关关了就还是单栏。
Demo 里 8 种方案,大部分应用用方案 1(initialRoute + onGenerateRoute)就够了,路由表在 NavigatorConfig 里统一管:
class NavigatorConfig { static Map<String, WidgetBuilder> get materialRoutes => { "/splash": (context) => SharedSplashPage( routeManager: RouteManager.forNavigator(), themeColor: Colors.blue, solution: SolutionType.navigator, ), "/login": (context) => SharedLoginPage( routeManager: RouteManager.forNavigator(), themeColor: Colors.blue, solution: SolutionType.navigator, ), "/": (context) => const HomePage(), "/home": (context) => const HomePage(), "/first": (context) => const FirstPage(), "/second": (context) => const SecondPage(), "/video": (context) => const VideoPlayerPage(),};static MaterialPageRoute? generateMaterialRoute(RouteSettings settings) { final name = settings.name; final pageBuilder = materialRoutes[name]; if (pageBuilder != null) { return MaterialPageRoute( settings: settings, // 必须保留 settings builder: (context) => pageBuilder(context), ); } // 未知路由兜底,避免白屏 return MaterialPageRoute( settings: settings, builder: (context) => SharedUnknownRoutePage( routeName: settings.name, routeManager: RouteManager.forNavigator(), ), ); }}入口 App 就简单了:
class Solution1App extends StatelessWidget { const Solution1App({super.key}); @override Widget build(BuildContext context) { return MaterialApp( title: '方案1: initialRoute + onGenerateRoute', navigatorObservers: [globalRouteObserver], initialRoute: '/splash', onGenerateRoute: NavigatorConfig.generateMaterialRoute, theme: NavigatorConfig.buildMaterialTheme(), ); }}注意点:onGenerateRoute 里必须把 settings 传给 MaterialPageRoute
主页就是一个列表页,每个卡片触发不同的跳转方式,核心代码:
class HomePage extends StatefulWidget { /* ... */ }class _HomePageState extends State<HomePage>with WidgetsBindingObserver, RouteAware {@override Widget build(BuildContext context) { return Scaffold( appBar: AppBar(title: const Text('分栏路由示例')), body: SafeArea( child: SingleChildScrollView( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 24), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ // 路由 API 区域 _buildSectionTitle('路由 API 示例'), _buildRouteCard(context, 'push() - 有路由名', '待 RouteSettings(name)', Colors.teal, Icons.label, onTap: () => Navigator.of(context).push( MaterialPageRoute( settings: const RouteSettings(name: '/first'), builder: (context) => const FirstPage(), ), ), ), // pushNamed、pushReplacementNamed、pushAndRemoveUntil... _buildRouteCard(context, 'pushNamed()', '命名路由', Colors.purple, Icons.route, onTap: () => Navigator.pushNamed(context, "/first"), ), // 弹窗区域、动效区域、横屏区域... ], ), ), ), ); }}主页上的按钮覆盖了所有分栏需要验证的跳转方式,包括push 带路由名/不带路由名、pushNamed、pushReplacementNamed、pushAndRemoveUntil,还有自定义 PageRouteBuilder。
详情页用的是基类模式,5 个详情页共享同一套 UI:
class FirstPage extends BaseDetailPage { const FirstPage({super.key}) : super(pageIndex: 1);}abstract class BaseDetailPage extends StatefulWidget { final int pageIndex; const BaseDetailPage({super.key, required this.pageIndex});String get nextRouteName { final routes = ['', '/second', '/third', '/fourth', '/fifth', '/first']; return routes[pageIndex]; }}Demo 模拟了一个常见流程:开屏页 → 登录页 → 主页 → 详情页。
开屏页的核心逻辑:
class SharedSplashPage extends StatefulWidget { final RouteManager routeManager; final SolutionType solution; // ...}void _navigateToLogin() { if (widget.routeManager.solution == SolutionType.navigator) { Navigator.of(context).pushReplacementNamed('/login'); } else { widget.routeManager.doReplaceRoute('/login'); }}登录页验证成功后跳到主页:
void _navigateToHome() { if (widget.routeManager.solution == SolutionType.navigator) { Navigator.of(context).pushNamedAndRemoveUntil('/', (route) => false); } else { widget.routeManager.doClearAndNavigate('/'); }}注意这里根据 solution 类型分发:Navigator 走原生的 Navigator API,Router 走 RouteManager 回调,这套抽象让同一个页面同时用在 Navigator 和 Router 两种方案里。
分栏模式下,对话框自动根据右侧状态决定显示位置:右侧是占位页就弹左边,右侧有内容就弹右边,遮罩层会两边同步加上。
Demo 里封装了 15 种弹窗,挑了个有代表性的:
// AlertDialog - 有确认/取消void showAlertDialog(BuildContext context) { showDialog<void>( context: context, builder: (BuildContext context) { return AlertDialog( title: const Text('AlertDialog'), content: const Text('Material 风格弹窗。分栏状态下自动调整位置。'), actions: [ TextButton(onPressed: () => Navigator.pop(context), child: const Text('取消')), ElevatedButton(onPressed: () => Navigator.pop(context), child: const Text('确定')), ], ); }, );}一个藏得很深的坑:showModalBottomSheet 默认 useRootNavigator: false,必须显式设 useRootNavigator: true
// 分栏下必须 useRootNavigator: trueshowModalBottomSheet<void>( context: context, useRootNavigator: true, // 这里,别忘了 builder: (BuildContext context) { /* ... */ },);视频播放页这种场景,你肯定不希望分栏,配置里加上路由名就行:
"fullScreenPages": ["/video"]Demo 的视频播放页用 video_player 播放本地 mp4,有完整的播放控制栏和手势交互。进入时 SDK 自动切单栏,退出时恢复分栏,不需要手动干预。
class VideoPlayerPage extends StatefulWidget { const VideoPlayerPage({super.key, this.videoTitle, this.videoAuthor}); /* ... */}// initState 里初始化视频_controller = VideoPlayerController.asset('assets/test.mp4');_controller.initialize().then((_) {if (mounted) { setState(() => _isInitialized = true); _controller.setLooping(true); _controller.play(); }});主页跳视频播放页:
Navigator.pushNamed(context, '/video', arguments: { 'title': '测试视频', 'author': '测试作者',});分栏对自定义 PageRoute 完全兼容,Demo 里提供了三种动效:
// 从底部滑入class SlideInTransition extends PageRouteBuilder {final Widget child; SlideInTransition({required this.child}) : super( transitionDuration: const Duration(milliseconds: 500), pageBuilder: (context, animation, secondaryAnimation) => child, transitionsBuilder: (context, animation, secondaryAnimation, child) {return SlideTransition( position: Tween<Offset>(begin: const Offset(0, 1), end: Offset.zero) .animate(CurvedAnimation(parent: animation, curve: Curves.easeOutCubic)), child: child, ); }, );}// 淡入淡出class FadeInTransition extends PageRouteBuilder { /* ... */ }// 缩放旋转 - 用 elasticOut 曲线弹出class ScaleRotateTransition extends PageRouteBuilder { /* ... */ }调用方式一样:
Navigator.push(context, SlideInTransition(child: const FirstPage()));如果你用 GoRouter,配置长这样:
final GoRouter _router = GoRouter( initialLocation: '/', observers: [globalRouteObserver], routes: [ GoRoute( path: '/', name: '/', // name 必须和配置文件的 homePage 一致 builder: (context, state) => const GoRouterHomePage(), routes: [ GoRoute( path: 'first', name: '/first', builder: (context, state) => const GoRouterDetailPage(pageIndex: 1), ), GoRoute( path: 'video', name: '/video', // fullScreenPages 里配的就是这个名字 builder: (context, state) => const VideoPlayerPage(), ), // ...更多路由 ], ), ],);Demo 完整代码在 split_view_sample:
https://gitcode.com/CPF-Flutter/flutter_samples/tree/master/ohos/split_view_sample赶紧去试试吧~
感兴趣的小伙伴记得关注我,关注社区!
“AI再牛,技术不能丢”