Flutter转盘App实战:抽屉配置与主题切换的完整实现

📅 发布时间:2026/10/18 23:50:41
Flutter转盘App实战:抽屉配置与主题切换的完整实现
上一期我们把转盘的核心流程跑通了旋转、减速、指针判定都正常App已经算“能玩”了。但真正拿给别人评测的时候问题马上来了有人嫌转盘转得太快有人想关掉震动有人对默认的红色配色不感冒甚至有人问我“为什么没有历史记录”。这些都是体验层面的需求不能用一次代码就定死需要一个让用户自己调节的地方。所以这期补上两块硬骨头抽屉配置和主题切换。抽屉配置听起来简单就是侧边滑出一个面板放几个开关、滑块。但要做得顺手得先想清楚一个问题配置项从下往上该怎么流动主题切换也没有表面看起来那么简单不只是换一个颜色而是要让转盘的扇形、按钮、状态栏、AppBar全部跟着变并且切换过程还得流畅。这篇我把实现思路、代码结构和踩过的坑一并写出来适合正在用Flutter做实际项目、尤其是在OpenHarmony设备上调试的朋友参考。1. 项目概述与设计思路1.1 这一期的核心目标把“死的配置”变成“活的设置”先盘点一下现在的状态。转盘页已经能完成一次完整的抽奖点击开始指针旋转减速停在某个扇形上然后弹窗展示结果。这个流程是闭环的但参数是写死在代码里的转速固定、音效固定、震动固定、扇形颜色固定。也就是说哪怕你只是想让转盘稍微转快一点也得改代码重新编译。这显然不适合一个要交给用户使用的App。这一期要做的就是把这些“固定值”全部提出来变成一组可读写的配置数据。用户在抽屉里关掉声音App后续的抽奖过程就不能再播放音效用户把转速拉到最大转盘的旋转速度就应该立刻变快用户选了新的主题色转盘扇形和主页按钮的颜色要同步变化。这里的关键词是“联动”不只是把配置存下来而是在配置变化的同时通知所有依赖它的界面刷新。在处理这个问题时我一开始想的是给每个配置项单独创建一个ValueNotifier比如soundNotifier、speedNotifier各管各的。后来发现这样太零散当配置项增加到十几个的时候监听、释放、组合会变得很麻烦。更好的方案是建一个统一的AppConfig数据模型用一个ChangeNotifier去驱动整个配置状态。UI层只对这一个对象负责配置项再多也只有一个入口。1.2 为什么用抽屉而不是独立设置页如果你需要一个“设置”功能第一个直觉通常是做一个独立的设置页面从首页右上角点进去。但在我们这个转盘App里我最终选了Scaffold的drawer侧滑抽屉。原因很简单转盘是单页的沉浸式体验用户不想为了调一个音量跑进一个全新的页面再从页面退回来。抽屉从侧边滑出停留在当前页面之上改完立即生效体验顺畅得多。另一个重要原因是Flutter对抽屉的原生支持非常成熟。Scaffold自带drawer属性只要指定一个Drawer组件就能获得侧滑动画、遮罩层、点击空白关闭这些交互不需要自己管理页面栈。如果做成独立设置页就要处理导航栈、返回逻辑、状态传递明显更复杂。做一个“轻量调节”的场景抽屉是性价比最高的方案。当然抽屉也有它的限制后面会细说包括抽屉宽度在OpenHarmony设备上的默认表现、安全区域处理、与系统返回手势的冲突。这些坑在平板和折叠屏上更容易暴露。1.3 主题切换的技术路线选型主题切换比抽屉配置要抽象一些。我看到很多教程里就是一个setState改一下themeMode从ThemeMode.light切到ThemeMode.dark。这在普通Demo里没问题但放到我们的转盘App里远远不够。核心原因是转盘的扇形颜色、指针样式、按钮上的渐变这些业务组件并不完全依赖ThemeData。ThemeData管的是Material组件库的配色比如AppBar背景、Text颜色、Switch选中色但扇形是我们在CustomPainter里自己画的它只认我们自己传进去的颜色。所以主题体系要分两层一层是Material主题负责Flutter框架组件的外观通过ThemeData和themeMode控制另一层是业务主题负责转盘相关的自定义配色通过一个ThemePreset数据模型控制。切换时先更新ThemePreset再更新ThemeData两者联动。比如用户选了“金币黄”主题AppBar变成金色系转盘扇形同步变成金色渐变。这样做还有一个好处以后加新主题只需要在预设列表里加一项转盘和组件都会自动适配不需要再碰绘制逻辑。2. 搭建侧边抽屉骨架与配置模型2.1 用Scaffold的drawer属性搭起侧边栏抽屉的基础实现没什么难度就是在Scaffold上挂一个drawerScaffold( appBar: AppBar( title: const Text(幸运大转盘), leading: Builder( builder: (context) { return IconButton( icon: const Icon(Icons.menu), onPressed: () { Scaffold.of(context).openDrawer(); }, ); }, ), ), drawer: const DrawerPanel(), body: const HomeBody(), )这里有一个容易忽略的细节如果AppBar没有leadingFlutter会自动帮你加一个抽屉图标按钮。但如果你自定义了leading就必须自己调用Scaffold.of(context).openDrawer()否则抽屉就打不开。在我实际项目里就是这样一开始为了在主标题旁边放一个Logo改了leading结果抽屉怎么滑都出不来排查了半天才发现是openDrawer没被调用。另外Flutter同时提供了drawer和endDrawer两个入口区别只在滑动方向一个从左边出来一个从右边出来。对中文用户来说左抽屉更符合阅读习惯所以我选了drawer。除非你有特殊布局需求否则不要混用两者同时使用会导致左右两边都有手势区域在转盘这种需要大面积操纵的界面上会影响触控的判定。2.2 配置模型把散落的配置项收拢成一张表抽屉面板里要放哪些配置项我从实际使用场景出发列了这六项音效开关抽奖过程中是否播放轮播音效震动开关结果弹出时是否震动反馈旋转速度滑块调节控制转盘慢速/快速档位主题模式亮色/暗色/跟随系统主题主色几张预设色板决定App外观历史记录入口跳转到抽奖历史页面。这些配置需要统一的数据模型承载同时还需要序列化能力用于本地存储。代码大概长这样class AppConfig { bool soundEnabled; bool vibrationEnabled; double spinSpeedFactor; ThemeMode themeMode; int accentColorIndex; AppConfig({ this.soundEnabled true, this.vibrationEnabled true, this.spinSpeedFactor 1.0, this.themeMode ThemeMode.system, this.accentColorIndex 0, }); MapString, dynamic toJson() { return { soundEnabled: soundEnabled, vibrationEnabled: vibrationEnabled, spinSpeedFactor: spinSpeedFactor, themeMode: themeMode.index, accentColorIndex: accentColorIndex, }; } factory AppConfig.fromJson(MapString, dynamic json) { return AppConfig( soundEnabled: json[soundEnabled] ?? true, vibrationEnabled: json[vibrationEnabled] ?? true, spinSpeedFactor: (json[spinSpeedFactor] as num?)?.toDouble() ?? 1.0, themeMode: ThemeMode.values[json[themeMode] ?? 2], accentColorIndex: json[accentColorIndex] ?? 0, ); } }这个模型本身不复杂但要注意一个设计习惯默认值必须写在模型里而不是写在UI层。很多配置项的默认值一旦分散到多个页面就会出现“这里默认开、那里默认关”的混乱。所有配置项的初始状态都在AppConfig构造时确定既方便测试又保证了一致性。2.3 业务主题的表示色板不能只存一个颜色主题切换最忌讳的事就是只存一个单一颜色值然后所有地方都用这个颜色。实际界面上AppBar背景、按钮渐变色、转盘扇形、文字颜色都需要不同的明度、饱和度一个颜色值根本喂不饱。我在设计ThemePreset时除了主色之外还会派生出一整套色板包括辅助色、背景色和对比色class ThemePreset { final String name; final Color primary; final Color background; final Color surface; final ListColor wheelPalette; const ThemePreset({ required this.name, required this.primary, required this.background, required this.surface, required this.wheelPalette, }); ThemeData toThemeData() { return ThemeData( colorScheme: ColorScheme.fromSeed( seedColor: primary, surface: surface, ), scaffoldBackgroundColor: background, appBarTheme: AppBarTheme(backgroundColor: primary), ); } }wheelPalette是一个颜色数组用于转盘扇形的绘制。比赛时几何排列的每个扇形依次取色这样转盘整体配色会和主题统一同时相邻扇形依然保持足够的色差不会出现两个差不多的颜色贴在一起。这个色板不能只用主色的深浅变化去生成我用的是手工打理的一组预设色每一个主题配一组效果比自动差分好得多。3. 抽屉配置面板的实现与联动3.1 抽屉内部布局用ListView放开关和滑块抽屉面板不能只是一个空壳里面要有组织地把配置项分组放好。我采用的布局是ListView配合三个分组通用设置、转盘行为、主题设置。class DrawerPanel extends StatelessWidget { const DrawerPanel({super.key}); override Widget build(BuildContext context) { final config context.watchAppConfigController().config; return Drawer( width: 300, child: SafeArea( child: ListView( padding: const EdgeInsets.all(16), children: [ const DrawerHeader(child: Text(配置中心)), const Text(通用设置), SwitchListTile( title: const Text(音效), secondary: const Icon(Icons.volume_up), value: config.soundEnabled, onChanged: (value) { context.readAppConfigController().updateSound(value); }, ), SwitchListTile( title: const Text(震动), secondary: const Icon(Icons.vibration), value: config.vibrationEnabled, onChanged: (value) { context.readAppConfigController().updateVibration(value); }, ), // 旋转速度滑块 ListTile( title: const Text(旋转速度), subtitle: Slider( value: config.spinSpeedFactor, min: 0.6, max: 2.0, onChanged: (value) { context.readAppConfigController().updateSpeed(value); }, ), ), // 主题切换相关配置项..., ], ), ), ); } }这里有两个实用经验。第一Drawer的默认宽度是304像素如果转盘页面本身很紧凑这个宽度会显得偏大。可以传入width参数手动调成300或者280但不要再小了配置项的文字会拥挤。第二抽屉内部一定要套SafeArea否则在刘海屏或带圆角的设备上顶部开关会被系统的状态栏挡掉这个在OpenHarmony设备上尤为明显。3.2 配置状态管理一个Controller统领所有变更配置项多起来后每个配置项写一个onChanged回调会非常冗长。我在这里引入了一个AppConfigController它是ChangeNotifier的子类里面只维护一份AppConfig所有更新方法都汇聚到这个类中class AppConfigController extends ChangeNotifier { AppConfig _config AppConfig(); AppConfig get config _config; void updateSound(bool enabled) { _config.soundEnabled enabled; notifyListeners(); } void updateSpeed(double factor) { _config.spinSpeedFactor factor; notifyListeners(); } void updateThemeMode(ThemeMode mode) { _config.themeMode mode; notifyListeners(); } void updateAccentColor(int index) { _config.accentColorIndex index; notifyListeners(); } void restoreDefault() { _config AppConfig(); notifyListeners(); } }为什么不用ValueNotifierAppConfig因为ValueNotifier只能整体替换值而我们的配置往往需要只改一个字段。如果用ValueNotifier就得每次先复制旧配置再改字段多写几步。ChangeNotifier配合notifyListeners每次只更新一个字段UI层通过AnimatedBuilder或context.watch监听页面刷新消耗也不大。给MaterialApp套一个监听的实现也简单class AppRoot extends StatelessWidget { override Widget build(BuildContext context) { return ListenableBuilder( listenable: appConfigController, builder: (context, _) { final config appConfigController.config; return MaterialApp( theme: presets[config.accentColorIndex].toThemeData(), darkTheme: darkPresets[config.accentColorIndex].toThemeData(), themeMode: config.themeMode, home: const HomePage(), ); }, ); } }这样抽屉里任何配置项的改动都会立刻触发MaterialApp重建整个App的组件外观马上切换。这个过程不需要手动调用setState也不需要通过路由传参。3.3 恢复默认最容易漏掉的逻辑抽屉面板刚做完时配置项都是单向改值没有“恢复默认”。后来测试时发现一旦把转速拉到2.0想回到正常速度就得滑半天非常不便。于是我在抽屉底部加了一个“恢复默认”按钮。恢复默认的逻辑不是把每个配置项逐个重置那是硬编码容易漏。正确方式是直接重新构造一个AppConfig()void restoreDefault() { _config AppConfig(); notifyListeners(); }这一行代码的优点在于以后新增配置项时只要在AppConfig构造函数里默认值是对的恢复默认按钮就自动对新增项生效。这个设计思想也贯穿整个配置模块默认值只存在于一处新增配置不用到处打补丁。4. 主题切换的完整实现4.1 跟随系统与手选模式一键切换主题模式主题模式我用的是ThemeMode枚举三个值light、dark、system。在抽屉里做成一个SegmentedButton或者三个单选项用户选择后写入AppConfig的themeMode字段。有人会问为什么不直接用布尔量isDark因为用户偏好里存在“跟随系统”这个中间态。假设手机当前是暗色模式用户选了亮色过了一会儿系统切成了亮色App应该保持用户选择的亮色而不是没完没了地跟随系统变。ThemeMode.system只要在初始化配置时读取一次系统模式后续切换由系统驱动。用枚举类型表达这三种状态比布尔量清晰得多。在OpenHarmony环境上系统深浅色模式读取走的是Flutter框架自带的platformDispatcher.platformBrightness底层由平台映射。实测这一周期里Flutter for OpenHarmony对主题模式的响应是同步的没有出现模式切换后界面不刷新的问题。4.2 转盘配色与主题联动转盘是自己画的因此主题切换后必须重新触发绘制。我的做法是把ThemePreset.wheelPalette传给转盘组件在shouldRepaint里判断色板是否变化override bool shouldRepaint(WheelPainter oldDelegate) { return oldDelegate.palette ! palette || oldDelegate.sectors ! sectors || oldDelegate.rotationAngle ! rotationAngle; }如果只切主题不重绘用户看到的还是一个老色板的转盘那主题切换就是“半截子功能”。转角动画那边也要做联动用户切到“金色”主题时转盘扇形最好在一个动画周期内完成颜色过渡而不是生硬地跳变。这里Flutter的TweenAnimationBuilder很好用用ColorTween在旧色板和新色板之间插值切换过程大概300毫秒视觉上会顺滑很多。说到色板选择还有一个细节相邻扇形的颜色不能太接近。比如你选了一组以蓝色为基调的色板如果色差不足转盘指示器停下来后用户很难看清落在哪个区域。所以每一套主题色我至少保留了5个色相差异明显的颜色。举个例子“幸运红”主题的色板是红、橙、金、深红、玫瑰红“午夜蓝”主题是深蓝、亮蓝、青色、紫色、品红。这样既统一又有辨识度。4.3 配置持久化重启App之后依然有效只做内存里的配置用户重启App后一切恢复默认这种体验是灾难级的。所以必须持久化配置项。Flutter里最常用的是shared_preferences它本质就是一个KV存储把配置的JSON字符串存进去启动时再读出来。保存的代码如下Futurevoid saveConfig(AppConfig config) async { final prefs await SharedPreferences.getInstance(); await prefs.setString(app_config, jsonEncode(config.toJson())); }读取时FutureAppConfig loadConfig() async { final prefs await SharedPreferences.getInstance(); final raw prefs.getString(app_config); if (raw null) { return AppConfig(); } return AppConfig.fromJson(jsonDecode(raw) as MapString, dynamic); }在OpenHarmony平台上shared_preferences是走适配层实现的实际项目里我用的就是官方Flutter插件OpenHarmony端有对应的存储实现映射。如果你的环境中这个插件不可用也可以自己用文件存储JSON逻辑差不多只是要自己处理文件路径和读写。无论用哪种方式业务层的存取逻辑都是这套换底层实现不影响上层代码。4.4 启动时加载在runApp之前把配置准备好持久化的数据必须在App构建前加载否则会出现一个尴尬的闪现启动时会看到一秒默认主题然后才跳到用户选择的自定义主题。解决方法是在main里先异步加载配置再启动AppFuturevoid main() async { WidgetsFlutterBinding.ensureInitialized(); final config await loadConfig(); appConfigController AppConfigController(config); runApp(const AppRoot()); }注意不要简单粗暴地套一层async在runApp后面忘记调用WidgetsFlutterBinding.ensureInitialized()会在读取插件时直接抛异常。这个错误非常隐蔽我也是前几期踩过一次后才养成的习惯。5. OpenHarmony适配问题与排查实录5.1 安全区域抽屉顶部和底部被截断在OpenHarmony设备的全面屏场景下状态栏高度会占据屏幕顶部的一部分空间。如果Drawer内部不套SafeArea配置面板的“音效”开关会重合到状态栏下面看起来像是被砍掉了一截。我的处理方式是两层防护Drawer外层套SafeAreaListView内部再留EdgeInsets。不要只依赖其中一个因为有些定制ROM的MediaQuery信息不准确两层防护更稳。另外抽屉底部在带手势条的设备上也会被手势区域占用一定要给底部留白。我踩过的具体问题是在模拟器上一切正常真机上“恢复默认”按钮被手势条遮住一半正好是SafeArea只包顶部、没包底部的后果。5.2 抽屉手势与返回手势的冲突抽屉的侧滑手势和系统返回手势都是左边缘滑入这在OpenHarmony的全屏手势导航上会产生竞争。用户体验到的情况是想滑出抽屉结果触发了系统返回页面直接退出了。我的解决方案是调整抽屉手势边界。Flutter里Drawer的滑动手势区域默认为整个宽度但你可以通过修改GestureDetector的专属参数或者给Scaffold设置drawerEnableOpenDragGesture结合drawerEdgeDragWidth控制生效区域Scaffold( drawerEnableOpenDragGesture: true, drawerEdgeDragWidth: 20, ... )把拖拽触发宽度收窄到20像素用户需要从边缘精确滑动才能打开抽屉系统返回手势依旧能在更宽的边缘区域生效。这样两者冲突的概率会小很多。如果还是容易误触干脆关掉drawerEnableOpenDragGesture只保留左上角菜单图标作为唯一入口。权衡之后我保留了图标入口和窄边缘手势理由是桌面级大屏设备上用鼠标的用户很依赖边缘拖动这个度要自己根据目标设备调试。5.3 常见问题速查表整理几个我实际调试中遇到的问题每条都给一个直接的排查方向问题可能原因解决办法抽屉点击图标打不开leading被重写后未调用openDrawer在Builder里通过Scaffold.of(context).openDrawer()打开主题切了但转盘颜色不变CustomPainter的shouldRepaint返回了false在重绘判断里加入色板对比色板变化时触发重绘App重启后配置全部丢失读取配置时机太晚runApp之后才加载改用main中await loadConfig()后启动App抽屉宽度占满半个屏幕未设置width使用默认304宽度根据设备宽度设置Drawer(width: 280~300)Android/iOS正常但OpenHarmony状态栏遮挡安全区域适配不一致Drawer内嵌SafeArea统一用MediaQuerypadding处理恢复默认按钮按了没反应只改了单个配置项没重置整个模型直接重新构造AppConfig()并notifyListeners5.4 性能观察抽屉打开与主题动画的帧率配置面板使用的是ListenableBuilder监听整个Controller理论上任何配置项变动都会让整个MaterialApp重建。这个重建范围很大但实际代价没有想象中高因为MaterialApp子树在没有传递新颜色或新配置时Widget复用率很高Flutter只会重建依赖主题的组件。转盘本身是重绘热点旋转动画期间如果用户切主题两个动画叠加可能会掉帧。我把主题切换视作一个低频率操作没有做额外的锁或者优化实测在标准刷新率的设备上转盘重绘加主题动画还能稳定在满帧附近。如果在你的设备上掉帧明显可以在主题切换时先暂停转盘动画等颜色过渡完成再继续这也是一个不复杂的临时方案。写在最后的经验抽屉配置和主题切换这两块功能代码量不算大但设计上有一个核心原则把状态集中起来管理别让UI直接改配置字段。所有配置变更都走Controller所有界面都监听Controller这样哪怕以后配置项翻一倍也只需要在模型里加字段在抽屉里加控件剩下的事情交给监听机制。另外想多说一句主题切换在Demo里看起来只是几种颜色换来换去但在实际产品里它是用户感知品牌温度最直接的窗口。色板不能随手拉亮度值凑数要花心思配出“同一家族”的颜色。我的经验是先定一个主色再选一个对比色最后从这两个颜色衍生出扇形色板整体观感比乱选五个颜色协调得多。这期做完转盘App已经从“一个能转的Demo”变成了“一个配置得起来的应用”。下一期我打算继续补历史记录和结果统计到那个时候抽屉里又多一个入口整个设置模块会更有完整感。