前言
Toast作为最常用的功能,项目中使用的场景非常多。但随着业务的复杂度越来越高,系统自带的Toast无法满足我们的需求,例如:
- 支持自定义样式
- 支持设置显示的位置
- ...
这里就手把手的撸一个支持高度自定义的Toast
需求分析
- 基础需求(与toast保持一致)
- 展示toast信息
- 支持设置显示时长,自动消失
- 支持显示在内容顶部
- 拓展需求
- 支持自定义显示的位置
- 支持自定义样式
开整
一、基础需求开发
需要支持显示在内容顶部,搜索一番官方文档,找到两种方式:
- 浮层(OverlayManager):OverlayManager
- 不依赖UI组件的全局自定义弹出框 (openCustomDialog):openCustomDialog
这里先采用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)
}
}
}
上述代码比较好理解:
- ToastUtil采用单例模式,方便使用
- 调用openCustomDialog()显示我们通过builderText构建的component
- setTimeout在duration后,调用closeCustomDialog关闭弹出窗
- 使用也非常简单: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就完成啦。可以直接在项目中使用,这里只是提供了整体的思路,代码等整理好再分享出来