因收到Google相关通知,网站将会择期关闭。相关通知内容 15 弹出选项与切换状态 1. 选择木鱼界面分析 现在想要的效果如下所示,点击第二个绿色按钮时,从底部弹出木鱼图片的选项。从界面上来说,有如下几个要点: 需要展示木鱼样式的基本信息,包括名称、图片及每次功德数范围。 需要将当前展示选择的木鱼边上蓝色边线,表示选择状态。 选择切换木鱼时,同时更新主界面木鱼样式。 木鱼界面 选择界面 仔细分析可以看出,两个选项的布局结构是类似的,不同点在于木鱼的信息以及激活状态。所以只要封装一个组件,就可以用来构建上面的两个选择项,这就是封装的可复用性。不过在此之前先梳理一下木鱼的数据信息: 根据目前的需求设定,木鱼需要 名称、图片资源、增加功德范围 的数据,这些数据可以通过一个类型进行维护,比如下面的 ImageOption 类: --->[muyu/models/image_option.dart]--- class ImageOption{ final String name; // 名称 final String src; // 资源 final int min; // 每次点击时功德最小值 final int max; // 每次点击时功德最大值 const ImageOption(this.name, this.src, this.min, this.max); } 对于一个样式来说,依赖的数据是 ImageOption 对象和 bool 类型的激活状态。这里封装一个 ImageOptionItem 组件用于构建一种样式的界面: 代码如下,红框中的单体是上中下的结果,通过 Column 组件竖向排列,另外使用装饰属性的 border ,根据是否激活,添加边线。其中的文字、图片数据都是通过 ImageOption 类型的成员确定的。 --->[muyu/options/select_image.dart]--- class ImageOptionItem extends StatelessWidget { final ImageOption option; final bool active; const ImageOptionItem({ Key? key, required this.option, required this.active, }) : super(key: key); @override Widget build(BuildContext context) { const Border activeBorder = Border.fromBorderSide(BorderSide(color: Colors.blue)); return Container( padding: const EdgeInsets.symmetric(vertical: 8), decoration: BoxDecoration( borderRadius: BorderRadius.circular(8), border: !active ? null : activeBorder, ), child: Column( children: [ Text(option.name, style: TextStyle(fontWeight: FontWeight.bold)), Expanded( child: Padding( padding: const EdgeInsets.symmetric(horizontal: 8.0), child: Image.asset(option.src), ), ), Text('每次功德 +${option.min}~${option.max}', style: const TextStyle(color: Colors.grey,fontSize: 12)), ], ), ); } } 2.底部弹框及界面构建 由于有了新需求,_MuyuPageState 状态类需要添加一些状态数据来实现功能。如下,新增了 imageOptions 对象表示木鱼选项的列表;已及 _activeImageIndex 表示当前激活的木鱼索引: ---->[_MuyuPageState]---- final List imageOptions = const [ ImageOption('基础版','assets/images/muyu.png',1,3), ImageOption('尊享版','assets/images/muyu_2.png',3,6), ]; int _activeImageIndex = 0; 点击时的底部弹框,可以使用 showCupertinoModalPopup 方法实现。其中 builder 入参中返回底部弹框中的内容组件,这里通过自定义的 ImageOptionPanel 组件来完成弹框内界面的构建逻辑。 ---->[_MuyuPageState]---- void _onTapSwitchImage() { showCupertinoModalPopup( context: context, builder: (BuildContext context) { return ImageOptionPanel( imageOptions: imageOptions, activeIndex: _activeImageIndex, onSelect: _onSelectImage, ); }, ); } 很容易可以看出选择木鱼的面板中需要 ImageOption 列表、当前激活索引两个数据;由于点击选择时需要更新主界面的数据,所以这里通过 onSelect 回调,让处理逻辑交由使用者来实现。 --->[muyu/options/select_image.dart]--- class ImageOptionPanel extends StatelessWidget { final List imageOptions; final ValueChanged onSelect; final int activeIndex; const ImageOptionPanel({ Key? key, required this.imageOptions, required this.activeIndex, required this.onSelect, }) : super(key: key); 界面的构建逻辑如下,整体结构并不复杂。主要是上下结构,上面是标题,下面是选项的条目。由于这里只要两个条目,使用 Row + Expanded 组件,让单体平分水平空间: @override Widget build(BuildContext context) { const TextStyle labelStyle = TextStyle(fontSize: 16, fontWeight: FontWeight.bold); const EdgeInsets padding = EdgeInsets.symmetric(horizontal: 8.0, vertical: 16); return Material( child: SizedBox( height: 300, child: Column( children: [ Container( height: 46, alignment: Alignment.center, child: const Text( "选择木鱼", style: labelStyle)), Expanded( child: Padding( padding: padding, child: Row( children: [ Expanded(child: _buildByIndex(0)), const SizedBox(width: 10), Expanded(child: _buildByIndex(1)), ], ), )) ], ), ), ); } 在 _buildByIndex 中,简单封装一下根据索引生成条目组件的逻辑,并通过点击事件,触发 onSelect 回调,将条目对应的索引传递出去。 Widget _buildByIndex(int index) { bool active = index == activeIndex; return GestureDetector( onTap: () => onSelect(index), child: ImageOptionItem( option: imageOptions[index], active: active, ), ); } 到这里,万事俱备只欠东风,在点击木鱼样式回调中,需要更新 _activeImageIndex 的值即可,这里如果点击的是当前样式,则不进行更新操作。另外,在选择时通过 Navigator.of(context).pop() 可以关闭底部弹框。 ---->[_MuyuPageState]---- void _onSelectImage(int value) { Navigator.of(context).pop(); if(value == _activeImageIndex) return; setState(() { _activeImageIndex = value; }); } 最后,主页面中的图片和点击时增加的值需要根据 _activeImageIndex 来确定。这里在 _MuyuPageState 中给两个 get 方法,方便通过 _activeImageIndex 和 imageOptions 获取需要的信息: ---->[_MuyuPageState]---- // 激活图像 String get activeImage => imageOptions[_activeImageIndex].src; // 敲击是增加值 int get knockValue { int min = imageOptions[_activeImageIndex].min; int max = imageOptions[_activeImageIndex].max; return min + _random.nextInt(max+1 - min); } //... MuyuAssetsImage( image: activeImage, // 使用激活图像 onTap: _onKnock, ), void _onKnock() { pool?.start(); setState(() { _cruValue = knockValue; // 使用激活木鱼的值 _counter += _cruValue; }); } 这样,就可以选择木鱼样式的功能,在敲击时每次功德的增加量也会不同。如下右图中,尊享版木鱼每次敲击,数字增加在 3~6 之间随机。当前代码位置 muyu 选择 切换后敲击 这里有个小问题,可以作为思考,这个问题将会在下一篇解决: 如上左图,在切换图片时,会看到 功德+2 的动画,这是为什么呢? 有哪些方式可以解决这个问题。 3. 选择音效功能 选择音效的整体思路和上面类似,点击主界面右上角第一个按钮,从底部弹出选择界面,这里准备了三个音效以供选择。当前音效也会高亮显示,另外右侧有一个播放按钮可以试听: 主界面 选择音效界面 首先还是对数据进行一下封装,对于音频选择界面而言,两个信息数据: 名称 和 资源。这里通过 AudioOption 类进行 --->[muyu/models/audio_option.dart]--- class AudioOption{ final String name; final String src; const AudioOption(this.name, this.src); } 在 _MuyuPageState 中,通过 audioOptions 列表记录音频选项对应的数据;_activeAudioIndex 表示当前激活的音频索引: ---->[_MuyuPageState]---- final List audioOptions = const [ AudioOption('音效1', 'muyu_1.mp3'), AudioOption('音效2', 'muyu_2.mp3'), AudioOption('音效3', 'muyu_3.mp3'), ]; int _activeAudioIndex = 0; 如何同样,在点击按钮时,通过 showCupertinoModalPopup 弹出底部栏,使用 AudioOptionPanel 构建展示内容: ---->[_MuyuPageState]---- void _onTapSwitchAudio() { showCupertinoModalPopup( context: context, builder: (BuildContext context) { return AudioOptionPanel( audioOptions: audioOptions, activeIndex: _activeAudioIndex, onSelect: _onSelectAudio, ); }, ); } 目前只有三个数据,这里通过 Column 竖直排放即可,如果你想支持更多的选项,可以使用滑动列表(下篇介绍)。 另外,对于条目而言,Flutter 提供了一些通用的结构,比如这里可以使用 ListTile 组件,来构建左中右的视图。 AudioOptionPanel 界面构建逻辑如下,其中提供 List.generate 构造函数生成条目组件列表;每个条目由 _buildByIndex 方法根据 index 索引进行构建;构建的内容使用 ListTile 根据 AudioOption 数据进行展示。 注: 这里 listA = [1,2,…listB] 表示在创建 listA 的过程中,将 listB 列表元素嵌入其中。 --->[muyu/options/select_audio.dart]--- class AudioOptionPanel extends StatelessWidget { final List audioOptions; final ValueChanged onSelect; final int activeIndex; const AudioOptionPanel({ Key? key, required this.audioOptions, required this.activeIndex, required this.onSelect, }) : super(key: key); @override Widget build(BuildContext context) { const TextStyle labelStyle = TextStyle(fontSize: 16, fontWeight: FontWeight.bold); return Material( child: SizedBox( height: 300, child: Column( children: [ Container( height: 46, alignment: Alignment.center, child: const Text("选择音效", style: labelStyle, )), ...List.generate(audioOptions.length, _buildByIndex) ], ), ), ); } Widget _buildByIndex(int index) { bool active = index == activeIndex; return ListTile( selected: active, onTap: () => onSelect(index), title: Text(audioOptions[index].name), trailing: IconButton( splashRadius: 20, onPressed: ()=> _tempPlay(audioOptions[index].src), icon: const Icon( Icons.record_voice_over_rounded, color: Colors.blue, ), ), ); } void _tempPlay(String src) async{ AudioPool pool = await FlameAudio.createPool(src, maxPlayers: 1); pool.start(); } } 最后,在 _MuyuPageState 中,选择音频回调时,根据激活索引,更新 pool 对象即可。这样在点击时就可以播放对应的音效。 ---->[_MuyuPageState]---- String get activeAudio => audioOptions[_activeAudioIndex].src; void _onSelectAudio(int value) async{ Navigator.of(context).pop(); if (value == _activeAudioIndex) return; _activeAudioIndex = value; pool = await FlameAudio.createPool( activeAudio, maxPlayers: 1, ); } 4.本章小结 本章通过弹出框展示切换音效和木鱼样式,可以进一步理解数据和界面视图之间的关系:数据为界面提供内容信息、界面交互操作更改数据信息。 另外,两个选项的面板通过自定义组件进行封装隔离,面板在需要的数据通过构造函数传入、界面中的事件通过回调函数交由外界执行更新数据逻辑。也就是说,封装的 Widget 在意的是如何构建展示内容,专注于做一件事,与界面构建无关的任务,交由使用者处理。 到这里,本篇新增的两个功能就完成了,当前代码位置 muyu 。目前为止,木鱼项目的代码已经有一点点复杂了,大家可以结合源码,好好消化一下。下一篇将继续对木鱼项目进行功能拓展,并以此学习一下滑动列表。