精华内容
下载资源
问答
  • Android 悬浮窗功能的实现

    万次阅读 多人点赞 2019-08-29 17:15:33
    我们大多数在两种情况下可以看到悬浮窗,一个是视频通话时的悬浮窗,另一个是360卫士的悬浮球,实现此功能的方式比较多,这里以视频通话悬浮窗中的需求为例。编码实现使用Kotlin。Java版本留言邮箱即可。 业务场景 ...

    前言

    我们大多数在两种情况下可以看到悬浮窗,一个是视频通话时的悬浮窗,另一个是360卫士的悬浮球,实现此功能的方式比较多,这里以视频通话悬浮窗中的需求为例。编码实现使用Kotlin。Java版本留言邮箱即可。

    业务场景

    以微信视频通话为例,在视频通话时,我们打开其他应用或点击Home键退出时或点击缩放图标,悬浮窗会显示在其他应用之上,给人的假象是通话页面变小了,点击悬浮窗回到通过页面,悬浮窗消失。退出通话页面悬浮窗消失。

    业务场景技术分析

    在编码之前,我们必须将流程整理好,这样更有利于编码的实现。实现一个功能如果需要10分钟,思考的时间是7分钟,编码占用的时间只是三分钟。

    1.悬浮窗可以显示在其他应用或launchers之上,这个肯定需要悬浮窗权限,而悬浮窗权限属于特殊权限,所以只能通过引导用户去打开无法像危险权限那样直接申请。可以做到后台显示则说明悬浮窗是一个Service。

    2.通话页面隐藏时悬浮窗显示,通话页面显示时悬浮窗隐藏,可以看出悬浮窗和Activity的生命周期相关联,所以悬浮窗的Service和通话页面的Activity是通过bind去绑定的。

    3.既然Service和Activity是通过bind去绑定的,说明当悬浮窗显示的时候,通话Activity虽然不可见但仍在运行。

    结合上述技术问题分析,我们倒叙一一通过编码实现

    悬浮窗实现方案

    • 实现效果

          

    • 准备工作

           首先我们新建一个项目,项目中有两个Activity,我们在第二个Activity编写通话模拟页面。在第二个页面的原因我们后面会讲到。

    • 如何将acitivity置于后台

    其实很简单,我们调用一个方法即可

    moveTaskToBack(true);

    这个方法的含义就是将当前的任务战置于后台,so,为什么我要在第二个Activity中实现的原因之一,因为默认的Activity的启动模式是标准模式,而上面方法会将任务栈置于后台而不是一个单独的Activity,所以我们为了显示悬浮窗时不影响操作软件的其他功能,我们要将通话页面的Activity设置为singleInstance,这样当调用上面方法的时候只是将通话页面所在的Activity栈置于后台,如果你还不了解启动模式可以移步至上一篇文章:Activity的启动模式

    我们现在在右上方的点击事件中添加上述代码,可以看到通话页面的Activity的已经在后台运行了。

    • 判断是否有悬浮窗权限

    点击左上角图标时,我们要先判断当前app是否有悬浮窗权限,首先我们在配置文件中添加,悬浮窗的权限。

    <uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW" />

    (很多文章标题都是悬浮窗如何绕过权限,什么设置类型为TOAST或者PHONE,我想说不可能的事,TOAST类型的虽然部分机型可以显示但是就是一个普通的TOSAT会自动消失)

    那么我们如何判断是否有悬浮窗权限呢,这一块不同厂商处理方案可能不一样,这里我们用一种通用的处理方案,测试表明除了(vivo部分)无效,其他多数机型都ok。并且vivo部分机型微信通话也不会弹出提示(这我就放心了~)

    fun zoom(v: View) {
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
            if (!Settings.canDrawOverlays(this)) {
                Toast.makeText(this, "当前无权限,请授权", Toast.LENGTH_SHORT)
                GlobalDialogSingle(this, "", "当前未获取悬浮窗权限", "去开启", DialogInterface.OnClickListener { dialog, which ->
                    dialog.dismiss()
                    startActivityForResult(Intent(Settings.ACTION_MANAGE_OVERLAY_PERMISSION, Uri.parse("package:" + packageName)), 0)
                }).show()
    
            } else {
                moveTaskToBack(true)
                val intent = Intent(this@Main2Activity, FloatWinfowServices::class.java)
                hasBind = bindService(intent, mVideoServiceConnection, Context.BIND_AUTO_CREATE)
            }
        }
    }

    我们通过Settings.canDrawOverlays(this)来判断当前应用是否有悬浮窗权限,如果没有,我们弹窗提示,通过

    startActivityForResult(Intent(Settings.ACTION_MANAGE_OVERLAY_PERMISSION, Uri.parse("package:" + packageName)), 0)

     跳转到开启悬浮窗权限页面。如果悬浮窗权限已开启,直接将当前任务栈置于后台,开启服务即可。

    其实回调方法,并没有直接告诉我们是否授权成功,所以我们需要在回调中再次判断

    override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent) {
        if (requestCode == 0) {
            if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
                if (!Settings.canDrawOverlays(this)) {
                    Toast.makeText(this, "授权失败", Toast.LENGTH_SHORT).show()
                } else {
                    Handler().postDelayed({
                        val intent = Intent(this@Main2Activity, FloatWinfowServices::class.java)
                        intent.putExtra("rangeTime", rangeTime)
                        hasBind = bindService(intent, mVideoServiceConnection, Context.BIND_AUTO_CREATE)
                        moveTaskToBack(true)
                    }, 1000)
    
                }
            }
        }
    }

    这里我们可以看到回调中延迟了1秒,因为测试发现某些机型反应“过快”,收到回调的时候还以为没有授权成功,其实已经成功了。

    绑定Service我们需要一个ServiceConnection对象

    internal var mVideoServiceConnection: ServiceConnection = object : ServiceConnection {
    
        override fun onServiceConnected(name: ComponentName, service: IBinder) {
            // 获取服务的操作对象
            val binder = service as FloatWinfowServices.MyBinder
            binder.service
        }
    
        override fun onServiceDisconnected(name: ComponentName) {}
    }
    

    Main2Activity的完整代码如下所示:

    /**
     * @author Huanglinqing
     */
    class Main2Activity : AppCompatActivity() {
    
        private val chronometer: Chronometer? = null
        private var hasBind = false
        private val rangeTime: Long = 0
    
        override fun onCreate(savedInstanceState: Bundle?) {
            super.onCreate(savedInstanceState)
            setContentView(R.layout.activity_main2)
        }
    
    
        fun zoom(v: View) {
            if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
                if (!Settings.canDrawOverlays(this)) {
                    Toast.makeText(this, "当前无权限,请授权", Toast.LENGTH_SHORT)
                    GlobalDialogSingle(this, "", "当前未获取悬浮窗权限", "去开启", DialogInterface.OnClickListener { dialog, which ->
                        dialog.dismiss()
                        startActivityForResult(Intent(Settings.ACTION_MANAGE_OVERLAY_PERMISSION, Uri.parse("package:" + packageName)), 0)
                    }).show()
    
                } else {
                    moveTaskToBack(true)
                    val intent = Intent(this@Main2Activity, FloatWinfowServices::class.java)
                    hasBind = bindService(intent, mVideoServiceConnection, Context.BIND_AUTO_CREATE)
                }
            }
        }
    
        internal var mVideoServiceConnection: ServiceConnection = object : ServiceConnection {
    
            override fun onServiceConnected(name: ComponentName, service: IBinder) {
                // 获取服务的操作对象
                val binder = service as FloatWinfowServices.MyBinder
                binder.service
            }
    
            override fun onServiceDisconnected(name: ComponentName) {}
        }
    
        override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent) {
            if (requestCode == 0) {
                if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
                    if (!Settings.canDrawOverlays(this)) {
                        Toast.makeText(this, "授权失败", Toast.LENGTH_SHORT).show()
                    } else {
                        Handler().postDelayed({
                            val intent = Intent(this@Main2Activity, FloatWinfowServices::class.java)
                            intent.putExtra("rangeTime", rangeTime)
                            hasBind = bindService(intent, mVideoServiceConnection, Context.BIND_AUTO_CREATE)
                            moveTaskToBack(true)
                        }, 1000)
    
                    }
                }
            }
        }
    
    
        override fun onRestart() {
            super.onRestart()
            Log.d("RemoteView", "重新显示了")
            //不显示悬浮框
            if (hasBind) {
                unbindService(mVideoServiceConnection)
                hasBind = false
            }
    
        }
    
        override fun onNewIntent(intent: Intent) {
            super.onNewIntent(intent)
        }
    
        override fun onDestroy() {
            super.onDestroy()
        }
    }
    • 新建悬浮窗Service

    新建悬浮窗Service FloatWinfowServices,因为我们使用的BindService,我们在onBind方法中初始化service中的布局

    override fun onBind(intent: Intent): IBinder? {
        initWindow()
        //悬浮框点击事件的处理
        initFloating()
        return MyBinder()
    }

    service中我们通过WindowManager来添加一个布局显示。

    /**
     * 初始化窗口
     */
    private fun initWindow() {
        winManager = application.getSystemService(Context.WINDOW_SERVICE) as WindowManager
        //设置好悬浮窗的参数
        wmParams = params
        // 悬浮窗默认显示以左上角为起始坐标
        wmParams!!.gravity = Gravity.LEFT or Gravity.TOP
        //悬浮窗的开始位置,因为设置的是从左上角开始,所以屏幕左上角是x=0;y=0
        wmParams!!.x = winManager!!.defaultDisplay.width
        wmParams!!.y = 210
        //得到容器,通过这个inflater来获得悬浮窗控件
        inflater = LayoutInflater.from(applicationContext)
        // 获取浮动窗口视图所在布局
        mFloatingLayout = inflater!!.inflate(R.layout.remoteview, null)
        // 添加悬浮窗的视图
        winManager!!.addView(mFloatingLayout, wmParams)
    }

    悬浮窗的参数主要设置悬浮窗的类型为

    WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY

    8.0 以下可设置为:

    wmParams!!.type = WindowManager.LayoutParams.TYPE_PHONE

    代码如下所示:

    private //设置window type 下面变量2002是在屏幕区域显示,2003则可以显示在状态栏之上
            //设置可以显示在状态栏上
            //设置悬浮窗口长宽数据
    val params: WindowManager.LayoutParams
        get() {
            wmParams = WindowManager.LayoutParams()
            if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
                wmParams!!.type = WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY
            } else {
                wmParams!!.type = WindowManager.LayoutParams.TYPE_PHONE
            }
            wmParams!!.flags = WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE or WindowManager.LayoutParams.FLAG_NOT_TOUCH_MODAL or
                    WindowManager.LayoutParams.FLAG_LAYOUT_IN_SCREEN or WindowManager.LayoutParams.FLAG_LAYOUT_INSET_DECOR or
                    WindowManager.LayoutParams.FLAG_WATCH_OUTSIDE_TOUCH
            wmParams!!.width = WindowManager.LayoutParams.WRAP_CONTENT
            wmParams!!.height = WindowManager.LayoutParams.WRAP_CONTENT
            return wmParams
        }

    当点击悬浮窗的时候回到Activity2页面,并且悬浮窗消失,所以我们只需要给悬浮窗添加点击事件

    linearLayout!!.setOnClickListener { startActivity(Intent(this@FloatWinfowServices, Main2Activity::class.java)) }

    当Service走到onDestory的时候将view移除,对于Activity2页面来说 当onResume的时候 解绑Service,当onstop的时候 绑定Service。

    从效果图中我们可以看到悬浮窗可以拖拽的,所以还要设置触摸事件,当移动距离超过某个值的时候让onTouch消费事件,这样就不会触发点击事件了。这个算是view比较基础的知识,相信大家都明白了。

    //开始触控的坐标,移动时的坐标(相对于屏幕左上角的坐标)
    private var mTouchStartX: Int = 0
    private var mTouchStartY: Int = 0
    private var mTouchCurrentX: Int = 0
    private var mTouchCurrentY: Int = 0
    //开始时的坐标和结束时的坐标(相对于自身控件的坐标)
    private var mStartX: Int = 0
    private var mStartY: Int = 0
    private var mStopX: Int = 0
    private var mStopY: Int = 0
    //判断悬浮窗口是否移动,这里做个标记,防止移动后松手触发了点击事件
    private var isMove: Boolean = false
    
    private inner class FloatingListener : View.OnTouchListener {
    
        override fun onTouch(v: View, event: MotionEvent): Boolean {
            val action = event.action
            when (action) {
                MotionEvent.ACTION_DOWN -> {
                    isMove = false
                    mTouchStartX = event.rawX.toInt()
                    mTouchStartY = event.rawY.toInt()
                    mStartX = event.x.toInt()
                    mStartY = event.y.toInt()
                }
                MotionEvent.ACTION_MOVE -> {
                    mTouchCurrentX = event.rawX.toInt()
                    mTouchCurrentY = event.rawY.toInt()
                    wmParams!!.x += mTouchCurrentX - mTouchStartX
                    wmParams!!.y += mTouchCurrentY - mTouchStartY
                    winManager!!.updateViewLayout(mFloatingLayout, wmParams)
                    mTouchStartX = mTouchCurrentX
                    mTouchStartY = mTouchCurrentY
                }
                MotionEvent.ACTION_UP -> {
                    mStopX = event.x.toInt()
                    mStopY = event.y.toInt()
                    if (Math.abs(mStartX - mStopX) >= 1 || Math.abs(mStartY - mStopY) >= 1) {
                        isMove = true
                    }
                }
                else -> {
                }
            }
    
            //如果是移动事件不触发OnClick事件,防止移动的时候一放手形成点击事件
            return isMove
        }
    }

    FloatWinfowServices所有代码如下所示:

    class FloatWinfowServices : Service() {
    
    
        private var winManager: WindowManager? = null
        private var wmParams: WindowManager.LayoutParams? = null
        private var inflater: LayoutInflater? = null
        //浮动布局
        private var mFloatingLayout: View? = null
        private var linearLayout: LinearLayout? = null
        private var chronometer: Chronometer? = null
    
    
        override fun onBind(intent: Intent): IBinder? {
            initWindow()
            //悬浮框点击事件的处理
            initFloating()
            return MyBinder()
        }
    
        inner class MyBinder : Binder() {
            val service: FloatWinfowServices
                get() = this@FloatWinfowServices
        }
    
        override fun onCreate() {
            super.onCreate()
        }
    
        /**
         * 悬浮窗点击事件
         */
        private fun initFloating() {
            linearLayout = mFloatingLayout!!.findViewById<LinearLayout>(R.id.line1)
            linearLayout!!.setOnClickListener { startActivity(Intent(this@FloatWinfowServices, Main2Activity::class.java)) }
            //悬浮框触摸事件,设置悬浮框可拖动
            linearLayout!!.setOnTouchListener(FloatingListener())
        }
    
    
        //开始触控的坐标,移动时的坐标(相对于屏幕左上角的坐标)
        private var mTouchStartX: Int = 0
        private var mTouchStartY: Int = 0
        private var mTouchCurrentX: Int = 0
        private var mTouchCurrentY: Int = 0
        //开始时的坐标和结束时的坐标(相对于自身控件的坐标)
        private var mStartX: Int = 0
        private var mStartY: Int = 0
        private var mStopX: Int = 0
        private var mStopY: Int = 0
        //判断悬浮窗口是否移动,这里做个标记,防止移动后松手触发了点击事件
        private var isMove: Boolean = false
    
        private inner class FloatingListener : View.OnTouchListener {
    
            override fun onTouch(v: View, event: MotionEvent): Boolean {
                val action = event.action
                when (action) {
                    MotionEvent.ACTION_DOWN -> {
                        isMove = false
                        mTouchStartX = event.rawX.toInt()
                        mTouchStartY = event.rawY.toInt()
                        mStartX = event.x.toInt()
                        mStartY = event.y.toInt()
                    }
                    MotionEvent.ACTION_MOVE -> {
                        mTouchCurrentX = event.rawX.toInt()
                        mTouchCurrentY = event.rawY.toInt()
                        wmParams!!.x += mTouchCurrentX - mTouchStartX
                        wmParams!!.y += mTouchCurrentY - mTouchStartY
                        winManager!!.updateViewLayout(mFloatingLayout, wmParams)
                        mTouchStartX = mTouchCurrentX
                        mTouchStartY = mTouchCurrentY
                    }
                    MotionEvent.ACTION_UP -> {
                        mStopX = event.x.toInt()
                        mStopY = event.y.toInt()
                        if (Math.abs(mStartX - mStopX) >= 1 || Math.abs(mStartY - mStopY) >= 1) {
                            isMove = true
                        }
                    }
                    else -> {
                    }
                }
    
                //如果是移动事件不触发OnClick事件,防止移动的时候一放手形成点击事件
                return isMove
            }
        }
    
        /**
         * 初始化窗口
         */
        private fun initWindow() {
            winManager = application.getSystemService(Context.WINDOW_SERVICE) as WindowManager
            //设置好悬浮窗的参数
            wmParams = params
            // 悬浮窗默认显示以左上角为起始坐标
            wmParams!!.gravity = Gravity.LEFT or Gravity.TOP
            //悬浮窗的开始位置,因为设置的是从左上角开始,所以屏幕左上角是x=0;y=0
            wmParams!!.x = winManager!!.defaultDisplay.width
            wmParams!!.y = 210
            //得到容器,通过这个inflater来获得悬浮窗控件
            inflater = LayoutInflater.from(applicationContext)
            // 获取浮动窗口视图所在布局
            mFloatingLayout = inflater!!.inflate(R.layout.remoteview, null)
            chronometer = mFloatingLayout!!.findViewById<Chronometer>(R.id.chronometer)
            chronometer!!.start()
            // 添加悬浮窗的视图
            winManager!!.addView(mFloatingLayout, wmParams)
        }
    
        private //设置window type 下面变量2002是在屏幕区域显示,2003则可以显示在状态栏之上
                //设置可以显示在状态栏上
                //设置悬浮窗口长宽数据
        val params: WindowManager.LayoutParams
            get() {
                wmParams = WindowManager.LayoutParams()
                if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
                    wmParams!!.type = WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY
                } else {
                    wmParams!!.type = WindowManager.LayoutParams.TYPE_PHONE
                }
                wmParams!!.flags = WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE or WindowManager.LayoutParams.FLAG_NOT_TOUCH_MODAL or
                        WindowManager.LayoutParams.FLAG_LAYOUT_IN_SCREEN or WindowManager.LayoutParams.FLAG_LAYOUT_INSET_DECOR or
                        WindowManager.LayoutParams.FLAG_WATCH_OUTSIDE_TOUCH
                wmParams!!.width = WindowManager.LayoutParams.WRAP_CONTENT
                wmParams!!.height = WindowManager.LayoutParams.WRAP_CONTENT
                return wmParams
            }
    
    
        override fun onStartCommand(intent: Intent, flags: Int, startId: Int): Int {
            return super.onStartCommand(intent, flags, startId)
        }
    
        override fun onDestroy() {
            super.onDestroy()
            winManager!!.removeView(mFloatingLayout)
        }
    }
    • 实际应用中需要考虑的一些其他问题

    在使用使用的过程中,我们肯定会遇到其他问题:

    1.用户使用过程中,可能会直接按Home键,这个时候如何提示呢?

       产生问题原因:因为用户按Home键之后,开发者无法重写Home键逻辑,此时应用不在前台运行,无法弹窗提醒,此时用户点击APP图标进入的是第一个栈,这个时候用户就没有进入通话页面的入口了。

      解决方案:

      第一种解决方案 我们可以仿照微信那样去做,就是在整个通话过程中开启一个前台通知,用户点击通知时进入通话页面。

      第二种解决方案 就是检测应用是否在前台,当通话页面在运行的时候,并且应用重新回到前台,我们广播到其他页面,提示权限引导即可。

    2.用户在通话页面(singleInstance模式),点击Home键

    应用在后台运行的时候,通话结束,Activity被finish,此时从任务程序中切回应用你会发现打开的竟然是通话页面!

    这个问题简单的说就是,如果你在通话页面呼叫某人,通话过程中按Home键,然后电话挂断,此时你从任务程序中切回应用,会再次呼叫这个人,也就是这种状态下重新回到了onCreate方法。

    问题产生原因:

    1.因为通话页面是singleInstance模式,此时有两个任务栈,按Home键后再从任务程序中切回,此时应用只保留了第二个任务栈,已经失去了和第一个任务栈的关系,finish之后无法在回到第一个任务栈。

    解决方案:

    1.(不推荐)通话页面不使用singleInstance模式,这种情况下,在通话过程中无法操作软件的其他功能,一般都不采取。

    2.(我目前的解决方案)设置一个标记位,标记当前是否在通话,在onCreate中如果通话已经结束了,跳转到一个过渡页面(标准模式),过渡页面中finish,就可以了,添加过渡页面的原因是我们不知道上一个页面是哪里,因为我们收到来电可能是任意页面,我们我们在过渡页面finsh之后,就再次回到了第一个任务栈。

    如果有其他好的解决方案 欢迎留言。

    如果需要Java版本的小伙伴 ,留言邮箱就可以了,我看到会发到邮箱哦!

    -------2020年6月2日更新------

    Java版本源码已提交至github

    https://github.com/huanglinqing123/RemoteView

    欢迎start 和Issues

    展开全文
  • 参考: Android悬浮窗的实现 WindowManager.LayoutParams的各种flag含义 1.悬浮窗的显示与消失,不影响其他页面的生命周期。

    参考:

    Android悬浮窗的实现

    WindowManager.LayoutParams的各种flag含义

    WindowManager.LayoutParams.type属性

    demo总结:

    1.悬浮窗的显示与消失,不影响其他页面的生命周期。

    2.flag对touch的影响

     btn1和btn2在Activity的页面上,btn3在悬浮窗上

    不设置WindowManager.LayoutParams().flag

    btn1不可touch,btn2不可touch,btn3可touch

    设置flag = WindowManager.LayoutParams.FLAG_NOT_TOUCH_MODAL

    btn1可touch,btn2不可touch,btn3可touch

    设置flag = WindowManager.LayoutParams.FLAG_NOT_TOUCHABLE

    btn1可touch,btn2可touch,btn3不可touch

    没办法做到悬浮窗其上的view和背后的view同时接收到touch

    3.可动态调整悬浮窗的大小

    WindowManager.LayoutParams的宽高设置成wrap_content

    重新设置悬浮窗次级根布局的layoutParams.width&height

    4.type对悬浮窗显示层级的影响

    不设置WindowManager.LayoutParams().type

    悬浮窗不会悬浮在其他应用上,也不会悬浮在本应用的其他Activity上

    设置type = WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY

    悬浮窗会悬浮在其他所有Activity上

    5.原先悬浮窗背后不可touch的view,悬浮窗setVisibility(View.INVISIBLE)隐藏后,就能够touch了

    展开全文
  • WindowManager实现悬浮窗口

    千次阅读 2014-08-18 22:19:14
    调用WindowManager,并设置WindowManager.LayoutParams的相关属性,通过WindowManager的addView方法创建View,这样产生出来的View根据WindowManager.LayoutParams属性不同...比如创建系统顶级窗口,实现悬浮窗口效果!

    调用WindowManager,并设置WindowManager.LayoutParams的相关属性,通过WindowManager的addView方法创建View,这样产生出来的View根据WindowManager.LayoutParams属性不同,效果也就不同了。比如创建系统顶级窗口,实现悬浮窗口效果! 需要特别说明的是,在MIUI系统上面,悬浮框默认是不显示的,需要到设置-应用程序里面找到应用信息,打开显示悬浮窗选项。
    WindowManager的方法很简单,基本用到的就三个addView,removeView,updateViewLayout。 


    而WindowManager.LayoutParams的属性就多了,非常丰富,具体请查看SDK文档。这里给出Android中的WindowManager.java源码,可以具体看一下。 
    WindowManager 的源码地址: 
    http://www.netmite.com/android/mydroid/frameworks/base/core/java/android/view/WindowManager.java 

    public class myFloatView extends Activity {  
        /** Called when the activity is first created. */  
        @Override  
        public void onCreate(Bundle savedInstanceState) {  
            super.onCreate(savedInstanceState);  
            setContentView(R.layout.main);  
            Button bb=new Button(getApplicationContext());  
            WindowManager wm=(WindowManager)getApplicationContext().getSystemService("window");  
            WindowManager.LayoutParams wmParams = new WindowManager.LayoutParams();  
            //wmParams.type=2002;  //type是关键,这里的2002表示系统级窗口,你也可以试试2003。 
    		wmParams.type=LayoutParams.TYPE_PHONE;
    		//wmParams.format=PixelFormat.RGBA_8888;   //设置图片格式,效果为背景透明
            wmParams.format=1;  
           
    		wmParams.flags=LayoutParams.FLAG_NOT_TOUCH_MODAL  
                                 | LayoutParams.FLAG_NOT_FOCUSABLE;  
           /* 
            * 下面的flags属性的效果形同“锁定”。 
            * 悬浮窗不可触摸,不接受任何事件,同时不影响后面的事件响应。 
            wmParams.flags=LayoutParams.FLAG_NOT_TOUCH_MODAL  
                                  | LayoutParams.FLAG_NOT_FOCUSABLE 
                                  | LayoutParams.FLAG_NOT_TOUCHABLE; 
           */  
    	    /** 
             *这里的flags也很关键 
             *代码实际是wmParams.flags |= FLAG_NOT_FOCUSABLE; 
             *40的由来是wmParams的默认属性(32)+ FLAG_NOT_FOCUSABLE(8) 
             */  
            //wmParams.flags=40;  
            wmParams.width=40;  
            wmParams.height=40;  
            wm.addView(bb, wmParams);//创建View  
        }  
    }  
     

    另外别忘了在AndroidManifest.xml文件中加入如下权限: 
    <uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW" /> 

    关于代码中wmParams.type的值的问题,下面给出一些数值参考:

    /** 
             * Window type: the status bar.  There can be only one status bar 
             * window; it is placed at the top of the screen, and all other 
             * windows are shifted down so they are below it. 
             */ 
            public static final int TYPE_STATUS_BAR         = FIRST_SYSTEM_WINDOW; 
        
            /** 
             * Window type: the search bar.  There can be only one search bar 
             * window; it is placed at the top of the screen. 
             */ 
            public static final int TYPE_SEARCH_BAR         = FIRST_SYSTEM_WINDOW+1; 
        
            /** 
             * Window type: phone.  These are non-application windows providing 
             * user interaction with the phone (in particular incoming calls). 
             * These windows are normally placed above all applications, but behind 
             * the status bar. 
             */ 
            public static final int TYPE_PHONE              = FIRST_SYSTEM_WINDOW+2; 
        
            /** 
             * Window type: system window, such as low power alert. These windows 
             * are always on top of application windows. 
             */ 
            public static final int TYPE_SYSTEM_ALERT       = FIRST_SYSTEM_WINDOW+3; 

    这个FIRST_SYSTEM_WINDOW的值就是2000。2003和2002的区别就在于2003类型的View比2002类型的还要top,能显示在系统下拉状态栏之上! 


       可以看出来,2002的值的含义其实就是2000+2。数值2000的含义就是系统级窗口,2002和2003的区别就是 2003 比 2002还要更上一层!比如,type为2003的view,能显示在手机下拉状态栏之上! 
       而关于flags等其他的属性请参考SDK文档
       
       
       下面给出实现可以移动的悬浮窗步骤:
       1、通过覆写悬浮View中onTouchEvent方法实现自由移动悬浮窗口。 


    2、悬浮窗口坐标的移动实际是windowMananager.LayoutParams中x和y的变换,但是要注意设置相应的gravity。 


    3、用windowManager创建的View,当不需要时,务必记住使用windowManager的removeView方法来移除,请在Activity相关生命周期中自行添加扫尾工作。
    public class MyFloatView extends ImageView {
    	private float mTouchStartX;
        private float mTouchStartY;
        private float x;
        private float y;
        
        private WindowManager wm=(WindowManager)getContext().getApplicationContext().getSystemService("window");
        private WindowManager.LayoutParams wmParams = ((MyApplication)getContext().getApplicationContext()).getMywmParams();
    
    	public MyFloatView(Context context) {
    		super(context);		
    		// TODO Auto-generated constructor stub
    	}
    	
    	 @Override
    	 public boolean onTouchEvent(MotionEvent event) {
    		 
    		 
    		 //获取相对屏幕的坐标,即以屏幕左上角为原点		 
    	     x = event.getRawX();   
    	     y = event.getRawY()-25;   //25是系统状态栏的高度
    	     Log.i("currP", "currX"+x+"====currY"+y);
    	     switch (event.getAction()) {
    	        case MotionEvent.ACTION_DOWN:
    	        	//获取相对View的坐标,即以此View左上角为原点
    	        	mTouchStartX =  event.getX();  
                    mTouchStartY =  event.getY();
                    
    	            Log.i("startP", "startX"+mTouchStartX+"====startY"+mTouchStartY);
    	            
    	            break;
    	        case MotionEvent.ACTION_MOVE:	            
    	            updateViewPosition();
    	            break;
    
    	        case MotionEvent.ACTION_UP:
    	        	updateViewPosition();
    	        	mTouchStartX=mTouchStartY=0;
    	        	break;
    	        }
    	        return true;
    		}
    	 
    	 private void updateViewPosition(){
    		//更新浮动窗口位置参数
    		wmParams.x=(int)( x-mTouchStartX);
    		wmParams.y=(int) (y-mTouchStartY);
    	    wm.updateViewLayout(this, wmParams);
    	    
    	 }
    
    }


    展开全文
  • 自定义悬浮条功能集成在通用视频控件中,就是提供一个顶部的悬浮条,放一排功能按钮,有抓拍、录像、云台控制、关闭等,相当于可以直接单击对应的按钮针对该通道的视频进行操作,悬浮条的含义就是鼠标移入的时候显示...

    一、前言

    自定义悬浮条功能集成在通用视频控件中,就是提供一个顶部的悬浮条,放一排功能按钮,有抓拍、录像、云台控制、关闭等,相当于可以直接单击对应的按钮针对该通道的视频进行操作,悬浮条的含义就是鼠标移入的时候显示出来,移除的时候自动隐藏,只在需要的时候显示出来,为视频画面尽可能多的流出空间显示,自定义悬浮条默认在顶部,也可以自行改成上下左右四个位置显示,视频控件拉伸大小的时候自动填充,有些厂家做的悬浮条是固定的,估计是因为技术上不过关才选择做成这样的,大部分会做成悬浮半透明,悬浮半透明相对来说难度大一些,通用的视频控件悬浮条部分,通过设置按钮文本集合来自动生成按钮,按钮单击自动发送对应按钮单击的信号出去,至于该按钮应该触发执行何种操作动作,这个由具体的厂家程序员去实现,毕竟每个用户要求的悬浮条功能都不一样。

    二、功能特点

    软件模块

    1. 视频监控模块,各种停靠小窗体子模块,包括设备列表、图文警情、窗口信息、云台控制、预置位、巡航设置、设备控制、悬浮地图、网页浏览等。
    2. 视频回放模块,包括本地回放、远程回放、设备播放、图片回放、视频上传等。
    3. 电子地图模块,包括图片地图、在线地图、离线地图、路径规划等。
    4. 日志查询模块,包括本地日志、设备日志等。
    5. 系统设置模块,包括系统设置(基本设置、视频参数、数据库设置、地图配置、串口配置等)、录像机管理、摄像机管理、轮询配置、用户管理等。

    基础功能

    1. 支持各种视频流(rtsp、rtmp、http等)、视频文件(mp4、rmvb、avi等)、本地USB摄像机播放。
    2. 支持多画面切换,包括1、4、6、8、9、13、16、25、36、64画面切换。
    3. 支持全屏切换,多种切换方式包括鼠标右键菜单、工具栏按钮、快捷键(alt+enter全屏,esc退出全屏)。
    4. 支持视频轮询,包括1、4、9、16画面轮询,可设置轮询分组(轮询预案)、轮询间隔、码流类型等。
    5. 支持onvif协议,包括设备搜索、云台控制、设备控制(图片参数、校对时间、系统重启,抓拍图片等)。
    6. 支持权限管理,不同的用户可以对应不同的模块权限,比如删除日志、关闭系统等。
    7. 数据库支持多种,包括sqlite、mysql、sqlserver、postgresql、oracle、人大金仓等。
    8. 本地USB摄像机支持设置分辨率、帧率等参数。
    9. 所有停靠模块都自动生成对应的菜单用来控制显示和隐藏,在标题栏右键可以弹出。
    10. 支持显示所有模块、隐藏所有模块、复位普通布局、复位全屏布局。
    11. 双击设备弹出实时预览视频,支持图片地图、在线地图、离线地图等。
    12. 摄像机节点拖曳到对应窗体播放视频,同时支持拖曳本地文件直接播放。
    13. 删除视频支持鼠标右键删除、悬浮条关闭删除、拖曳到视频监控面板外删除等多种方式。
    14. 图片地图上设备按钮可自由拖动,自动保存位置信息。百度地图上可以鼠标单击获取经纬度信息,用来更新设备位置。
    15. 视频监控面板窗体中任意通道支持拖曳交换,瞬间响应。
    16. 封装了百度地图,视图切换,运动轨迹,设备点位,鼠标按下获取经纬度等。
    17. 双击节点、拖曳节点、拖曳窗体交换位置等操作,均自动更新保存最后的播放地址,下次软件打开自动应用。
    18. 右下角音量条控件,失去焦点自动隐藏,音量条带静音图标。
    19. 支持视频截图,可指定单个或者对所有通道截图,底部小工具栏也有截图按钮。
    20. 支持超时自动隐藏鼠标指针、自动全屏机制。
    21. 支持onvif云台控制,可上下左右移动云台摄像机,包括复位和焦距调整等。
    22. 支持任意onvif摄像机,包括但不限于海康、大华、宇视、天地伟业、华为等。
    23. 可保存视频,可选定时存储或者单文件存储,可选存储间隔时间。
    24. 可设置视频流通信方式tcp+udp,可设置视频解码是速度优先、质量优先、均衡等。
    25. 可设置软件中文名称、英文名称、LOGO图标等。
    26. 存储的视频文件支持导出到指定目录,支持批量上传到服务器。

    特色功能

    1. 主界面采用停靠窗体模式,各种组件以小模块的形式加入,可自定义任意模块加入。
    2. 停靠模块可拖动任意位置嵌入和悬浮,支持最大化全屏,支持多屏幕。
    3. 双重布局文件存储机制,正常模式、全屏模式都对应不同的布局方案,自动切换和保存,比如全屏模式可以突出几个模块透明显示在指定位置,更具科幻感现代化。
    4. 原创onvif协议机制,采用底层协议解析(udp广播搜索+http请求执行命令)更轻量易懂易学习拓展,不依赖任何第三方组件比如gsoap。
    5. 原创数据导入导出机制,跨平台不依赖任何组件,瞬间导出数据。
    6. 内置多个原创组件,宇宙超值超级牛逼,包括数据导入导出组件(导出到xls、pdf、打印)、数据库组件(数据库管理线程、自动清理数据线程、万能分页、数据请求等)、地图组件、视频监控组件、文件多线程收发组件、onvif通信组件、通用浏览器内核组件等。
    7. 自定义信息框+错误框+询问框+右下角提示框(包含多种格式)等。
    8. 精美换肤,高达17套皮肤样式随意更换,所有样式全部统一,包括菜单等。
    9. 视频控件悬浮条可以自行增加多个按钮,监控界面底部小工具栏也可自行增加按钮。
    10. 双击摄像机节点自动播放视频,双击节点自动依次添加视频,会自动跳到下一个,双击父节点自动添加该节点下的所有视频。可选主码流、子码流。
    11. 录像机管理、摄像机管理,可添加删除修改导入导出打印信息,立即应用新的设备信息生成树状列表,不需重启。
    12. 可选多种内核自由切换,ffmpeg、vlc、mpv等,均可在pro中设置。推荐用ffmpeg,跨平台最多,默认提供好了linux和mac平台上编译好的库。
    13. 支持硬解码,可设置硬解码类型(qsv、dxva2、d3d11va等)。
    14. 默认采用opengl绘制视频,超低的CPU资源占用,支持yuyv和nv12两种格式绘制,很牛逼。
    15. 高度可定制化,用户可以很方便的在此基础上衍生自己的功能,比如增加自定义模块,增加运行模式、机器人监控、无人机监控、挖掘机监控等。
    16. 支持xp、win7、win10、linux、mac、各种国产系统(UOS、中标麒麟、银河麒麟等)、嵌入式linux等系统。
    17. 注释完整,项目结构清晰,超级详细完整的使用开发手册,精确到每个代码文件的功能说明,不断持续迭代版本。

    三、体验地址

    1. 体验地址:https://pan.baidu.com/s/1d7TH_GEYl5nOecuNlWJJ7g 提取码:01jf 文件名:bin_video_system.zip。
    2. 国内站点:https://gitee.com/feiyangqingyun
    3. 国际站点:https://github.com/feiyangqingyun
    4. 个人主页:https://blog.csdn.net/feiyangqingyun
    5. 知乎主页:https://www.zhihu.com/people/feiyangqingyun/

    四、效果图

    在这里插入图片描述

    五、核心代码

    VideoWidget::VideoWidget(QWidget *parent) : QWidget(parent)
    {
        //设置强焦点
        setFocusPolicy(Qt::StrongFocus);
        //设置支持拖放
        setAcceptDrops(true);
    
        timerCheck = new QTimer(this);
        timerCheck->setInterval(10 * 1000);
        connect(timerCheck, SIGNAL(timeout()), this, SLOT(checkVideo()));
    
        image = QImage();
    
        //顶部工具栏,默认隐藏,鼠标移入显示移除隐藏
        flowPanel = new QWidget(this);
        flowPanel->setObjectName("flowPanel");
        flowPanel->setVisible(false);
    
        //用布局顶住,左侧弹簧
        QHBoxLayout *layout = new QHBoxLayout;
        layout->setSpacing(2);
        layout->setMargin(0);
        layout->addStretch();
        flowPanel->setLayout(layout);
    
        //按钮集合名称,如果需要新增按钮则在这里增加即可
        QList<QString> btns;
        btns << "btnFlowVideo" << "btnFlowSnap" << "btnFlowSound" << "btnFlowAlarm" << "btnFlowClose";
    
        //有多种办法来设置图片,qt内置的图标+自定义的图标+图形字体
        //既可以设置图标形式,也可以直接图形字体设置文本
    #if 0
        QList<QIcon> icons;
        icons << QApplication::style()->standardIcon(QStyle::SP_ComputerIcon);
        icons << QApplication::style()->standardIcon(QStyle::SP_FileIcon);
        icons << QApplication::style()->standardIcon(QStyle::SP_DirIcon);
        icons << QApplication::style()->standardIcon(QStyle::SP_DialogOkButton);
        icons << QApplication::style()->standardIcon(QStyle::SP_DialogCancelButton);
    #else
        QList<QChar> chars;
        chars << 0xe68d << 0xe672 << 0xe674 << 0xea36 << 0xe74c;
    
        //判断图形字体是否存在,不存在则加入
        QFont iconFont;
        QFontDatabase fontDb;
        if (!fontDb.families().contains("iconfont")) {
            int fontId = fontDb.addApplicationFont(":/image/iconfont.ttf");
            QStringList fontName = fontDb.applicationFontFamilies(fontId);
            if (fontName.count() == 0) {
                qDebug() << "load iconfont.ttf error";
            }
        }
    
        if (fontDb.families().contains("iconfont")) {
            iconFont = QFont("iconfont");
            iconFont.setPixelSize(17);
    #if (QT_VERSION >= QT_VERSION_CHECK(4,8,0))
            iconFont.setHintingPreference(QFont::PreferNoHinting);
    #endif
        }
    #endif
    
        //循环添加顶部按钮
        for (int i = 0; i < btns.count(); i++) {
            QPushButton *btn = new QPushButton;
            //绑定按钮单击事件,用来发出信号通知
            connect(btn, SIGNAL(clicked(bool)), this, SLOT(btnClicked()));
            //设置标识,用来区别按钮
            btn->setObjectName(btns.at(i));
            //设置固定宽度
            btn->setFixedWidth(20);
            //设置拉伸策略使得填充
            btn->setSizePolicy(QSizePolicy::Preferred, QSizePolicy::Expanding);
            //设置焦点策略为无焦点,避免单击后焦点跑到按钮上
            btn->setFocusPolicy(Qt::NoFocus);
    
    #if 0
            //设置图标大小和图标
            btn->setIconSize(QSize(16, 16));
            btn->setIcon(icons.at(i));
    #else
            btn->setFont(iconFont);
            btn->setText(chars.at(i));
    #endif
    
            //将按钮加到布局中
            layout->addWidget(btn);
        }
    
        copyImage = false;
        checkLive = true;
        drawImage = true;
        fillImage = true;
    
        flowEnable = false;
        flowBgColor = "#000000";
        flowPressColor = "#5EC7D9";
    
        timeout = 20;
        borderWidth = 5;
        borderColor = "#000000";
        focusColor = "#22A3A9";
        bgText = "实时视频";
        bgImage = QImage();
    
        osd1Visible = false;
        osd1FontSize = 12;
        osd1Text = "时间";
        osd1Color = "#FF0000";
        osd1Image = QImage();
        osd1Format = OSDFormat_DateTime;
        osd1Position = OSDPosition_Right_Top;
    
        osd2Visible = false;
        osd2FontSize = 12;
        osd2Text = "通道名称";
        osd2Color = "#FF0000";
        osd2Image = QImage();
        osd2Format = OSDFormat_Text;
        osd2Position = OSDPosition_Left_Bottom;
    
        this->initFlowStyle();
    }
    
    VideoWidget::~VideoWidget()
    {
        if (timerCheck->isActive()) {
            timerCheck->stop();
        }
    
        close();
    }
    
    void VideoWidget::resizeEvent(QResizeEvent *)
    {
        //重新设置顶部工具栏的位置和宽高,可以自行设置顶部显示或者底部显示
        int height = 20;
        flowPanel->setGeometry(borderWidth, borderWidth, this->width() - (borderWidth * 2), height);
        //flowPanel->setGeometry(borderWidth, this->height() - height - borderWidth, this->width() - (borderWidth * 2), height);
    }
    
    void VideoWidget::enterEvent(QEvent *)
    {
        //这里还可以增加一个判断,是否获取了焦点的才需要显示
        //if (this->hasFocus()) {}
        if (flowEnable) {
            flowPanel->setVisible(true);
        }
    }
    
    void VideoWidget::leaveEvent(QEvent *)
    {
        if (flowEnable) {
            flowPanel->setVisible(false);
        }
    }
    
    展开全文
  • Eclipse有一个很好的功能,就是当代码调用某个android API时,鼠标移到对应的函数或者方法上,就会自动有一个悬浮窗提示该函数的说明(所包含的参数含义,方法功能)。但是在Android Studio鼠标移到函数上,发现悬浮...
  • android源码 ,WindowManager,悬浮
  • 刚开始接触悬浮窗,看到网上有很多关于各个type 的介绍,但是没有具体说明它们之间的z轴顺序,所以特意做了调查。 一、含义: 首先先贴上各个type 的意义 应用程序窗口。 public static final int FIRST_...
  • 添加一个悬浮组件(next 主题)

    千次阅读 2021-04-03 19:59:32
    为博客添加一个悬浮组件,内置功能:回到顶端、实现深色模式、跳转评论区、播放背景音乐。
  • 自定义Echarts折线图中悬浮框的位置

    千次阅读 2018-08-31 17:48:28
    在Echarts3的折线图的使用过程中,想使初始化出来的图表更人性化,就比如说有这么个需求,在Echarts折线图中,鼠标悬浮在左侧某一个点时,悬浮框悬停在点的右侧;鼠标悬停在右侧某一个点时,悬浮框悬停在点的左侧。 ...
  • 任务目的 ... 掌握常用HTML标签的含义、用法 ... ...掌握基本的CSS编码,包括以下但不限于: ...掌握CSS选择器的含义和用法 实践并掌握CSS的颜色、字体、背景、边框、盒模型、简单布局等样式的定义方式...
  • 1、解决高版本小米、魅族等手机悬浮窗权限报Android permission denied for window type 2002错误。 2、解决黑马程序员教学视频中悬浮窗在高版本安卓手机上不能移动的问题。 /*************************************...
  • Android 中ListView悬浮头部效果设置

    千次阅读 2017-04-15 18:26:19
    Android 中ListView悬浮头部效果设置Android界面开发中,ListView上面会有一个一直显示的筛选条件的条目,有时候上面又会有一些简单介绍,但是这个简单介绍内容一般都是ListView上滑后会消失,而筛选条件的条目一直...
  • 悬浮框PopupWindow的功能和使用 Android的对话框有两种:PopupWindow和AlertDialog。 不同之处 1、AlertDialog的位置固定,而PopupWindow的位置可以随意 2、AlertDialog是非阻塞线程的,而PopupWindow是阻塞线程...
  • cursor鼠标悬浮效果和opacity透明度效果 cursor鼠标悬浮效果 opacity透明度的效果 提示: 博主:章飞_906285288 博客地址:http://blog.csdn.net/qq_29924041 cursor鼠标悬浮效果我们知道a标签是有一个默认鼠标...
  • Android6.0和悬浮窗权限

    千次阅读 2019-06-04 20:20:07
    Unable to add window android.view.ViewRootImpl -- permission denied for this window type Android6.0系统悬浮窗权限的问题解决方法 Android项目该如何选择targetSdkVersion AndroidStudio中各个SdkVersion的含义
  • 想要做一个简单的像悬浮球那样的能在手机桌面上随意拖动的效果,首先你需要知道 1:往手机桌面上添加一个自定义view(悬浮球)使用的是WindowManager.addView(View view, WindowManager.LayoutParams params); ---->...
  • 悬浮搜索框是当数据界面不断滚动时,搜索框始终悬浮在最上方。来看一下效果图 UI代码 <view class="search-wrapper"> <view class="search-panel"> <view class="search-section"> <...
  • android 悬浮球 (所有界面可用) 开发 在测试的时候,会遇到屏幕旋转,导致布局失效错乱 解决方案 1 直接指定 landscape 或者portrait ..... mFBParams = new LayoutParams(); //设置悬浮球布局的参数 mFBParams....
  • 具体思路是这样的:当用户点击左上角最小化按钮的时候,最小化视频通话Activity(这时Activity处于后台状态),于此同时开启悬浮框,新建一个新的ViewGroup将全局Constents.mVideoViewLayout中用户选中的最大View...
  • 本文简单介绍一下自定义指令的基本用法,并实现一个指令v-drag实现悬浮框拖动功能。 一、基本用法 1)注册  类似组件,指令的注册也可分为全局注册与局部注册。顾名思义,全局注册即在项目下所有Vue组件中都可...
  • 各值的含义:  【A】stateUnspecified:软键盘的状态并没有指定,系统将选择一个合适的状态或依赖于主题的设置  【B】stateUnchanged:当这个activity出现时,软键盘将一直保持在上一个activity里的状态,无论是...
  • 极客学院的网站上有一些悬浮的页面元素,很影响正常使用。 于是经过查找Adblock Plus官方的过滤规则撰写说明书,在Adblock Plus的自定义过滤规则中加入如下3条,即可屏蔽。   jikexueyuan.com##div.pewm1 ...
  • &lt;html&gt; &lt;head&gt; &lt;meta http-equiv ="Content-Type" content ="text/html; charset=UTF-8"&gt; &...text/javascript&qu
  • 图1:初始化项目时的效果 图2: 改变背景色和字体颜色后的效果 图3:实现沉浸式透明悬浮后的效果 简单介绍一下ionic的StatusBar中的一些属性,具体内容请参见Ionic官方文档(https://ionicframework...
  • CSS的含义

    2021-07-20 18:56:56
    CSS的含义 描述 HTML:网页结构的表达 CSS:网页样式的修饰 JS:网页样式的互动 CSS:层叠样式表 作用: 修饰页面的标签 例如:颜色,大小,间距 对页面的元素进行定位和布局 含义:所谓层叠,可以将...
  • 基本程序的含义: Public Class Form1:Inherits System.Windows.Forms.Form End Class   或者 Pubic Class Form1 Inherit System.Windows.Forms.Form End Class  但是System.Window.Forms里...
  • 本模块共有六篇文章,参考郭神的《第一行代码》,对Material Design的学习做一个详细的笔记,大家可以一起交流一下: Material Design 实战 之...Material Design 实战 之第三弹—— 悬浮按钮和可交互提示(...
  • (注意三个参数含义:view,"text", Snackbar.LENGTH_SHORT); 2.2 Snackbar的make()后面连缀调用setAction()来设置一个动作 (两个参数:一参为bar栏右侧点击交互显示内容,二参为重写onClick()的...

空空如也

空空如也

1 2 3 4 5 ... 20
收藏数 3,629
精华内容 1,451
关键字:

悬浮的含义