Android可拖动控件(二):简易悬浮窗

需求说明

在完成了上篇文章:可拖动控件 之后,很容易就让我联想到另一个问题:那就是类似于悬浮球一样的效果能够浮在应用的表面上的悬浮控件时如何去实现的呢?经过了查阅资料和书写Demo,终于实现了一个相对简陋的悬浮窗,能够达到基本效果,有需要的朋友可以查看本篇内容,或许对你在实现思路上有一定的帮助作用。

文章回顾:
Android可拖动控件(一)

设计概述

要实现悬浮球效果,首先要注意如下几点:

  1. 需要有能够悬浮在应用之上的权限。
  2. 控件需要能够在屏幕内拖动。
  3. 手指抬起的时候,悬浮球需要有依附边缘的效果。
  4. 当前应用的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事件时判断此次由DOWNUP事件的时间是否超过了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轴上位置是否在屏幕的左半边还是右半边,如果在左边就依附左边界,反之则依附右边界。

总结

  1. 悬浮窗必须要有指定权限,否则可能会无法显示。
  2. 悬浮窗往往不依赖于activity,因此需要借助Application或者Service来实现显示。
  3. 悬浮窗可拖动和依附效果可以通过onTouch事件分发以及WIndowManager设置显示属性来实现。

demo github地址

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 204,293评论 6 478
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,604评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,958评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,729评论 1 277
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,719评论 5 366
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,630评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,000评论 3 397
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,665评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,909评论 1 299
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,646评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,726评论 1 330
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,400评论 4 321
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,986评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,959评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,197评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 44,996评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,481评论 2 342

推荐阅读更多精彩内容