悬浮窗口_拖动_吸边
悬浮窗口/拖动/吸边
悬浮窗口/拖动/吸边
实现了一个可以拖拽的悬浮球组件,支持自动贴边和隐藏等功能,适用于窗口场景。以下是对其功能的详细描述:
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. 扩展性与定制化
通过该代码,开发者可以方便地修改悬浮球的外观(如图片资源、半径大小),以及其行为(如自动隐藏时间、是否允许超过边界、是否启用拖动等),满足多种应用场景的需求。
悬浮球组件
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
import { window } from '@kit.ArkUI'
const TAG: string = '[FloatBall]';
/**
* 悬浮球
*/
@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
}
}
父页面
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
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%')
}
}
本文由作者按照 CC BY 4.0 进行授权