1. 为什么做到第十二期才开始搞抽屉:功能越攒越多之后的必然选择
幸运大转盘这个项目写到第十二篇,主界面早就不是当初那个只有"一个转盘+一个按钮"的清爽样子了。前面十一期里,我陆续往里加了奖品管理、抽奖记录、音效开关、震动反馈、中奖弹窗、动画速度调节……每一个功能单独拿出来都挺合理,但堆在同一个页面上就会发现问题:入口全都挤在转盘旁边,有的用图标,有的用文字按钮,有的藏在长按手势里,用户进来之后根本不知道该点哪里。
这就是我在第十二期决定停下来做抽屉配置的原因。**当一个页面的功能入口超过七个,信息架构就必须重新梳理,而不是继续往主界面堆控件。**抽屉导航(Drawer)在这种场景下几乎是标准解:它把辅助功能从主界面上剥离开来,保留转盘App最核心的沉浸体验——用户进来就是转盘,想干别的事再划出抽屉。
选择抽屉而不是底部Tab栏,还有一个转盘场景特有的原因。转盘这个页面本质上是"工具型沉浸页",用户的核心动作是"点击转动→看结果",整个过程需要视觉聚焦在转盘本身。底部Tab栏会永远占着一条高度,转盘为了避开它只能往上缩,动画和指针的视觉冲击力都会被削弱。抽屉是懒加载的,平时完全不占空间,只有用户主动滑出来才渲染,这对转盘类App是最友好的方案。
如果你也在做类似的工具型App,我的建议是:**不要在第一版就急着加抽屉,等主要功能闭环之后再统一收纳入口。**第一版用单页面把所有功能堆出来,反而能逼你想清楚哪些功能是核心、哪些是辅助。第十二期做抽屉,不是补课,是对前面十一期功能的一次盘点。
2. 抽屉里放什么:菜单项的分组模型和事件分发设计
2.1 菜单数据模型:不要用一堆散落的if-else
抽屉界面的配置,最忌讳的是在build方法里写一长串ListTile,然后每个onTap里塞一段独立逻辑。项目越往后越难维护。我在这一期里先把菜单抽象成一个数据模型:
class DrawerMenuItem { final String title; final IconData icon; final VoidCallback? onTap; final bool showDividerBefore; final Widget? trailing; const DrawerMenuItem({ required this.title, required this.icon, this.onTap, this.showDividerBefore = false, this.trailing, }); }showDividerBefore是为了处理分组:抽屉里"历史记录""奖品管理"属于业务功能,"外观设置""关于我们"属于系统功能,两组之间需要一条分割线。把这个开关放在数据里,而不是在构建列表时做索引判断,能让菜单项的增删只改数据、不改布局代码。
2.2 分组思考:业务功能在前,系统设置在底
我在设计这组菜单时,遵循的是移动端抽屉导航的通用心智模型:
| 分组 | 菜单项 | 说明 |
|---|---|---|
| 业务功能 | 抽奖记录、奖品管理、概率设置 | 和转盘核心玩法相关 |
| 系统设置 | 主题切换、声音反馈、震动反馈 | 属于App级偏好 |
| 辅助信息 | 关于、分享、检查更新 | 低频但必须存在 |
业务功能放在抽屉上部,是因为用户滑出抽屉时,最可能想看的还是和转盘直接相关的东西——比如"我上次抽到了什么"。系统设置放中间,辅助信息压底,这是各大App验证过的习惯路径。
主题切换这个入口,我特意放在了抽屉尾部、靠近"关于"的位置。原因很简单:主题切换不是高频操作,用户不会天天换主题。把它放在显眼位置反而会吸引用户反复进出设置,打断转盘的使用流。但在施工上,主题切换的实现复杂度却不低,因为它牵涉的是全局状态。
2.3 抽屉头和尾的定制
抽屉的头部(DrawerHeader)我用了转盘的主视觉色做一个渐变背景,上面放App名称和当前主题状态的小标签。这里有个细节:**DrawerHeader不建议直接塞图片,因为后面做主题切换时,头部背景需要跟随主题色联动。**我是用Container包了一个渐变,颜色值从主题配置对象里动态取,这样切主题时抽屉头不用额外处理就自动变色了。
抽屉底部放了一个临时版本号文本和一个小按钮"清空抽奖记录"——这是开发期常用的调试入口,顺手放在了真实菜单之外,用Spacer推到底部,和菜单项视觉上区分开。
3. 主题切换的关键链路:ThemeData、状态提升与持久化
3.1 从ThemeData开始:深色和浅色不是二选一
Flutter的主题切换,核心机制所有人都知道:MaterialApp的theme和darkTheme两个参数,加上themeMode决定当前用哪个。真正麻烦的是颜色体系的设计。
幸运大转盘这个App,主视觉是喜庆的红色系——转盘盘面是红黄相间的扇区,指针是金色,这些颜色在浅色背景下很协调,但到了深色背景下就会出现问题:**暗色模式下,大面积红色会显得刺眼,金色的明度也不够。**所以主题切换不是简单地把ThemeData.brightness改成Brightness.dark就完事,而是要重新设计一套适配深色背景的转盘配色。
我的做法是定义了一个AppThemeConfig:
class AppThemeConfig { final String id; final String label; final Color primaryColor; final Color backgroundColor; final Color cardColor; final Color drawerHeaderStart; final Color drawerHeaderEnd; final Brightness brightness; final List<Color> wheelSectorColors; // 转盘扇区配色 } class AppThemes { static final light = AppThemeConfig( id: 'light', label: '明亮', primaryColor: Color(0xFFE53935), backgroundColor: Color(0xFFF5F5F5), cardColor: Colors.white, drawerHeaderStart: Color(0xFFFF8A80), drawerHeaderEnd: Color(0xFFD32F2F), brightness: Brightness.light, wheelSectorColors: [Color(0xFFFFF176), Color(0xFFFF7043), ...], ); static final dark = AppThemeConfig( id: 'dark', label: '暗黑', primaryColor: Color(0xFFB71C1C), backgroundColor: Color(0xFF121212), cardColor: Color(0xFF1E1E1E), drawerHeaderStart: Color(0xFF7F0000), drawerHeaderEnd: Color(0xFF212121), brightness: Brightness.dark, wheelSectorColors: [Color(0xFF8D6E63), Color(0xFF6D4C41), ...], ); }把转盘扇区颜色也放进主题配置里,是一个很多人会忽略的点。转盘的颜色不应该在转盘组件内部写死,否则换主题只能换外壳、核心视觉纹丝不动,用户会觉得"切了个寂寞"。真正的主题切换必须穿透到业务组件内部。
3.2 状态管理:ChangeNotifier够用,别上复杂架构
主题切换的状态管理,我这期用的是ChangeNotifier + ValueListenableBuilder的组合,没有引入任何第三方状态管理库。原因很实际:这个项目的状态量没大到需要Bloc或Riverpod的程度,主题切换本质上是"一个可全局读写的值",用系统自带的能力就够了。
class ThemeController extends ChangeNotifier { ThemeController(this._mode); ThemeMode _mode; ThemeMode get mode => _mode; Future<void> setMode(ThemeMode mode) async { if (mode == _mode) return; _mode = mode; notifyListeners(); await _persist(mode); } }在MaterialApp外层用ValueListenableBuilder包一层,监听ThemeController的变化,每次通知都重建MaterialApp传新的themeMode。这个方案性能上没有隐患,因为MaterialApp本身并不重——Flutter只在themeMode实际变化时才会触发build,状态没变的情况下传同一个对象也不会重绘子树。
3.3 持久化:SharedPreferences在OpenHarmony上的适配
主题模式必须持久化——用户切到深色关了App,下次打开变成浅色,这种体验会让前面的工作前功尽弃。持久化我用的是shared_preferences插件。
这里要专门说一句Flutter for OpenHarmony的适配问题。普通的Flutter项目直接pub add shared_preferences就行,但在OpenHarmony工程里,你得确认用的是适配过鸿蒙的版本。我在前期踩过一个坑:默认拉下来的shared_preferences在OpenHarmony上运行时,getInstance()会抛异常,因为底层走的平台通道在鸿蒙上没有被实现。解决方式是改用OpenHarmony社区适配的shared_preferences版本,或者是官方Flutter适配分支里带的那个。具体版本号不建议照抄,因为生态更新很快,你只要记得一条原则:跑在OpenHarmony上,所有依赖了平台通道的插件都要先验证鸿蒙适配情况。
存取的代码本身和标准Flutter没有区别:
Future<void> _persist(ThemeMode mode) async { final prefs = await SharedPreferences.getInstance(); await prefs.setString('theme_mode', mode.name); } Future<ThemeMode> _loadMode() async { final prefs = await SharedPreferences.getInstance(); final saved = prefs.getString('theme_mode'); switch (saved) { case 'light': return ThemeMode.light; case 'dark': return ThemeMode.dark; default: return ThemeMode.system; } }3.4 跟随系统的联动逻辑
系统模式这一档,用户选了"跟随系统"时,App会读取系统的亮暗状态。在OpenHarmony上,获取系统亮暗状态的渠道和Android不太一样,我用的方案是在windowManager读取颜色模式——这一块的细节放到下一节平台差异里专门说。
4. OpenHarmony上的平台差异:抽屉样式、状态栏和安全区
4.1 Flutter for OpenHarmony的工程结构变化
先说工程层面。Flutter for OpenHarmony的项目结构在前期搭建时已经处理过了,这一篇只说和主题切换相关的部分。ohos目录下有一个原生工程,里面可以访问鸿蒙的windowManager和preferences接口。Flutter层和原生层的交互,还是要通过MethodChannel。如果你之前写过标准Flutter的原生插件,这个模式是完全一样的,区别只是原生侧从Java/Kotlin换成了ArkTS。
主题切换时要读取系统亮暗状态,就是通过MethodChannel调鸿蒙侧实现的:
static const _channel = MethodChannel('lucky_wheel/system'); final String? mode = await _channel.invokeMethod('getSystemColorMode');鸿蒙原生侧(ArkTS)的代码,核心是调用系统能力获取当前的颜色模式,返回light或dark字符串。这里有个容易踩的坑:**MethodChannel的invokeMethod返回类型在鸿蒙侧和标准Flutter不一致,字符串可以安全传递,但如果你传Map或List,字段排序和嵌套结构的处理要格外小心。**后期调试时我打印通道返回值发现,鸿蒙侧返回的Map会被包一层Object类型,Flutter侧要用Map.castFrom做一次显式转换,否则运行时直接类型报错。
4.2 安全区:抽屉必须在状态栏下面止步
这是抽屉配置里最容易翻车的视觉细节。OpenHarmony设备的状态栏高度和Android不同,如果直接用SafeArea包整个Drawer,在某些机型上抽屉背景会从状态栏顶部就开始渲染,和系统状态栏重叠后看起来很脏。
我的处理方式是:抽屉整体背景从MediaQuery.padding.top开始计算,也就是让抽屉的背景延伸进状态栏区域(这样视觉上更连贯),但抽屉内容只从状态栏以下开始排列。这个效果用标准Flutter写法就能实现:
Widget buildDrawer(BuildContext context) { final topPadding = MediaQuery.of(context).padding.top; return Drawer( child: Container( color: theme.cardColor, child: Column( children: [ SizedBox(height: topPadding), DrawerHeader(...), Expanded(child: menuList), bottomArea, ], ), ), ); }注意这里没有再包一层SafeArea,因为如果包了,DrawerHeader的背景会被状态栏区域"顶下来",留出一条难看的缝隙。**对抽屉这种全屏组件,正确的做法是手动读取padding并自行消化,而不是依赖SafeArea。**这是我做抽屉以来反复踩过的坑,标准写法在Android上可能看不出问题,但换到OpenHarmony的平板或折叠屏上,状态栏高度变化就更明显。
4.3 抽屉的阴影与圆角渲染差异
还有一个小问题,说出来可能有人不信:同一个Drawer在OpenHarmony上的阴影表现和标准Android不同。鸿蒙的渲染引擎对BoxShadow的模糊半径处理比较"实",同样的BoxShadow参数,鸿蒙上阴影边缘会更硬、更接近一个深色边框。
我调了半天参数后放弃了对齐,干脆把抽屉右边框做成了1像素的浅色边线,配合阴影一起用。这样既保证了鸿蒙上的辨识度,也不影响Android上的原有表现——**跨平台的项目里,不要追求像素级一致,只要视觉逻辑一致就合格了。**用户不会拿着两个设备对比阴影模糊度,但一定会注意到抽屉是否看得清边界。
5. 联动设计:转盘、抽屉和主题切换的三种交互关系
5.1 抽屉滑出时转盘必须暂停
转盘动画和抽屉之间存在一个交互冲突:如果用户正在转动转盘的过程中滑出抽屉,转盘的旋转动画默认还会继续跑。这不是bug,但体验上很奇怪——用户打开抽屉看到奖品设置,转盘还在背后嗡嗡转。
我的做法是在DrawerController的回调里监听抽屉状态:
drawerController.addListener(() { if (drawerController.isDragging) { _wheelController.pause(); } });抽屉完全关闭后恢复动画。注意这里用的是isDragging而不是isOpen,因为用户从边缘滑出到一半又缩回去的场景也要处理。另外,转盘暂停时我不能直接停止Ticker,否则指针位置会丢失,我做的方案是记录当前角度和剩余动画时长,恢复时从断点续跑。这个"断点续转"逻辑是我在第三期动画部分就留好的口子,这期正好用上了。
5.2 主题切换的瞬时重绘问题
主题切换时,整个MaterialApp会重建,所有页面都走一遍新的ThemeData。表面上看没什么问题,但实际做下来,转盘页面在切主题的瞬间会闪一下白底——因为MaterialApp重建时,根页面的Scaffold先以新主题构建,而转盘的CustomPainter还在用旧的颜色缓存。
解决方式是给转盘的核心画布组件加一层repaint控制,让它在主题切换时强制同步重绘,同时给AnimatedTheme设置一个非常短的过渡时长:
AnimatedTheme( duration: Duration(milliseconds: 300), curve: Curves.easeInOut, child: child, )这段过渡动画配合CustomPainter的shouldRepaint判断,能让转盘扇区颜色平滑过渡而不是闪跳。这里有一个性能注意点:转盘扇区颜色变化后,整个盘面六到八个扇形都要重绘,如果在低端设备上再加上动画过渡的每一帧都重算颜色,会有几帧掉帧。我的优化是把扇区颜色缓存在WheelPainter的成员变量里,只有AppThemeConfig的引用变化时才重新计算颜色缓存的列表。
5.3 深浅色下转盘侧光的处理
转盘为了立体感,我在扇区上叠加了一层径向渐变高光,浅色主题下用的是白色高光、40%透明度,效果是"陶瓷质感"。但这个参数直接搬到深色主题下会灾难——深色背景下的白色高光会显得转盘像蒙了一层灰。
这个细节我在主题配置里加了两个独立字段:sectorHighlightColor和highlightOpacity,不同主题用不同值。**凡是能想象到的视觉参数,都放进主题配置对象里,不要靠if-else判断当前主题后再临时定义。**主题配置就应该是一个完整的、自洽的视觉描述,这样后续加第三个主题(比如"护眼绿"或者节日限定主题)时,不需要改动任何业务组件的逻辑,只需要新增一个AppThemeConfig实例。
6. 实测踩坑记录:抽屉、主题与鸿蒙适配的三个真实问题
6.1 问题一:抽屉打开后边缘手势冲突
转盘页面有一个"边缘滑动返回"的需求,我本来打算让用户在转盘页面也能从左侧边缘滑出抽屉。但实测发现,转盘上面部署了一个手势检测区域——用来支持"右滑快速设置"的快捷手势,两个手势在抽屉边缘位置产生了冲突,表现为:快速右滑时,有时触发抽屉、有时触发快捷设置,完全随机。
排查链路是这样的:先给两个手势都加了日志,确定冲突范围是屏幕左边缘0到40像素内。接着验证了GestureArena的处理顺序,发现默认情况下,抽屉的手势获胜,因为Drawer的滑动手势用的是HorizontalDragGestureRecognizer,注册顺序在自定义手势之前。
最终方案是放弃边缘手势,把抽屉的呼出手势限制在更靠内的区域,同时把"右滑快捷设置"改成"从屏幕中部右滑触发"。**代价是这两个手势需要在不同区域共存,但换来的是确定性——用户在任何位置都不会遇到一次滑动触发两个动作。**做手势交互,宁可保守也不能赌命。
6.2 问题二:主题切换后抽屉的选中态颜色不刷新
另一个坑比较隐蔽:抽屉菜单里有"当前主题"这一项,我给它加了一个选中态高亮,切完主题后,抽屉菜单的ListTile.selected状态应该自动更新成新主题对应的选中背景色。但实测在OpenHarmony上,切换主题后抽屉菜单的选中项颜色变成了一个说不清的紫色——这是主题切换后ListTile内部的selectedColor没有及时从ThemeData里取新值导致的。
解决方式是在ThemeController.notifyListeners()之后,显式调用了setState刷新抽屉页面的状态,同时给ListTile的selectedColor显式传值,不依赖ThemeData的默认推导。命中的教训是:在Flutter for OpenHarmony上,部分Material组件的主题继承链路比标准Flutter浅,显式传值比"相信默认行为"可靠得多。
6.3 问题三:暗色模式下转盘指针的可见性
最后一个问题是纯设计层面的。暗色主题下,我原本把指针做成了金色配深色描边,但实测在暗色主题的转盘上,金色指针和深色扇区的明度对比不够,转动时指针视觉上"隐形"了。
后来我把指针配色改成浅金色加白色高光边,并且做了一道投影。简单说就是:**指针在暗色主题必须以"发光体"的视觉形态出现,而不是沿用浅色主题下的"实体金属"形态。**这种细节不实测根本发现不了,模拟器上看起来还不错的角度,到了真机上因为屏幕亮度曲线不同,表现差异很大。
7. 把主题切换扩展到更多场景的设计预留
这一期做完抽屉和主题切换之后,整个项目的功能已经接近完整。但设计主题系统时,我留了几个后续可以直接扩展的口子,也顺便分享一下:
第一,主题配置里预留了wheelSectorColors的列表长度。现在转盘固定六个扇区,我特意把颜色列表设计成长度可变的——以后如果做"自定义转盘扇区数"的玩法,主题配置不用动,只改列表长度就行。
第二,ThemeController里的setMode预留了动画参数。将来如果要做"主题切换跟随当前时间自动变化"(比如晚上十点后自动切暗色),只需要加一个Timer判断时间,不需要动任何界面代码。
第三,抽屉菜单的数据模型里,trailing字段目前只用了Switch控件。但设计时预留了自定义Widget的可能性——以后如果要在某个菜单项尾部加角标、加网络状态圆点,直接传一个Widget就行,不需要改菜单列表的构建逻辑。
这三条预留不是画饼,它们都源于我在前十一期里反复做的重构:**凡是"以后大概率要变"的东西,一开始就不要写死。**主题和抽屉是整个App的"壳",壳的扩展性决定了后面加功能时会不会牵一发动全身。
8. 最后随手记:几个可以直接抄走的配置参数
分享几个我这期实测下来稳定的参数值。抽屉宽度我用了width: 304,比默认的320略窄,因为在横屏和平板场景下,320的抽屉配转盘页面会显得臃肿。主题过渡动画时长300ms,配合Curves.easeInOut,体感上是"明显但不拖沓"。抽屉头的渐变角度用的是-45度,从左上到右下,视觉上更符合阅读动线。
暗色主题的主色我最后定的是Color(0xFFB71C1C)而不是纯粹的Color(0xFF000000)加红点缀——整机看起来会更协调,因为纯黑背景下转盘的红色扇区会显得发脏。这个判断没有公式,是几十套配色试出来的,建议各位做暗色主题时多花时间在"主题色和背景的明度关系"上,而不是纠结单个颜色的色号。
菜单项的高度统一设成了56,和Material规范一致,但抽屉头和菜单之间的间距我故意留大了——24像素。原因是转盘App的抽屉里就六个项,内容不多,留白多一点反而显得从容。如果你的菜单项很多,这个间距建议缩到8。
以上就是第十二期的全部内容。说实话,抽屉配置本身不难,难的是抽屉和主题切换这两个"全局能力"牵涉到的所有关联状态——转盘暂停、颜色体系更换、平台差异适配、手势冲突——每一项单独拿出来都是小问题,但串在一起就是一笔不小的工程量。做完这一期,幸运大转盘这个项目在工程结构上才算真正闭环了。