419 lines
12 KiB
Markdown
419 lines
12 KiB
Markdown
|
||
|
||
因收到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<ImageOption> 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<ImageOption> imageOptions;
|
||
final ValueChanged<int> 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<AudioOption> 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<AudioOption> audioOptions;
|
||
final ValueChanged<int> 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 。目前为止,木鱼项目的代码已经有一点点复杂了,大家可以结合源码,好好消化一下。下一篇将继续对木鱼项目进行功能拓展,并以此学习一下滑动列表。
|
||
|
||
|
||
|
||
|