实现了一个可以拖拽的悬浮球组件,支持自动贴边和隐藏等功能,适用于窗口场景。以下是对其功能的详细描述:
1. 组件介绍
该悬浮球(FloatBall
)组件是一种浮动在窗口中的 UI 元素,用户可以通过拖动该元素,在屏幕上自由移动。同时,组件支持自动隐藏和自动贴边的功能,当用户不操作悬浮球时,它可以半透明隐藏或者贴靠屏幕的边缘。该组件设计为通用,可以在鸿蒙系统中应用于各种场景。
2. 状态变量
组件中使用了多个状态变量来控制悬浮球的显示、移动和透明度等功能:
statusHeight
:状态栏的高度,用于计算窗口内有效区域。bottomAvoidAreaHeight
:底部规避区域的高度,防止悬浮球被遮挡。curLeft
和curTop
:当前悬浮球的相对于窗口左上角的横纵坐标。opacityN
:悬浮球的透明度,通过该变量控制悬浮球在不同状态下的显示效果。
3. 移动与触摸控制
该组件实现了悬浮球的拖动功能,通过监听触摸事件(onTouch
)来响应用户操作。代码中的逻辑包括:
- 记录触摸按下时悬浮球的位置和触摸点的坐标。
- 根据用户的拖动,实时更新悬浮球的位置,并限制其在屏幕边界范围内移动。
- 松开手指后,根据设置,悬浮球会自动隐藏或者贴边。
4. 自动隐藏和贴边功能
该组件支持自动隐藏和自动贴边两种特性,分别在用户不操作时触发:
- 自动隐藏:在一段时间后悬浮球会逐渐变为半透明并隐藏,隐藏的时间间隔可以通过
aotoHideTime
属性来配置。 - 自动贴边:悬浮球松开后会自动靠近最近的屏幕边缘,通过动画完成平滑的贴边操作,贴边超时时间通过
aotoEdgingTime
配置。
5. 生命周期方法
aboutToAppear()
方法在组件初始化时调用,负责获取当前窗口的信息,例如状态栏高度、规避区域高度、窗口宽度等,并根据这些信息设置悬浮球的初始位置(默认为屏幕右下角)。
6. 透明度控制
组件支持透明度变化,当用户操作悬浮球时,透明度恢复为默认值 (opacityDefault
);当悬浮球进入半隐藏状态时,透明度会降低 (opacityHide
)。
7. 事件传递机制
组件可以通过回调函数的方式将点击、隐藏、贴边等事件传递给外部使用者:
onClickEvent
:悬浮球点击事件。onEdgingEvent
:悬浮球贴边事件。onHideEvent
:悬浮球隐藏事件。onShowEvent
:悬浮球显示事件。
这些事件处理函数允许外部代码在触发相应事件时,执行自定义逻辑。
8. 动画处理
悬浮球的显示、隐藏、以及贴边操作,均通过动画实现,使交互过程更加流畅和友好。代码使用 animateTo
方法,指定动画持续时间,并在动画结束时调用相应的回调函数。
9. 扩展性与定制化
通过该代码,开发者可以方便地修改悬浮球的外观(如图片资源、半径大小),以及其行为(如自动隐藏时间、是否允许超过边界、是否启用拖动等),满足多种应用场景的需求。
悬浮球组件
import { window } from '@kit.ArkUI'
const TAG: string = '[FloatBall]';
/**
* 悬浮球
*/
@Entry
@Component
export struct FloatBall {
@State statusHeight: number = 0 // 状态栏高度
@State bottomAvoidAreaHeight: number = 0 // 手机底部规避区域高度
@State curLeft: number = 0 // 当前悬浮按钮距离窗口左边距离
@State curTop: number = 0 // 当前悬浮按钮距离窗口顶部距离
@State opacityN: number = 0 // 当前透明度
private startLeft: number = 0 // 开始移动那一刻悬浮按钮距离窗口左边距离
private startTop: number = 0 // 开始移动那一刻悬浮按钮距离窗口顶部距离
private startX: number = 0 // 开始移动触摸点x坐标,相对窗口左上角
private startY: number = 0 // 开始移动触摸点y坐标,相对窗口左上角
private winWidth: number = 0 // 窗口宽度
private winHeight: number = 0 // 窗口高度
private isTouc = false // 是否触摸中
private time = 0 // 隐藏定时器
private state = 0 // 当前状态 0 未处理 1 显示 2 贴边 3 隐藏
fixedLeft:boolean = false // 固定左边
fixedRight:boolean = false // 固定右边
image: Resource = null! // 图片资源
radius: number = 25 // 悬浮按钮半径
opacityHide: number = 1.0 // 半隐藏后的透明度
opacityDefault: number = 1.0 // 默认透明度
marginSart = 25 // 从隐藏恢复到显示状态的默认边距
aotoHide = true // 开启自动隐藏
aotoHideTime = 3000 // 自动隐藏的超时时间
aotoEdging = true // 自动贴边
enableOutEdging = true // 拖拽时允许超过边界
enableDragWhenHidden = true // 允许隐藏时直接拖动,如果false,则需要先点击一下,从隐藏状态显示后,才能继续拖动
aotoEdgingTime = 3000 // 自动贴边的超时时间
onClickEvent?: () => boolean; // 点击事件传递, 如果返回false,则阻止后续事件
onEdgingEvent?: () => boolean; // 贴边事件传递, 如果返回false,则阻止后续事件
onHideEvent?: () => boolean; // 半隐藏事件传递, 如果返回false,则阻止后续事件
onShowEvent?: () => boolean; // 显示事件传递, 如果返回false,则阻止后续事件
/**
* 生命周期函数-初始化
*/
aboutToAppear() {
// 初始化透明度
this.opacityN = this.opacityDefault
// 初始化窗口信息
this.getWindowInfo()
}
/**
* 获取窗口尺寸信息
*/
getWindowInfo() {
window.getLastWindow(getContext(this), (err, windowClass) => {
if (!err.code) { //状态栏高度
this.statusHeight = px2vp(windowClass.getWindowAvoidArea(window.AvoidAreaType.TYPE_SYSTEM).topRect.height)
//获取手机底部规避区域高度
this.bottomAvoidAreaHeight =
px2vp(windowClass.getWindowAvoidArea(window.AvoidAreaType.TYPE_NAVIGATION_INDICATOR)
.bottomRect
.height) //获取窗口宽高
let windowProperties = windowClass.getWindowProperties()
this.winWidth = px2vp(windowProperties.windowRect.width)
this.winHeight = px2vp(windowProperties.windowRect.height)
//设置初始位置位于屏幕右下角,演示设置可根据实际调整
this.curLeft = 10
if(this.fixedRight){
this.curLeft = this.winWidth - this.radius * 2
} else if(this.fixedLeft){
this.curLeft = 0
} else{
this.curLeft = 10
}
this.curTop = this.winHeight * 0.75
// 开启自动用隐藏
if (this.aotoHide) {
this.onHide()
}
}
})
}
/**
* UI 绘制
*/
build() {
//悬浮按钮
Row() {
Image(this.image).width(this.radius * 2) // 图片
}
.width(this.radius * 2)
.height(this.radius * 2)
.justifyContent(FlexAlign.Center)
.borderRadius(this.radius)
// .backgroundColor('#E8E8E8')
.opacity(this.opacityN) // 透明度
.position({ x: this.curLeft, y: this.curTop }) // 位置绑定,注意@State关键词
.onTouch((event: TouchEvent) => { // 手指按下记录初始触摸点坐标、悬浮按钮位置
if (event.type === TouchType.Down) {
this.isTouc = true
console.log('TouchEvent: 按下');
// 恢复透明度
this.restoreOpacity()
this.startX = event.touches[0].windowX
this.startY = event.touches[0].windowY
this.startLeft = this.curLeft
this.startTop = this.curTop
} // 按下
else if (event.type === TouchType.Up) {
this.isTouc = false
console.log('TouchEvent: 松开');
if (this.aotoHide) { // 松开后隐藏
this.onHide()
} else if (this.aotoEdging) { // 松开后贴边
this.onEdging()
}
}
else if (event.type === TouchType.Move) { // 拖动
if(this.state == 3 && !this.enableDragWhenHidden){
return
}
this.isTouc = true
let touch = event.touches[0] // 获取手指触摸位置
let curLeft = this.startLeft + (touch.windowX - this.startX) // 计算悬浮球位置
// 只有在没固定情况下才能移动
if(!this.fixedLeft && !this.fixedRight) {
// 是否允许超过边界
if(!this.enableOutEdging){
curLeft = Math.max(0, curLeft) // 限制悬浮球不能移除屏幕右边
this.curLeft = Math.min(this.winWidth - 2 * this.radius,curLeft) // 限制悬浮球不能移除屏幕左边
} else{
this.curLeft = curLeft
}
}
let curTop = this.startTop + (touch.windowY - this.startY) // 限制悬浮球不能移除屏幕上边
curTop = Math.max(0, curTop) // 限制悬浮球不能移除屏幕下边
this.curTop =
Math.min(this.winHeight - 2 * this.radius - this.bottomAvoidAreaHeight - this.statusHeight, curTop)
console.log('TouchEvent: 移动');
}
})
.onClick(() => {
if (!this.isShow() || this.state == 3) {
this.onMyShow()
return
}
let fal = false
if (this.onClickEvent !== undefined) {
fal = this.onClickEvent();
}
if (fal && this.aotoHide) {
this.onHide(); // 点击完,就继续去隐藏
}
})
}
public onMyClick2(onClickEvent: () => boolean) {
this.onClickEvent = onClickEvent
}
/**
* 隐藏
*/
onHide() {
// 如果已经是隐藏,就退出
if (!this.isShow()) {
return
}
// 如果开启了自动贴边,就先判断是否贴边
if (this.aotoEdging && !this.ifEdging()) {
// 先执行贴边
console.log(TAG, "开启了自动贴边, 当前未贴边")
this.onEdging()
return
}
// 先取消上一个定时器,在搞新的
if (this.time != 0) {
console.log(TAG, "清除上次未执行的目标")
clearTimeout(this.time);
}
console.log(TAG, "创建目标: 隐藏")
this.time = setTimeout(() => {
console.log(TAG, "进入执行目标: 隐藏")
this.onMyHideDo()
}, this.aotoHideTime)
}
/**
* 贴边
*/
onEdging() {
// if (this.time != 0) {
// console.log(TAG, "清除上次未执行的目标")
// clearTimeout(this.time)
// }
console.log(TAG, "创建目标: 贴边")
// this.time = setTimeout(() => {
console.log(TAG, "进入执行目标: 贴边")
this.onEdgingDo()
// }, this.aotoEdgingTime)
}
/**
* 执行贴边
*/
onEdgingDo() {
if (this.isTouc) {
// 如果还在触摸中,就往后延时执行
console.log(TAG, "推后执行目标:在触摸中 贴边")
this.onEdging()
} else {
this.state = 2
// 隐藏动画
console.log(TAG, "执行目标:贴边")
animateTo({
duration: 1000,
onFinish: (() => {
console.log(TAG, "贴边动画结束")
let fal = true
// 记得清除定时器资源
clearTimeout(this.time)
this.time = 0
// 传递贴边事件
if(this.onEdgingEvent !== undefined){
fal = this.onEdgingEvent()
}
// 贴边动画结束 开始准备隐藏
if (fal && this.aotoHide) {
this.onHide()
}
})
}, () => {
// 修改位置
if (this.curLeft > this.winWidth / 2) {
this.curLeft = this.winWidth - this.radius * 2
} else {
this.curLeft = 0
}
})
// 取消定时器
if (this.time != 0) {
clearTimeout(this.time);
this.time = 0
}
}
}
/**
* 执行隐藏动作
*/
onMyHideDo() {
if (this.isTouc) {
// 如果还在触摸中,就往后延时执行
console.log(TAG, "推后执行目标:在触摸中 隐藏")
this.onHide()
} else {
this.state = 3
// 隐藏动画
console.log(TAG, "执行目标:隐藏")
animateTo({
duration: 1000,
onFinish: (() => {
console.log(TAG, "隐藏动画结束")
// 记得清除定时器资源
clearTimeout(this.time)
this.time = 0
// 传递隐藏事件
if(this.onHideEvent !== undefined){
this.onHideEvent()
}
})
}, () => {
// 修改透明度
this.onOpacity()
// 修改坐标
if (this.curLeft > this.winWidth / 2) {
this.curLeft = this.winWidth - this.radius
} else {
this.curLeft = 0 - this.radius
}
})
// 取消定时器
if (this.time != 0) {
clearTimeout(this.time);
this.time = 0
}
}
}
/**
* 显示
*/
onMyShow() {
// 显示动画
console.log(TAG, "执行目标:显示")
this.state = 1
animateTo({
duration: 500, onFinish: (() => {
console.log(TAG, "显示动画结束")
let fal = true
// 记得清除定时器资源
clearTimeout(this.time)
this.time = 0
if(this.onShowEvent !== undefined){
fal = this.onShowEvent()
}
if(fal){
// 重新加载隐藏
this.onHide()
}
})
}, () => {
// 修改坐标
if (this.curLeft > this.winWidth / 2) {
this.curLeft = this.winWidth - this.radius * 2 - this.marginSart
} else {
this.curLeft = this.marginSart
}
})
// 显示后,就取消定时器
if (this.time != 0) {
clearTimeout(this.time);
this.time = 0
}
}
/**
* 启用半透明
*/
onOpacity(){
this.opacityN = this.opacityHide
}
/**
* 恢复默认透明度
*/
restoreOpacity(){
this.opacityN = this.opacityDefault
}
/**
* 判断是否是隐藏状态
* @returns
*/
isShow() {
if (this.curLeft < 0) {
return false
} else if (this.curLeft >= this.winWidth - this.radius) {
return false
}
return true
}
/**
* 判断是否贴边
* @returns
*/
ifEdging() {
if (this.curLeft == 0) {
return true
} else if (this.curLeft == this.winWidth - this.radius * 2) {
return true
}
return false
}
}
父页面
import { FloatBall } from 'lib_base/src/main/ets/common/view/FloatBall';
import { promptAction } from '@kit.ArkUI';
import { webview } from '@kit.ArkWeb';
@Entry
@Component
struct Index {
controller: WebviewController = new webview.WebviewController();
build() {
Stack() {
// 一个webview,充当用户业务视图
Web({ src: "https://www.baidu.com", controller: this.controller })
.domStorageAccess(true)
.onlineImageAccess(true)
.imageAccess(true)
.zoomAccess(false) // 禁止缩放
.javaScriptAccess(true) // 启用js交互
.backgroundColor(Color.White) // 背景
.width('100%')
// 悬浮按钮
FloatBall({
image: $r('app.media.loading'), // 图片资源
radius: 25, // 悬浮按钮半径
marginSart: 25, // 从隐藏恢复到显示状态的默认边距
aotoEdging: true, // 开启自动贴边
aotoHideTime: 10,
aotoEdgingTime: 300,
enableOutEdging: false,
aotoHide: false, // 开启自动隐藏
opacityHide: 0.5, // 半隐藏后的透明度
onClickEvent: (): boolean => { // 点击事件
promptAction.showToast({ message: '点击了悬浮球' });
if(this.controller.accessStep(-1)){
this.controller.backward(); // 返回上一个web页
}
return true
},
onEdgingEvent: (): boolean => {
promptAction.showToast({ message: '我贴边了' });
return true
},
onHideEvent: (): boolean => {
promptAction.showToast({ message: '我隐藏了' });
return true
},
onShowEvent: (): boolean => {
promptAction.showToast({ message: '我显示了' });
return true
}
})
// .touchable(false) // 这个过期了,.hitTestBehavior(HitTestMode.None)代替
.hitTestBehavior(HitTestMode.None) // 重要用于点击事件穿透,不然无法点击Web内容
.width("100%")
.height("100%")
}
.height('100%')
.width('100%')
}
}