15 弹出选项与切换状态

1. 选择木鱼界面分析

现在想要的效果如下所示,点击第二个绿色按钮时,从底部弹出木鱼图片的选项。从界面上来说,有如下几个要点:

  • 需要展示木鱼样式的基本信息,包括名称、图片及每次功德数范围。
  • 需要将当前展示选择的木鱼边上蓝色边线,表示选择状态。
  • 选择切换木鱼时,同时更新主界面木鱼样式。
木鱼界面 选择界面
fa4426744315b5f1fdf8659efac37cb.jpg 800956908b6a68d94dfad9cba9ef6f3.jpg

仔细分析可以看出,两个选项的布局结构是类似的,不同点在于木鱼的信息以及激活状态。所以只要封装一个组件,就可以用来构建上面的两个选择项,这就是封装的可复用性。不过在此之前先梳理一下木鱼的数据信息: 根据目前的需求设定,木鱼需要 名称图片资源增加功德范围 的数据,这些数据可以通过一个类型进行维护,比如下面的 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 组件用于构建一种样式的界面:

image.png

代码如下,红框中的单体是上中下的结果,通过 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 组件,让单体平分水平空间:

image.png

@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 方法,方便通过 _activeImageIndeximageOptions 获取需要的信息:

---->[_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

选择 切换后敲击
103.gif 104.gif

这里有个小问题,可以作为思考,这个问题将会在下一篇解决: 如上左图,在切换图片时,会看到 功德+2 的动画,这是为什么呢? 有哪些方式可以解决这个问题。


3. 选择音效功能

选择音效的整体思路和上面类似,点击主界面右上角第一个按钮,从底部弹出选择界面,这里准备了三个音效以供选择。当前音效也会高亮显示,另外右侧有一个播放按钮可以试听:

主界面 选择音效界面
ceee40e6c28ae8e2385d8459ea906a4.jpg d8fcfc6c4429e575fd93ffb804b7ff5.jpg

首先还是对数据进行一下封装,对于音频选择界面而言,两个信息数据: 名称资源。这里通过 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 组件,来构建左中右的视图。

image.png

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 。目前为止,木鱼项目的代码已经有一点点复杂了,大家可以结合源码,好好消化一下。下一篇将继续对木鱼项目进行功能拓展,并以此学习一下滑动列表。