【Harmony Next】手把手撸一个支持高度自定义的Toast

前言

Toast作为最常用的功能,项目中使用的场景非常多。但随着业务的复杂度越来越高,系统自带的Toast无法满足我们的需求,例如:

  1. 支持自定义样式
  2. 支持设置显示的位置
  3. ...

这里就手把手的撸一个支持高度自定义的Toast

需求分析

  1. 基础需求(与toast保持一致)
  • 展示toast信息
  • 支持设置显示时长,自动消失
  • 支持显示在内容顶部
  1. 拓展需求
  • 支持自定义显示的位置
  • 支持自定义样式

开整

一、基础需求开发

需要支持显示在内容顶部,搜索一番官方文档,找到两种方式:

这里先采用openCustomDialog进行实现

typescript 复制代码
openCustomDialog<T extends Object>(dialogContent: ComponentContent<T>, options?: promptAction.BaseDialogOptions): Promise<void>;

interface BaseDialogOptions {
    // 设置显示位置
    alignment?: DialogAlignment;
    // 设置显示位置偏移量
    offset?: Offset;
    // 设置显示的层级
    levelMode?: LevelMode;
}

那我们先使用openCustomDialog来实现最简单的toast功能:

typescript 复制代码
/// 先定义toast支持设置的参数
export default interface ToastParam {
  content: string, // 显示信息
  duration?: number, // 显示时长
}


@Builder
function builderText(toastParam: ToastParam) {
  Text(toastParam.message)
    .fontSize(15)
    .fontColor($r('app.color.font_white_color'))
    .padding({
      left: 10,
      right: 10,
      top: 4,
      bottom: 4
    })
    .backgroundColor($r('app.color.bg_brand_color'))
    .height(40)
    .borderRadius(20)
    .maxLines(1)
    .constraintSize({ maxWidth: '70%' })
}

/// 搞个ToastUtil
export class ToastUtil {
  private static instance: ToastUtil = new ToastUtil()

  private constructor() {
  }

  static getInstance(): ToastUtil {
    return ToastUtil.instance
  }

  private uiContext: UIContext | null = null

  init(context: UIContext) {
    this.uiContext = context
  }

  showToast(toastParam: ToastParam) {
    if (this.uiContext) {
      let customBuild = wrapBuilder<[ToastParam]>(builderText)
      let componentContent = new ComponentContent(
        this.uiContext!!, customBuild, toastParam
      )
      this.uiContext.getPromptAction().openCustomDialog(componentContent, {
        isModal: false, // 是否为模态,模态时则无法与下层page进行交互,因此设为false
        alignment: DialogAlignment.Bottom, // 直接底部贴边展示了,需要配合下边的offset
		offset: { dx: 0, dy: -100 } // 距离底部100
      })
      setTimeout(() => {
        this.uiContext?.getPromptAction().closeCustomDialog(componentContent)
      }, toastParam.duration ?? 3000)
    }
  }
}

上述代码比较好理解:

  1. ToastUtil采用单例模式,方便使用
  2. 调用openCustomDialog()显示我们通过builderText构建的component
  3. setTimeout在duration后,调用closeCustomDialog关闭弹出窗
  4. 使用也非常简单:ToastUtil.getInstance().showToast("toast");

这里还有个问题,每次调用showToast都会弹出一个新的dialog,而且会产生一系列的setTimeOut回调。因此这里优化一下,showToast前先取消上次的setTimeOut并关闭之前显示的dialog

kotlin 复制代码
private timerId: number = -1
private dialogArray: Array<ComponentContent<ToastParam>> = []
showToast(toastParam: ToastParam) {
  clearTimeout(this.timerId)
  this.clearPreDialog()
  if (this.uiContext) {
    let customBuild = wrapBuilder<[ToastParam]>(builderText)
    if (toastParam.click || toastParam.clickEvent) {
      customBuild = wrapBuilder<[ToastParam]>(builderTextClick)
    }
    let componentContent = new ComponentContent(
      this.uiContext!!, customBuild, toastParam
    )
    this.dialogArray.push(componentContent)
    this.uiContext.getPromptAction().openCustomDialog(componentContent, {
      isModal: false,
      alignment: toastParam.alignment ?? DialogAlignment.Bottom,
      offset: toastParam.offset ?? { dx: 0, dy: -100 }
    })
    this.timerId = setTimeout(() => {
      this.uiContext?.getPromptAction().closeCustomDialog(componentContent)
    }, toastParam.duration ?? 3000)
  }
}
clearPreDialog() {
  this.dialogArray.forEach((element) => {
    this.uiContext?.getPromptAction().closeCustomDialog(element)
  })
  this.dialogArray = []
}

二、拓展需求开发

1 支持自定义显示的位置

上述分析提到过,openCustomDialog有两个参数可以配合我们实现该需求:

arduino 复制代码
// 设置显示位置
alignment?: DialogAlignment;
// 设置显示位置偏移量
offset?: Offset;

那我们就拓展自定义参数,添加这两个设置:

typescript 复制代码
export default interface ToastParam {
  content: string, // 显示信息
  duration?: number, // 显示时长
  offset?: Offset, // 偏移量
  alignment?: DialogAlignment // 显示位置
}

// 调用openCustomDialog时,使用我们传递的参数进行配置
showToast(toastParam: ToastParam) {
  if (this.uiContext) {
    let customBuild = wrapBuilder<[ToastParam]>(builderText)
    let componentContent = new ComponentContent(
      this.uiContext!!, customBuild, toastParam
    )
    this.uiContext.getPromptAction().openCustomDialog(componentContent, {
      isModal: false,
      alignment: toastParam.alignment ?? DialogAlignment.Bottom,
      offset: toastParam.offset ?? { dx: 0, dy: -100 }
    })
    setTimeout(() => {
      this.uiContext?.getPromptAction().closeCustomDialog(componentContent)
    }, toastParam.duration ?? 3000)
  }
}
2 支持自定义样式

openCustomDialog接受的componentContent参数里,builder: WrappedBuilder<[T]>就是用来构建显示视图的,那最直接的方案就是,ToastParam里添加一个builder作为参数,那我们改下代码

typescript 复制代码
export default interface ToastParam {
  content: string, // 显示信息
  duration?: number, // 显示时长
  offset?: Offset, // 偏移量
  alignment?: DialogAlignment, // 显示位置
  viewBuilder?: WrappedBuilder<[ToastParam]> // 自定义component
}

showToast(toastParam: ToastParam) {
  if(toastParam.message == null && toastParam.viewBuilder == null){
	// 自定义视图、message不能同时为空
    return
  }
  clearTimeout(this.timerId)
  this.clearPreDialog()
  if (this.uiContext) {
    let customBuild = wrapBuilder<[ToastParam]>(builderText)
    if (toastParam.viewBuilder != null) {
      // 当传递的builder不为空,则直接使用作为视图
      customBuild = toastParam.viewBuilder
    }
    let componentContent = new ComponentContent(
      this.uiContext!!, customBuild, toastParam
    )
    ...
  }
}

使用需要通过wrapBuilder包装一层component,并且有个注意点:wrapBuilder方法只能传入全局@Builder方法

less 复制代码
// 定义一个全局@Builder方法
@Builder
export function buildCustomToastView(toastParam: ToastParam) {
  CustomToastView({ toastParam: toastParam })
}

// 使用
ToastUtil.getInstance().showToast({
  viewBuilder: wrapBuilder(buildCustomToastView)
})

假如觉得每次调用ToastUtil.getInstance().showToast()太繁琐,可以导出一个showToast函数

scss 复制代码
export function showToast(toastParam: ToastParam) {
  ToastUtil.getInstance().showToast(toastParam)
}

// 使用更简洁
showToast({viewBuilder: wrapBuilder(buildCustomToastView)})

用着一段时间挺开心~然而问题来,测试反馈:为啥这个toast响应了返回键导致page没有返回反而是toast被关闭了?这是因为toast本质还是一个dialog来的,正常就会响应返回键。那我们是不是切换成OverlayManager的方案做?那就搞起来

OverlayManager与Dialog方案的主要差异在于OverlayManager是没有办法设置ui显示位置的,默认居中显示。因此我们需要着手解决这个问题:可以套一层Column并通过设置它的offset来调整展示位置,至于offset的大小则通过屏幕高度来调整

kotlin 复制代码
@Builder
function builderText(toastAttribute: ToastAttribute) {
  Column() {
    Text(toastAttribute.message)
      .fontSize(15)
      .fontColor($r('app.color.font_white_color'))
      .padding({
        left: 10,
        right: 10,
        top: 4,
        bottom: 4
      })
      .backgroundColor($r('app.color.bg_brand_color'))
      .height(40)
      .borderRadius(20)
      .maxLines(1)
  }.offset({ y: toastAttribute.offset ?? 0 })
}

export class ToastUtils {
  private static instance: ToastUtils = new ToastUtils()

  private constructor() {
  }

  static getInstance(): ToastUtils {
    return ToastUtils.instance
  }

  private uiContext: UIContext | null = null
  private overlayNode: OverlayManager | null = null

  init(context: UIContext) {
    this.uiContext = context
    this.overlayNode = this.uiContext.getOverlayManager()
  }

  private timerId: number = -1
  private dialogArray: Array<ComponentContent<ToastAttribute>> = []

  showToast(toastAttribute: ToastAttribute) {
    if (toastAttribute.message == null && toastAttribute.viewBuilder == null) {
      return
    }
    clearTimeout(this.timerId)
    this.clearPreDialog()
    if (this.uiContext) {
      let customBuild = wrapBuilder<[ToastAttribute]>(builderText)
      if (toastAttribute.viewBuilder != null) {
        customBuild = toastAttribute.viewBuilder
      } else {
        if (toastAttribute.alignment != null) {
          // 通过屏幕高度计算具体的位置
          switch (toastAttribute.alignment) {
            case Alignment.TopStart:
            case Alignment.Top:
            case Alignment.TopEnd:
              toastAttribute.offset = -this.uiContext.px2vp(display.getDefaultDisplaySync().height) / 2 + 100
              break;
            case Alignment.Center:
              break;
            default:
            // 默认底部
              toastAttribute.offset = this.uiContext.px2vp(display.getDefaultDisplaySync().height) / 2 - 100
              break;
          }
        } else {
          toastAttribute.offset = this.uiContext.px2vp(display.getDefaultDisplaySync().height) / 2 - 100
        }
      }
      let componentContent = new ComponentContent(
        this.uiContext!!, customBuild, toastAttribute
      )
      this.dialogArray.push(componentContent)
      this.overlayNode?.addComponentContent(componentContent)
      this.timerId = setTimeout(() => {
        this.overlayNode?.removeComponentContent(componentContent)
      }, toastAttribute.duration ?? 3000)
    }
  }

  clearPreDialog() {
    this.dialogArray.forEach((element) => {
      this.overlayNode?.removeComponentContent(element)
    })
    this.dialogArray = []
  }
}

那假如需要自定义view是不是也要这么处理?其实我们可以封装一个基类component即可,代码如下:

kotlin 复制代码
import { ToastAttribute } from "./ToastAttribute"
import { display } from "@kit.ArkUI"

@ComponentV2
export struct BaseToastComponent {
  @Param @Require toastAttribute: ToastAttribute
  private screenHeight: number = 0

  aboutToAppear(): void {
    this.screenHeight = this.getUIContext().px2vp(display.getDefaultDisplaySync().height)
    if (this.toastAttribute.alignment != null) {
      switch (this.toastAttribute.alignment) {
        case Alignment.TopStart:
        case Alignment.Top:
        case Alignment.TopEnd:
          this.toastAttribute.offset = -this.screenHeight / 2 + 100
          break;
        case Alignment.Center:
          break;
        default:
        // 默认底部
          this.toastAttribute.offset = this.screenHeight / 2 - 100
          break;
      }
    } else {
      this.toastAttribute.offset = this.screenHeight / 2 - 100
    }
  }

  @Builder
  builderText() {
    Text(this.toastAttribute.message)
      .fontSize(15)
      .fontColor($r('app.color.font_white_color'))
      .padding({
        left: 10,
        right: 10,
        top: 4,
        bottom: 4
      })
      .backgroundColor($r('app.color.bg_brand_color'))
      .height(40)
      .borderRadius(20)
      .maxLines(1)
  }

  @BuilderParam toastBuild: () => void = this.builderText

  build() {
	// 默认做好了偏移
    Column() {
      this.toastBuild()
    }
    .offset({ y: this.toastAttribute.offset })
  }
}

使用起来的就方便了

scss 复制代码
// 定义一个全局@Builder方法,将BaseToastComponent作为父布局
@Builder
export function buildToastView(toastAttribute: ToastAttribute) {
  BaseToastComponent({ toastAttribute: toastAttribute }){
    Column() {
      Image($r('app.media.ic_harmony'))
        .width(50)
        .height(50)
        .margin({ bottom: 20 })
      Text("自定义View")
        .fontSize(18)
        .fontColor($r('app.color.font_white_color'))
    }
    .width(200)
    .height(100)
    .borderRadius(20)
    .justifyContent(FlexAlign.Center)
    .backgroundColor($r('app.color.font_brand_color'))
  }
}

// 使用
ToastUtils.getInstance().showToast({
  viewBuilder: wrapBuilder(buildToastView)
})

总结

至此,支持高度自定义的toast就完成啦。可以直接在项目中使用,这里只是提供了整体的思路,代码等整理好再分享出来

相关推荐
安卓开发者6 小时前
鸿蒙NEXT传感器开发概述:开启智能感知新时代
华为·harmonyos
安卓开发者19 小时前
鸿蒙NEXT按键拦截与监听开发指南
华为·harmonyos
2503_9284115619 小时前
10.13 Tabs选项卡布局
华为·harmonyos·鸿蒙
我爱学习_zwj21 小时前
【鸿蒙进阶-7】鸿蒙与web混合开发
前端·华为·harmonyos
HMSCore1 天前
消息推送策略:如何在营销与用户体验间找到最佳平衡点
harmonyos
HMSCore1 天前
同一设备多账号登录,如何避免消息推送“串门”?
harmonyos
零點壹度ideality1 天前
鸿蒙实现可以上下左右滑动的表格-摆脱大量ListScroller
前端·harmonyos
●VON1 天前
重生之我在大学自学鸿蒙开发第七天-《AI语音朗读》
学习·华为·云原生·架构·harmonyos
君逸臣劳1 天前
玩Android Harmony next版,通过项目了解harmony项目快速搭建开发
android·harmonyos