UI

利用DecorView实现悬浮窗的效果

2020-08-09  本文已影响0人  超级绿茶

由于众所周知的原因,Android系统虽然提供了悬浮窗的功能,但使用之前需要用记授权,有些手机对这个授权还要再次确认,以至于很多用户出于谨慎的目地就不去打开了。但我们在实际开发当中却又需要这个功能是该怎么?

既然直接使用是不行了,那只能考虑折中的办法了。首先能想到的是把悬浮窗作在XML布局里面,不用时隐藏,需要时显示。但如果我们遇到一批需要悬浮窗的界面时要怎么办?有一个比较稳妥的方法;从DecorView下手!

我们知道Activity对于界面的加载是交给自己的手下PhoneWindow去处理的,PhoneWindow再交给自己的手下DecorView。DecorView虽然是一个FrameLayout的自定义类,理论上由它来加载就可以了,但DecorView还挺能整活,它不直接加载我们定义的XML布局,而是先加载了一个系统内置的布局,这个内置的布局是LinearLayout结构的,上面是一个标题区,下面是内容区,内容区就是一个FrameLayout,并且有一个系统级的ID作为标记。这个内置布局结构如下:

<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:fitsSystemWindows="true"
    android:orientation="vertical">
    <ViewStub android:id="@+id/action_mode_bar_stub"
              android:inflatedId="@+id/action_mode_bar"
              android:layout="@layout/action_mode_bar"
              android:layout_width="match_parent"
              android:layout_height="wrap_content"
              android:theme="?attr/actionBarTheme" />
    <FrameLayout
         android:id="@android:id/content"
         android:layout_width="match_parent"
         android:layout_height="match_parent"
         android:foregroundInsidePadding="false"
         android:foregroundGravity="fill_horizontal|top"
         android:foreground="?android:attr/windowContentOverlay" />
</LinearLayout>

我们自定义的XML布局就是被加载到这个ID为content的FrameLayout里的。由于这是一个系统内置的布局,每一个Activity都有,所以我们可以充分利用这一便利来给实现我们的悬浮窗效果。

大致的思路是这样的:

  1. 自定义一个Activity子类专门用于处理悬浮窗的操作,在项目中需要用到悬浮窗的地方就继承这个类。
  2. 在自定义的Activity子类中通过findViewById<ViewGroup>(android.R.id.content)获取到系统内置的内容布局(ContentFrameLayout)。
  3. 自定义一个View作为我们的悬浮窗,然后将这个View添加到内容布局。

在上述步骤之前ID为content的内容布局之下原本只有一个子布局,即我们在setContentView里指定的XML布局资源,执行了上述步骤之后内容布局又会多出一个子布局,由于内容布局本身就是一个FrameLayout,所以后加的布局自然放在最上层,这就是悬浮窗的效果了。

下面就来实例个带拖动效果的悬浮窗


悬浮窗带拖动效果

先自定义一个FloatActivity,封装一下悬浮窗的功能。由于我们的悬浮窗是带有拖动功能的,所以还需要把窗体的坐标保存在配置文件里,以便于Activity在跳转的时候可以读取得到:


import android.annotation.SuppressLint
import android.content.Context
import android.os.Bundle
import android.view.LayoutInflater
import android.view.MotionEvent
import android.view.View
import android.view.ViewGroup
import android.widget.FrameLayout
import androidx.appcompat.app.AppCompatActivity
import androidx.core.content.edit
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleObserver
import androidx.lifecycle.OnLifecycleEvent
import kotlinx.android.synthetic.main.float_window.view.*

/**
 * 带悬浮窗操作的Activity
 */
open class FloatActivity : AppCompatActivity() {
    private var offX = 0
    private var offY = 0
    private var isPressing = false

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        // 根据Activity的生命周期显示或移除悬浮窗
        this.lifecycle.addObserver(object : LifecycleObserver {
            @OnLifecycleEvent(Lifecycle.Event.ON_RESUME)
            fun onResume() {
                // 在Activity可见时加载悬浮窗
                showFloatWindow(isShowing(applicationContext))
            }

            @OnLifecycleEvent(Lifecycle.Event.ON_STOP)
            fun onStop() {
                // 由于悬浮窗是每个Activity都有的,所以在暂停时移除以释放资源
                removeFloatWindow()
            }
        })
    }

    /**
     * 定义一个悬浮窗,这个悬浮窗只是一个普通的View对象
     * 可以根据需求定义不同的窗体
     */
    @SuppressLint("ClickableViewAccessibility")
    private fun buildFloatWindow(): View {
        val view = LayoutInflater.from(this).inflate(R.layout.float_window, null, false)
        view.layoutParams = ViewGroup.LayoutParams(
            ViewGroup.LayoutParams.WRAP_CONTENT,
            ViewGroup.LayoutParams.WRAP_CONTENT
        )
        view.btnClose.setOnClickListener { showFloatWindow(false) }
        view.ivIcon.setOnTouchListener { v, event ->
            when (event.action) {
                MotionEvent.ACTION_DOWN -> {
                    offX = event.rawX.toInt() - view.x.toInt()
                    offY = event.rawY.toInt() - view.y.toInt()
                    isPressing = true
                    return@setOnTouchListener true
                }
                MotionEvent.ACTION_UP -> {
                    isPressing = false
                    saveLocation(this, view.x.toInt(), view.y.toInt())
                    return@setOnTouchListener true
                }
                MotionEvent.ACTION_MOVE -> {
                    if (isPressing) {
                        view.x = event.rawX - offX
                        view.y = event.rawY - offY
                    }
                    return@setOnTouchListener true
                }
            }
            false
        }
        return view
    }

    /**
     * 设置悬浮窗的显示或隐藏
     */
    protected fun showFloatWindow(isShow: Boolean) {
        val vgContent = findViewById<ViewGroup>(android.R.id.content)
        if (vgContent.childCount == 1) {
            vgContent.addView(FrameLayout(this))
        }
        val vgFloatContainer = vgContent.getChildAt(1) as ViewGroup
        if (isShow) {
            if (vgFloatContainer.childCount == 0) {
                val viewFloat = buildFloatWindow()
                viewFloat.x = loadLocationX(this).toFloat()
                viewFloat.y = loadLocationY(this).toFloat()
                vgFloatContainer.addView(viewFloat)
            }
        } else {
            vgFloatContainer.removeAllViews()
        }
        setShowing(this, isShow)
    }

    /**
     * 删除悬浮窗
     */
    protected fun removeFloatWindow() {
        val vgContent = findViewById<ViewGroup>(android.R.id.content)
        if (vgContent.childCount == 2)
            vgContent.removeViewAt(1)
    }

    /**
     * 保存和读取悬浮窗的参数到配置文件
     */
    companion object {
        private const val FILE = "floatWindow"
        fun isShowing(ctx: Context): Boolean {
            return ctx.getSharedPreferences(FILE, Context.MODE_PRIVATE)
                .getBoolean("show", false)
        }

        private fun setShowing(ctx: Context, isShowing: Boolean) {
            ctx.getSharedPreferences(FILE, Context.MODE_PRIVATE)
                .edit { putBoolean("show", isShowing) }
        }

        private fun loadLocationX(ctx: Context): Int {
            return ctx.getSharedPreferences(FILE, Context.MODE_PRIVATE)
                .getInt("x", 0)
        }

        private fun loadLocationY(ctx: Context): Int {
            return ctx.getSharedPreferences(FILE, Context.MODE_PRIVATE)
                .getInt("y", 0)
        }

        private fun saveLocation(ctx: Context, x: Int, y: Int) {
            ctx.getSharedPreferences(FILE, Context.MODE_PRIVATE)
                .edit {
                    putInt("x", x)
                    putInt("y", y)
                }
        }
    }
}

我们自定义了一个View作为悬浮窗,然后把这个View添加到一个全屏的FrameLayout里面,悬浮窗的移动都是在这个FrameLayout里实现的,最后再把这个FrameLayout添加到内容布局之中。

float_window.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:background="@android:color/white"
    android:elevation="6dp"
    android:gravity="center_vertical"
    android:orientation="horizontal"
    android:padding="12dp">

    <ImageView
        android:layout_width="40dp"
        android:layout_height="40dp"
        android:id="@+id/ivIcon"
        android:src="@mipmap/ic_launcher_round" />

    <TextView
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:padding="12dp"
        android:text="点击图标拖拽" />

    <Button
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:id="@+id/btnClose"
        android:text="关闭" />
</LinearLayout>

在项目中只需简单的调用即可,MainActivity.kt

import android.content.Intent
import android.os.Bundle
import kotlinx.android.synthetic.main.activity_main.*

class MainActivity : FloatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)

        btnSecond.setOnClickListener { startActivity(Intent(this, SecondActivity::class.java)) }

        chkShowFloatWindow.setOnCheckedChangeListener { _, isChecked ->
            showFloatWindow(isChecked) //根据勾选值显示或隐藏悬浮窗
        }
    }

    override fun onResume() {
        super.onResume()
        chkShowFloatWindow.isChecked = FloatActivity.isShowing(this)
    }
}

activity_main.xml

<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".MainActivity">

    <Button
        android:id="@+id/btnSecond"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="跳转SecondActivity"
        android:textAllCaps="false"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintLeft_toLeftOf="parent"
        app:layout_constraintRight_toRightOf="parent"
        app:layout_constraintTop_toTopOf="parent" />

    <CheckBox
        android:id="@+id/chkShowFloatWindow"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_marginTop="20dp"
        android:text="显示悬浮窗"
        app:layout_constraintLeft_toLeftOf="@+id/btnSecond"
        app:layout_constraintRight_toRightOf="@id/btnSecond"
        app:layout_constraintTop_toBottomOf="@id/btnSecond" />

</androidx.constraintlayout.widget.ConstraintLayout>

SecondActivity.kt

import android.os.Bundle
import kotlinx.android.synthetic.main.activity_second.*

class SecondActivity : FloatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_second)

        btnBack.setOnClickListener { onBackPressed() }

        chkShowFloatWindow.setOnCheckedChangeListener { _, isChecked -> showFloatWindow(isChecked) }
    }

    override fun onResume() {
        super.onResume()
        chkShowFloatWindow.isChecked = FloatActivity.isShowing(this)
    }
}

activity_second.xml

<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".SecondActivity">

    <Button
        android:id="@+id/btnBack"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="返回"
        android:textAllCaps="false"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintLeft_toLeftOf="parent"
        app:layout_constraintRight_toRightOf="parent"
        app:layout_constraintTop_toTopOf="parent" />

    <CheckBox
        android:id="@+id/chkShowFloatWindow"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_marginTop="20dp"
        android:text="显示悬浮窗"
        app:layout_constraintLeft_toLeftOf="@+id/btnBack"
        app:layout_constraintRight_toRightOf="@id/btnBack"
        app:layout_constraintTop_toBottomOf="@id/btnBack" />
</androidx.constraintlayout.widget.ConstraintLayout>

点击链接加入QQ群聊:https://jq.qq.com/?_wv=1027&k=5z4fzdT
或关注微信公众号:口袋里的安卓

上一篇 下一篇

猜你喜欢

热点阅读