需求说明
在完成了上篇文章:可拖动控件 之后,很容易就让我联想到另一个问题:那就是类似于悬浮球一样的效果能够浮在应用的表面上的悬浮控件时如何去实现的呢?经过了查阅资料和书写Demo,终于实现了一个相对简陋的悬浮窗,能够达到基本效果,有需要的朋友可以查看本篇内容,或许对你在实现思路上有一定的帮助作用。
文章回顾:
Android可拖动控件(一)
设计概述
要实现悬浮球效果,首先要注意如下几点:
- 需要有能够悬浮在应用之上的权限。
- 控件需要能够在屏幕内拖动。
- 手指抬起的时候,悬浮球需要有依附边缘的效果。
- 当前应用的Activity不显示,只有悬浮球
接下来我们会针对如上几项进行一一说明。
详细设计及代码示例
1. 悬浮权限
悬浮权限主要包含两种:显示系统窗口权限和view在屏幕顶部显示权限。在AndroidMani.xml中声明权限:
<!--显示系统窗口权限-->
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW" />
<!--屏幕顶部显示权限-->
<uses-permission android:name="android.permission.SYSTEM_OVERLAY_WINDOW" />
当然需要在Android6.0以上需要去获取权限,代码示例如下:
private fun requestPermission() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M && !Settings.canDrawOverlays(this)) {
val intent = Intent()
intent.action = Settings.ACTION_MANAGE_OVERLAY_PERMISSION
intent.data = Uri.parse("package:$packageName")
startActivityForResult(intent, 0)
}
}
2. 悬浮窗实现
1. 悬浮窗View本身
首先是悬浮窗View本身,可以自定义一个ViewGroup,我选取了一个简单例子,LinearLayout下包了一个TextView和ImageView。示例如下:
package com.example.floatingwindowdemo.custom.floatview
import android.content.Context
import android.util.AttributeSet
import android.view.View
import android.widget.LinearLayout
import android.widget.TextView
import androidx.appcompat.widget.AppCompatImageView
import com.example.floatingwindowdemo.FloatWindowApplication
import com.example.floatingwindowdemo.R
import com.example.floatingwindowdemo.util.LogUtils
class FloatViews : LinearLayout, View.OnClickListener {
constructor(context: Context) : super(context) {}
constructor(context: Context, attributeSet: AttributeSet) : super(context, attributeSet) {}
constructor(context: Context, attributeSet: AttributeSet, defStyleAttr: Int) :
super(context, attributeSet, defStyleAttr) {
}
private var image: AppCompatImageView? = null
private var text: TextView? = null
//是否需要依附边缘
var needAttach = false
init {
View.inflate(context, R.layout.float_views_layout, this)
image = findViewById(R.id.img_float_window)
text = findViewById(R.id.text_float_window)
setOnClickListener(this)
//减去虚拟按键的高度
// screenHeight -= DeviceUtils.instance.getVirtualBarHeight(context)
}
override fun onClick(v: View?) {
LogUtils.instance.getLogPrint("点击了可拖动控件" + v?.context?.packageName)
FloatWindowApplication.startTargetActivity()
}
}
2. 悬浮窗依托的Application
悬浮窗往往是在我们的主应用在没有显示任何其他的界面的时候才显示的,因此悬浮窗是不依托于Activity而存在的单独View,所以悬浮窗需要依靠Application存在而不是Activity,所以需要定义一个Application。
AppLication代码如下:
package com.example.floatingwindowdemo
import android.app.Activity
import android.app.Application
import android.content.Context
import android.content.Intent
import android.os.Bundle
import com.example.floatingwindowdemo.custom.floatview.FloatWindowHelper
class FloatWindowApplication : Application() {
private var activityCount = 0
private var flowGroup: FloatWindowHelper? = null
override fun onCreate() {
super.onCreate()
mContext = this
if (flowGroup == null)
flowGroup = FloatWindowHelper()
registerActivityLifecycleCallbacks(object : ActivityLifecycleCallbacks {
override fun onActivityPaused(activity: Activity) {
}
override fun onActivityStarted(activity: Activity) {
if (activityCount == 0) {
hideWindow()
}
++activityCount
}
override fun onActivityDestroyed(activity: Activity) {
}
override fun onActivitySaveInstanceState(activity: Activity, outState: Bundle) {
}
override fun onActivityStopped(activity: Activity) {
--activityCount
if (activityCount == 0) {
showWindow()
}
}
override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) {
}
override fun onActivityResumed(activity: Activity) {
}
})
}
fun showWindow() {
flowGroup?.showView(this)
}
fun hideWindow() {
flowGroup?.hideView(this)
}
companion object {
val instance by lazy(mode = LazyThreadSafetyMode.SYNCHRONIZED) { FloatWindowApplication() }
var mContext: Context? = null
fun startTargetActivity() {
if (mContext != null) {
var intent = Intent()
intent.setClass(mContext!!, TestTargetActivity::class.java)
intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK
mContext?.startActivity(intent)
}
}
}
}
设置的Application通过注册Activity的生命周期回调,判断当Activity数量为0的时候显示悬浮窗,当存在Activity时取消悬浮窗。当然如果你有其他要求,可以通过新建Service来实现启动和控件悬浮窗的显隐。
新设置的Application必须要在AndroidManifest.xml中注册,示例如下:
<application
android:name=".FloatWindowApplication"
...>
</application>
3. 悬浮窗辅助类
在完成了上述两步之后,我们还需要一个单独的辅助类用来控制悬浮窗的位置及加载,示例如下:
package com.example.floatingwindowdemo.custom.floatview
import android.annotation.SuppressLint
import android.content.Context
import android.graphics.PixelFormat
import android.os.Build
import android.util.DisplayMetrics
import android.view.*
import android.view.animation.BounceInterpolator
import androidx.core.view.ViewCompat.animate
import com.example.floatingwindowdemo.FloatWindowApplication
import com.example.floatingwindowdemo.util.LogUtils
import java.util.logging.Logger
class FloatWindowHelper {
private var windowManager: WindowManager? = null
private var view: FloatViews? = null
private var lastX: Int = 0
private var lastY: Int = 0
private var downTime = 0L
private var isDraged = false
private fun addView(context: Context) {
if (windowManager == null) {
windowManager = context.getSystemService(Context.WINDOW_SERVICE) as WindowManager?
}
var layoutParam = WindowManager.LayoutParams()
//设置宽和高
layoutParam.height = WindowManager.LayoutParams.WRAP_CONTENT
layoutParam.width = WindowManager.LayoutParams.WRAP_CONTENT
//设置初始位置在左上角
layoutParam.format = PixelFormat.TRANSPARENT
layoutParam.gravity = Gravity.START or Gravity.TOP
var displayMetrics: DisplayMetrics? = FloatWindowApplication.mContext?.resources?.displayMetrics
var screenWidth = displayMetrics?.widthPixels ?: 0
var screenHeight = displayMetrics?.heightPixels ?: 0
// layoutParam.verticalMargin = 0.2f
// FLAG_LAYOUT_IN_SCREEN:将window放置在整个屏幕之内,无视其他的装饰(比如状态栏); FLAG_NOT_TOUCH_MODAL:不阻塞事件传递到后面的窗口
layoutParam.flags = WindowManager.LayoutParams.FLAG_LAYOUT_IN_SCREEN or
WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE
//设置悬浮窗属性
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
layoutParam.type = WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY
} else {
// 设置窗体显示类型(TYPE_TOAST:与toast一个级别)
layoutParam.type = WindowManager.LayoutParams.TYPE_TOAST
}
if (view == null)
view = FloatViews(context)
windowManager?.addView(view, layoutParam)
}
fun showView(context: Context) {
if (view == null || view?.windowToken == null) {
addView(context)
}
if (view != null)
view?.visibility = View.VISIBLE
}
fun hideView(context: Context) {
view?.visibility = View.GONE
}
companion object {
val instance = FloatWindowHelper()
}
}
通过设置WindowManager来去加载悬浮窗View,设置layoutParam来实现其具体的位置效果。
3. 悬浮窗的可拖动效果
要使得悬浮窗这一View可以拖动,肯定会涉及到其Touch事件的处理,因此需要对其OnTouchListener
里的touch事件进行处理。
示例代码如下:
view?.setOnTouchListener(object : View.OnTouchListener {
override fun onTouch(v: View?, ev: MotionEvent?): Boolean {
if (v == null || ev == null)
return false
when (ev.action) {
MotionEvent.ACTION_DOWN -> {
lastX = ev.rawX.toInt()
lastY = ev.rawY.toInt()
downTime = System.currentTimeMillis()
isDraged = false
}
MotionEvent.ACTION_MOVE -> {
var dx = ev.rawX.toInt() - lastX
var dy = ev.rawY.toInt() - lastY
var l = v.left + dx
var r = v.right + dx
var t = v.top + dy
var b = v.bottom + dy
//当滑动出边界时需要重新设置位置
if (l < 0) {
l = 0
r = v.width
}
if (t < 0) {
t = 0
b = v.height
}
v.layout(l, t, r, b)
lastX = ev.rawX.toInt()
lastY = ev.rawY.toInt()
layoutParam.x = lastX - v.width / 2
layoutParam.y = lastY - v.height / 2
windowManager?.updateViewLayout(v, layoutParam)
}
MotionEvent.ACTION_UP -> {
if (System.currentTimeMillis() - downTime < ViewConfiguration.getTapTimeout()) {
v.performClick()
isDraged = false
} else {
isDraged = true
}
}
}
return isDraged
}
})
由于touch事件往往会和点击事件发生冲突,因此在上述代码中添加了一个判断是否拖拽的参数:isDraged
。当触发DOWN
事件时置为false,在UP
事件时判断此次由DOWN
到UP
事件的时间是否超过了tap的时间,如果没有超过,则判定是一次滑动事件,反之则是点击事件,需要调用performClick()
方法来确保点击事件的触发。
4. 悬浮窗的边界依附效果
悬浮窗能够在屏幕内进行拖动还是有所不够的,还需要能够进行边界依附,当拖动到一定位置后,当用户手松开后能够及时的依附在左右边缘上,以免影响用户的整体使用体验。
显然依附动作是在用户手松开的这一动作触发后才有的,因此可以考虑实现在touch
事件的UP
这一动作之后去完成这一功能。
实现依附动作比较简单的方式就是去重新设置悬浮窗的位置,示例代码如下:
MotionEvent.ACTION_UP -> {
if (System.currentTimeMillis() - downTime < ViewConfiguration.getTapTimeout()) {
v.performClick()
isDraged = false
} else {
isDraged = true
}
LogUtils.instance.getLogPrint(screenHeight.toString())
if ((v as FloatViews).needAttach && screenWidth != 0) {
if (lastX > screenWidth / 2) {
layoutParam.x = screenWidth - v.width
} else {
layoutParam.x = 0
}
windowManager?.updateViewLayout(v, layoutParam)
}
}
上述代码通过判断当前的位置的x轴上位置是否在屏幕的左半边还是右半边,如果在左边就依附左边界,反之则依附右边界。
总结
- 悬浮窗必须要有指定权限,否则可能会无法显示。
- 悬浮窗往往不依赖于activity,因此需要借助Application或者Service来实现显示。
- 悬浮窗可拖动和依附效果可以通过onTouch事件分发以及WIndowManager设置显示属性来实现。