鸿蒙纪·梦始卷#09 | 电子木鱼 - 弹框与切换选择

482 阅读4分钟

《鸿蒙纪元》张风捷特烈 计划打造的一套 HarmonyOS 开发系列教程合集。致力于创作优质的鸿蒙原生学习资源,帮助开发者进入纯血鸿蒙的开发之中。本系列的所有代码将开源在 HarmonyUnit 项目中:

github: github.com/toly1994328…
gitee: gitee.com/toly1994328…

鸿蒙纪元 系列文章列表可在《文章总集》 或 【github 项目首页】 查看。


上一篇,我们简单了解了一下鸿蒙开发中通过 SoundPool 播放短音效,从而实现了 电子木鱼 的基本功能。本文将进一步完善功能,看一下鸿蒙开发中如何弹出底部框,实现 切换音效和图片 的功能:


1. 选择木鱼界面分析

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

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

我们先梳理一下木鱼的数据信息:根据目前的需求设定,木鱼需要 名称图片资源增加功德范围 的数据,这些数据可以通过一个类型进行维护,比如下面的 ImageOption 类:这种单纯记录数据信息的类我称之为数据模型,放在 model 文件夹中:

---->[muyu/model/image_option.ets]----
export class ImageOption {
  name: string = ''; // 名称
  src: ResourceStr = ''; // 资源
  min: number = 0; // 每次点击时功德最小值
  max: number = 0; // 每次点击时功德最大值

  constructor(name: string, src: ResourceStr, min: number, max: number) {
    this.name = name;
    this.src = src;
    this.min = min;
    this.max = max;
  }
}

业务层 MuyuBloc 需要持有界面所依赖的状态数据,如下所示:增加 activeImageIndex 表示当前选中的木鱼索引,用于视图中边框高亮处理;imageOptions 时木鱼图片信息数据 ImageOption 列表,用于视图中弹框中的内容;

---->[muyu/bloc/MuyuBloc.ets]----
export class MuyuBloc {
  @Track activeImageIndex: number = 0;

  @Track imageOptions: ImageOption[] = [
    new ImageOption('基础版', $r('app.media.muyu'), 1, 3),
    new ImageOption('尊享版', $r('app.media.muyu2'), 3, 6),
  ];
  
  imageSrc(): ResourceStr {
    return this.imageOptions[this.activeImageIndex].src;
  }
  
  changeImage(index: number): void {
    this.activeImageIndex = index;
  }
  
  //略同...

另外 changeImage 的行为会修改激活索引,在用户点击选择时触发;这里提供了一个 imageSrc 方法,便于视图层方便获取当前激活的图片,这样主界面的图片展示,只需要修改一行代码即可:


2. 底部弹框与图片选择

首先,需要在木鱼界面最上层组件通过 bindSheet 来绑定抽屉,其中:

  • 第一参数,可以通过 $$ 符双向绑定一个布尔值状态类。这样每当 isShowSheet 改变时,就可以控制底部抽屉的打开和关闭;
  • 第二参数是抽屉界面展示的内容,需要通过 @Builder 构建,相当于一个插槽;
  • 第三参数是抽屉的配置参数,这里简单设置一下抽屉的高度。
---->[muyu/view/MuyuPage.ets]----
@State isShowSheet: boolean = false;

build() {
  Column() {
    //略同...
  }.backgroundColor('#fafafa')
  .bindSheet($$this.isShowSheet, this.sheetBuilder(), {
    height: 400,
    showClose: false,
  })
}

  @Builder sheetBuilder() {
    //TODO build sheet
  }

下面来完成木鱼选择界面的布局,这个组件相对独立,我将其封装为 MuyuSelectorSheet 组件。首先从界面上来看,它需要 ImageOption 列表和激活的索引;另外,它还需要响应点击的事件,通知构建者那个索引的条目被点击了。

通过 type 关键字,可以定义函数类型,比如这里 IndexSelector 是一个接收索引的回调函数

这样在使用 MuyuSelectorSheet 时,构造函数中可以通过 onSelected 参数处理时间,并通过回调参数感知用户交互的激活索引。触发 MuyuBloc 的相关功能即可:

--->[muyu/view/MuyuSelectorSheet.ets]---
import { ImageOption } from "../model/image_option";

type IndexSelector = (index: number) => void;

@Component
export struct MuyuSelectorSheet {
  @Prop activeIndex: number = 0;
  private options: ImageOption[] = []
  private onSelected?: IndexSelector;

构建类似布局的条目列表,可以通过 ForEach 组件进行构建。对于单体的构建,一般会进行拆分。你可以通过一个组件来处理,也可以拆为一个 @Builder 构建器:

@Component
export struct MuyuSelectorSheet {
  @Prop activeIndex: number = 0;
  private options: ImageOption[] = []
  private onSelected?: IndexSelector;

  build() {
    Column() {
      Row() {
        Text('选择木鱼').fontSize('18fp').fontWeight(FontWeight.Bold)
      }.height(52).justifyContent(FlexAlign.Center).alignItems(VerticalAlign.Center)

      Row() {
        ForEach(this.options, (item: ImageOption, index: number) => this.buildItem(item, index),);
      }.margin({ top: 24 })
    }
    .padding({ left: 16, right: 16 })
    .width('100%')
    .height('100%')
    .borderRadius(8)
    .backgroundColor(Color.White)
  }
}

比如这里通过 buildItem 构建器,构建每个木鱼样式的展示:

@Builder buildItem(item: ImageOption, index: number) {
  Row() {
    Column() {
      Text(item.name).fontSize('16fp').fontWeight(FontWeight.Bold)
      Image(item.src)
        .width(120)
        .height(120)
        .borderRadius(4)
        .backgroundColor(Color.White)
        .margin({ top: 16, bottom: 16 })
      Text(`每次功德 +${item.min} ~ ${item.max}`).fontSize('14fp').fontColor('#b0b0b0')
    }
  }
  .alignItems(VerticalAlign.Center)
  .padding({ left: 24, right: 16, top: 12, bottom: 12 })
  .layoutWeight(1)
  .margin({ right: 8 })
  .border(index == this.activeIndex ? { width: 1, radius: 6, color: Color.Blue } : {})
  .onClick(() => this.onSelected!(index));
}

此时,将 MuyuSelectorSheet 放入之前的 sheetBuilder 中参与构建即可。

注意,不能直接放入,需要套 Column、Row 之类的容器,不知为啥,不然报错。

@Builder sheetBuilder() {
  Column() {
      MuyuSelectorSheet({
        activeIndex: this.model.activeImageIndex,
        options: this.model.imageOptions,
        onSelected: (index) => {
          this.model.changeImage(index);
          this.isShowSheet = false;
        }
      )
  }
}

3. 音效选择功能

音效的选择也是类似的,首先准备数据资源,AudioOption 中有名字和资源两个属性。点击条目右侧时可以试听音效:

主界面音效选择
--->[muyu/model/audio_option.ets]----
export class AudioOption {
  name: string = '';
  src: string = '';

  constructor(name: string, src: string) {
    this.name = name;
    this.src = src;
  }
}

MuyuBloc 中也需要定义声音相关的数据,比如 AudioOption 列表、激活的声音索引;另外不想每次点击都加载一下声音资源,这里准备了 soundIds 列表,在 loadSounds 中统一加载。
changeVoice 方法用于切换到用户选择的音频索引;previewVoice 用于预览对应索引的音频。这样数据逻辑层面就完成了。下面看一下视图构建逻辑:

---->[muyu/bloc/MuyuBloc.ets]----
  private soundIds: number[] = [];
  
  @Track activeAudioIndex: number = 0;
  
  @Track audioOptions: AudioOption[] = [
    new AudioOption('空灵弥音', 'muyu_1.mp3'),
    new AudioOption('落子禅定', 'muyu_2.mp3'),
    new AudioOption('滴水明镜', 'muyu_3.mp3'),
  ];
  
  changeVoice(index: number): void {
    this.activeAudioIndex = index;
  }
  
  previewVoice(index: number): void {
    this.player?.play(this.soundIds[index]);
  }
  
  async loadSounds() {
    this.soundIds = [];
    for (let i = 0; i < this.audioOptions.length; i++) {
      let audio: AudioOption = this.audioOptions[i];
      let id = await this.loadSoundId(audio.src);
      this.soundIds.push(id);
    }
  }

同样,音频选择也进行独立封装,通过 VoiceSelectorSheet 组件进行构建,构建时传入激活索引、音频信息列表;以及两个点击事件分别通知外界 点击选中点击预览的事件:

import { AudioOption } from "../model/audio_option";

type IndexSelector = (index: number) => void;

@Component
export struct VoiceSelectorSheet {
  @Prop activeIndex: number = 0;
  private options: AudioOption[] = []
  private onSelected?: IndexSelector;
  private onPreview?: IndexSelector;

  @Builder
  voiceItem(item: AudioOption, index: number) {
    Row() {
      Text(item.name).layoutWeight(1).fontColor(this.activeIndex==index?Color.Blue:Color.Black)
      Button({ type: ButtonType.Normal, stateEffect: true }) {
        SymbolGlyph($r('sys.symbol.music_fill'))
          .fontSize(20) .fontColor([Color.Blue]) .fontWeight(FontWeight.Bold)
      }
      .width(36).height(36) .borderRadius(4) .backgroundColor(Color.White)
      .onClick(() => this.onPreview!(index))

    }.height(46).alignItems(VerticalAlign.Center).padding({ left: 24, right: 16 })
    .onClick(() => this.onSelected!(index));
  }

  build() {
    Column() {
      Row() {
        Text('选择音效').fontSize('18fp').fontWeight(FontWeight.Bold)
      }.height(52).justifyContent(FlexAlign.Center).alignItems(VerticalAlign.Center)

      ForEach(this.options, (item: AudioOption, index: number) => this.voiceItem(item, index),);
    }
    .width('100%').height('100%').borderRadius(8).backgroundColor(Color.White)
  }
}

最后,我们需要定义一个状态量,表示点击的是切换音频还是切换图片:然后在 sheetBuilder 构建函数中根据状态区分构建哪个组件。到这里,提交一个小里程碑:v15-电子木鱼-切换音频、图片


尾声

本章通过弹出框展示切换音效和木鱼样式,可以进一步理解数据和界面视图之间的关系:数据为界面提供内容信息、界面交互操作更改数据信息。
另外,两个选项的面板通过自定义组件进行封装隔离,面板在需要的数据通过构造函数传入、界面中的事件通过回调函数交由外界执行更新数据逻辑。也就是说,封装的 Widget 在意的是如何构建展示内容,专注于做一件事,与界面构建无关的任务,交由使用者处理。

更多文章和视频知识资讯,大家可以关注我的公众号、掘金和 B 站 。关注 公众号 并回复 鸿蒙纪元 可领取最新的 xmind 脑图电子版,让我们一起成长,变得更强。我们下次再见~