这是一个在当前Activity内显示的浮窗(不需要系统悬浮窗权限),支持拖动和左右贴边。
完整实现方案
1. FloatView 核心类
kotlin
class FloatView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0
) : FrameLayout(context, attrs, defStyleAttr) {
// 贴边位置枚举
enum class EdgePosition { LEFT, RIGHT, FREE }
// 配置参数
private var edgeMargin = 0 // 贴边时的边距
private var topMargin = 0 // 顶部边距
private var bottomMargin = 0 // 底部边距
private var edgePosition = EdgePosition.FREE // 当前贴边状态
private var isDraggable = true // 是否可拖动
private var autoEdgeEnabled = true // 是否自动贴边
// 触摸相关
private var lastX = 0f
private var lastY = 0f
private var downX = 0f
private var downY = 0f
private var isDragging = false
// 动画相关
private var edgeAnimator: ValueAnimator? = null
private val edgeAnimDuration = 300L // 贴边动画时长
// 回调
var onEdgeChangeListener: ((EdgePosition) -> Unit)? = null
var onDragChangeListener: ((x: Int, y: Int) -> Unit)? = null
init {
edgeMargin = 16.dp
topMargin = 100.dp
bottomMargin = 100.dp
setupView()
}
private fun setupView() {
// 确保FloatView可以接收触摸事件
isClickable = true
isFocusable = true
}
override fun onTouchEvent(event: MotionEvent): Boolean {
if (!isDraggable) return super.onTouchEvent(event)
when (event.action) {
MotionEvent.ACTION_DOWN -> {
lastX = event.rawX
lastY = event.rawY
downX = event.x
downY = event.y
isDragging = false
// 取消正在进行的贴边动画
edgeAnimator?.cancel()
}
MotionEvent.ACTION_MOVE -> {
val deltaX = event.rawX - lastX
val deltaY = event.rawY - lastY
// 判断是否开始拖动(避免与点击冲突)
if (!isDragging) {
val distance = Math.sqrt((deltaX * deltaX + deltaY * deltaY).toDouble())
if (distance > 5) { // 5px阈值
isDragging = true
parent.requestDisallowInterceptTouchEvent(true)
}
}
if (isDragging) {
// 移动View
x += deltaX
y += deltaY
// 边界限制
constrainPosition()
// 通知拖动变化
onDragChangeListener?.invoke(x.toInt(), y.toInt())
lastX = event.rawX
lastY = event.rawY
}
}
MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> {
parent.requestDisallowInterceptTouchEvent(false)
if (isDragging) {
// 拖动结束,检查是否需要自动贴边
if (autoEdgeEnabled) {
snapToEdge()
}
isDragging = false
} else {
// 点击事件
performClick()
}
}
}
return true
}
/**
* 限制View在父容器范围内
*/
private fun constrainPosition() {
val parent = parent as? View ?: return
val parentWidth = parent.width
val parentHeight = parent.height
// 左右边界
if (x < 0) x = 0f
if (x + width > parentWidth) x = (parentWidth - width).toFloat()
// 上下边界(考虑状态栏等)
if (y < topMargin) y = topMargin.toFloat()
if (y + height > parentHeight - bottomMargin) {
y = (parentHeight - height - bottomMargin).toFloat()
}
}
/**
* 自动贴边到最近的边缘
*/
private fun snapToEdge() {
val parent = parent as? View ?: return
val parentWidth = parent.width
val centerX = x + width / 2
// 判断贴左边还是右边
val targetX = if (centerX < parentWidth / 2) {
edgeMargin.toFloat() // 贴左边
} else {
parentWidth - width - edgeMargin.toFloat() // 贴右边
}
// 如果已经在边缘附近,不需要动画
if (Math.abs(x - targetX) < 10) {
x = targetX
updateEdgePosition(targetX)
return
}
// 创建平移动画
edgeAnimator?.cancel()
edgeAnimator = ValueAnimator.ofFloat(x, targetX).apply {
duration = edgeAnimDuration
interpolator = DecelerateInterpolator()
addUpdateListener { animation ->
x = animation.animatedValue as Float
}
addListener(object : AnimatorListenerAdapter() {
override fun onAnimationEnd(animation: Animator) {
updateEdgePosition(targetX)
}
})
start()
}
}
/**
* 更新贴边状态
*/
private fun updateEdgePosition(targetX: Float) {
val parent = parent as? View ?: return
val parentWidth = parent.width
edgePosition = when {
targetX <= edgeMargin + 10 -> EdgePosition.LEFT
targetX >= parentWidth - width - edgeMargin - 10 -> EdgePosition.RIGHT
else -> EdgePosition.FREE
}
onEdgeChangeListener?.invoke(edgePosition)
}
/**
* 手动设置贴边位置
*/
fun snapToEdge(position: EdgePosition) {
val parent = parent as? View ?: return
val parentWidth = parent.width
val targetX = when (position) {
EdgePosition.LEFT -> edgeMargin.toFloat()
EdgePosition.RIGHT -> parentWidth - width - edgeMargin.toFloat()
EdgePosition.FREE -> x // 保持当前位置
}
edgeAnimator?.cancel()
edgeAnimator = ValueAnimator.ofFloat(x, targetX).apply {
duration = edgeAnimDuration
interpolator = DecelerateInterpolator()
addUpdateListener { animation ->
x = animation.animatedValue as Float
}
addListener(object : AnimatorListenerAdapter() {
override fun onAnimationEnd(animation: Animator) {
updateEdgePosition(targetX)
}
})
start()
}
}
/**
* 获取当前贴边位置
*/
fun getEdgePosition(): EdgePosition = edgePosition
// ============ 配置方法 ============
fun setEdgeMargin(margin: Int) {
edgeMargin = margin
}
fun setEdgeMarginDp(marginDp: Int) {
edgeMargin = marginDp.dp
}
fun setVerticalMargin(top: Int, bottom: Int) {
topMargin = top
bottomMargin = bottom
}
fun setVerticalMarginDp(topDp: Int, bottomDp: Int) {
topMargin = topDp.dp
bottomMargin = bottomDp.dp
}
fun setDraggable(draggable: Boolean) {
isDraggable = draggable
}
fun setAutoEdgeEnabled(enabled: Boolean) {
autoEdgeEnabled = enabled
}
fun setEdgeAnimDuration(duration: Long) {
edgeAnimDuration = duration
}
override fun onDetachedFromWindow() {
super.onDetachedFromWindow()
edgeAnimator?.cancel()
}
// dp转px扩展
private val Int.dp: Int
get() = (this * context.resources.displayMetrics.density + 0.5f).toInt()
}
2. 使用示例(Activity中)
kotlin
class MainActivity : AppCompatActivity() {
private lateinit var floatView: FloatView
private lateinit var rootLayout: FrameLayout
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
rootLayout = findViewById(R.id.root_layout)
setupFloatView()
}
private fun setupFloatView() {
// 创建FloatView
floatView = FloatView(this).apply {
// 设置内容(可以是任何View)
val contentView = LayoutInflater.from(this@MainActivity)
.inflate(R.layout.float_view_content, this, false)
addView(contentView)
// 配置参数
setEdgeMarginDp(16)
setVerticalMarginDp(100, 100)
setDraggable(true)
setAutoEdgeEnabled(true)
// 设置布局参数
val params = FrameLayout.LayoutParams(
FrameLayout.LayoutParams.WRAP_CONTENT,
FrameLayout.LayoutParams.WRAP_CONTENT
)
layoutParams = params
// 初始位置(右上角)
post {
val parentWidth = rootLayout.width
x = (parentWidth - width - 16.dp).toFloat()
y = 100.dp.toFloat()
}
// 监听贴边变化
onEdgeChangeListener = { position ->
when (position) {
FloatView.EdgePosition.LEFT -> {
// 贴左边时的处理,比如调整内容方向
contentView.findViewById<ImageView>(R.id.float_icon)
?.rotation = 0f
}
FloatView.EdgePosition.RIGHT -> {
// 贴右边时的处理
contentView.findViewById<ImageView>(R.id.float_icon)
?.rotation = 180f
}
FloatView.EdgePosition.FREE -> {
// 自由位置
}
}
}
// 监听拖动变化
onDragChangeListener = { x, y ->
// 可以在这里做某些实时更新
}
// 设置点击事件
setOnClickListener {
Toast.makeText(this@MainActivity, "FloatView Clicked", Toast.LENGTH_SHORT).show()
}
}
// 添加到根布局
rootLayout.addView(floatView)
}
// 外部控制方法示例
fun hideFloatView() {
floatView.visibility = View.GONE
}
fun showFloatView() {
floatView.visibility = View.VISIBLE
}
fun snapToLeft() {
floatView.snapToEdge(FloatView.EdgePosition.LEFT)
}
fun snapToRight() {
floatView.snapToEdge(FloatView.EdgePosition.RIGHT)
}
override fun onDestroy() {
super.onDestroy()
// 清理
rootLayout.removeView(floatView)
}
private val Int.dp: Int
get() = (this * resources.displayMetrics.density + 0.5f).toInt()
}
3. 布局文件
activity_main.xml
xml
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/root_layout"
android:layout_width="match_parent"
android:layout_height="match_parent">
<!-- 你的其他内容 -->
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="主界面内容"
android:layout_gravity="center" />
<!-- FloatView会通过代码添加,不需要在这里声明 -->
</FrameLayout>
float_view_content.xml(浮窗内容布局)
xml
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="vertical"
android:background="@drawable/float_view_bg"
android:padding="12dp"
android:elevation="8dp">
<ImageView
android:id="@+id/float_icon"
android:layout_width="40dp"
android:layout_height="40dp"
android:src="@drawable/ic_float"
android:contentDescription="Float Icon" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="拖动我"
android:textSize="12sp"
android:layout_marginTop="4dp"
android:gravity="center" />
</LinearLayout>
float_view_bg.xml(背景drawable)
xml
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<solid android:color="#FFFFFF" />
<corners android:radius="20dp" />
<stroke
android:width="1dp"
android:color="#E0E0E0" />
</shape>
4. 关键特性说明
| 特性 | 说明 |
|---|---|
| 拖动 | 支持自由拖动,有5px阈值避免与点击冲突 |
| 自动贴边 | 松手后自动吸附到最近的左右边缘 |
| 边界限制 | 不会拖出父容器范围,可设置上下边距 |
| 贴边动画 | 300ms减速动画,流畅自然 |
| 状态回调 | 监听贴边状态变化和拖动位置变化 |
| 无需权限 | 界面内浮窗,不需要SYSTEM_ALERT_WINDOW权限 |
这个实现方案是界面内浮窗 ,适用于当前Activity内的悬浮操作入口。如果需要全局系统悬浮窗 (跨应用显示),则需要使用WindowManager和SYSTEM_ALERT_WINDOW权限,实现方式会有所不同。