Day22 - 悬浮视窗

总算到了悬浮视窗这步了...

悬浮视窗的原理其实很简单,建立一个背景运作的Service,并且透过WindowManager将自定义的Layout显示出来。

本文参考来源

How to Make a Floating Window Application in Android?

设定权限

AndroidManifest.xml

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

分别是悬浮视窗以及Foreground Service需要的权限

权限说明:
SYSTEM_ALERT_WINDOW
FOREGROUND_SERVICE

Overlay

为了确保我们的悬浮视窗能在其他App上层,需要让使用者另外开启设定

// 检查是否已开启。
private fun checkOverlayDisplayPermission(): Boolean =
    Settings.canDrawOverlays(requireContext())

//使用Intent导至设定页面。
private fun requestOverlayDisplayPermission() {
    AlertDialog.Builder(requireContext())
        .setCancelable(true)
        .setTitle("Screen Overlay Permission Needed")
        .setMessage("Enable 'Display over other apps' from System Settings.")
        .setPositiveButton("Open Settings") { _, _ ->
            val intent = Intent(
                Settings.ACTION_MANAGE_OVERLAY_PERMISSION,
                Uri.parse("package:${requireContext().packageName}")
            )
            launcher.launch(intent)
        }
        .show()
}

Start Service

Day21加入的popup按钮的点击事件

binding.popup.setOnClickListener {
    //TODO save search items.
    val serviceIntent = Intent(requireActivity(), ObserveService::class.java)
    it.context.startForegroundService(serviceIntent)
    requireActivity().finish()
}

基本上就是开启Service并结束MainActivityTODO的部分是日後要处理的事情,不会在今天的内容中。
使用startForegroundService启动的话要在Service中建立Notification并使用对应的startForeground方法,这部分已经更新很久就不另外放程序码了。

建立悬浮视窗

首先建立layout(layout_observer.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"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:background="@color/background">

    <pet.ca.ptttweetsobserver.ControllableRecyclerView
        android:id="@+id/recyclerView"
        android:layout_width="0dp"
        android:layout_height="0dp"
        android:background="@color/transparent"
        android:padding="@dimen/one_grid_unit"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent" />

    <Button
        android:id="@+id/resumeUpdate"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_marginBottom="@dimen/one_grid_unit"
        android:text="自动更新"
        android:translationY="200dp"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent" />

    <ImageView
        android:id="@+id/back"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_marginStart="@dimen/one_grid_unit"
        android:layout_marginTop="@dimen/one_grid_unit"
        android:background="@color/text_normal"
        android:clickable="true"
        android:elevation="@dimen/half_grid_unit"
        android:focusable="true"
        android:foreground="?android:attr/selectableItemBackground"
        android:padding="@dimen/one_grid_unit"
        android:src="@drawable/ic_baseline_fullscreen_24"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent" />

    <View
        android:id="@+id/divider"
        android:layout_width="1dp"
        android:layout_height="0dp"
        android:background="@color/text_normal"
        app:layout_constraintBottom_toBottomOf="@id/back"
        app:layout_constraintStart_toEndOf="@id/back"
        app:layout_constraintTop_toTopOf="@id/back" />

    <ImageView
        android:id="@+id/move"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_marginTop="@dimen/one_grid_unit"
        android:background="@color/text_normal"
        android:clickable="true"
        android:elevation="@dimen/half_grid_unit"
        android:focusable="true"
        android:foreground="?android:attr/selectableItemBackground"
        android:padding="@dimen/one_grid_unit"
        android:src="@drawable/selector_move"
        app:layout_constraintStart_toEndOf="@id/divider"
        app:layout_constraintTop_toTopOf="parent" />

    <View
        android:id="@+id/divider2"
        android:layout_width="1dp"
        android:layout_height="0dp"
        android:background="@color/text_normal"
        app:layout_constraintBottom_toBottomOf="@id/move"
        app:layout_constraintStart_toEndOf="@id/move"
        app:layout_constraintTop_toTopOf="@id/move" />

    <ImageView
        android:id="@+id/resize"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_marginTop="@dimen/one_grid_unit"
        android:background="@color/text_normal"
        android:clickable="true"
        android:elevation="@dimen/half_grid_unit"
        android:focusable="true"
        android:foreground="?android:attr/selectableItemBackground"
        android:padding="@dimen/one_grid_unit"
        android:src="@drawable/ic_baseline_pinch_24"
        app:layout_constraintStart_toEndOf="@id/divider2"
        app:layout_constraintTop_toTopOf="parent" />


    <ImageView
        android:id="@+id/close"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_marginTop="@dimen/one_grid_unit"
        android:layout_marginEnd="@dimen/one_grid_unit"
        android:background="@color/text_normal"
        android:clickable="true"
        android:elevation="@dimen/half_grid_unit"
        android:focusable="true"
        android:foreground="?android:attr/selectableItemBackground"
        android:padding="@dimen/one_grid_unit"
        android:src="@drawable/ic_round_close_24"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintTop_toTopOf="parent" />


</androidx.constraintlayout.widget.ConstraintLayout>

预览画面
https://ithelp.ithome.com.tw/upload/images/20211006/20124602e94frlIdse.png

Day02的草图有些差异,目前这边基本只保留调整外框大小跟位置的按钮了。

接着在程序码中Inflate并加入到WindowManager

private fun createFloatingWindow() {
    val metrics = application.resources.displayMetrics
    val width = metrics.widthPixels
    val height = metrics.heightPixels

    windowManager = getSystemService(WINDOW_SERVICE) as WindowManager

    val inflater = getSystemService(LAYOUT_INFLATER_SERVICE) as LayoutInflater
    _binding = LayoutObserverBinding.inflate(inflater)

    val frameLayoutParams = WindowManager.LayoutParams(
        (width * 0.55f).toInt(),
        (height * 0.58f).toInt(),
        layoutType,
        WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE,
        PixelFormat.TRANSLUCENT
    )
    frameLayoutParams.gravity = Gravity.CENTER
    frameLayoutParams.x = 0
    frameLayoutParams.y = 0

    windowManager.addView(binding.root, frameLayoutParams)
}

其实这段没什麽好说明的,addView後画面就会显示出来了。

读取Ptt推文

这部分的内容与Day17Day19Day20基本一致,差别只在於目前已经在文章内了,不需要多做进入文章并置底的前置动作。

今天除了建立悬浮视窗,也一并把移动和关闭的功能加入了,退回和修改大小因为我目前想起来比较难一些,预计会放在接下来几天的内容

移动悬浮视窗

移动按钮的点击事件很单纯:

binding.move.setOnClickListener {
    if (it.isSelected) {
        binding.root.setOnTouchListener(null)
    } else {
        binding.root.setOnTouchListener(onFrameTouchListener)
    }
    binding.recyclerView.touchable = it.isSelected
    it.isSelected = !it.isSelected
}

使用isSelected状态来判断目前要切换成哪种模式,在移动模式中加入onFrameTouchListener来处理後续触控事件。此外由於我们的Layout内有使用RecyclerView,为了避免事件被拦截需要做另外的处理。

ControllableRecyclerView

自定义一个新的RecyclerView并override dispatchTouchEvent

class ControllableRecyclerView @JvmOverloads constructor(
    context: Context,
    attrs: AttributeSet? = null,
    defStyleAttr: Int = 0
) : RecyclerView(context, attrs, defStyleAttr) {

    var touchable = true

    override fun dispatchTouchEvent(ev: MotionEvent?): Boolean {
        return (touchable && super.dispatchTouchEvent(ev))
    }
}

透过另外设定的touchable变数来判断是否要在RecyclerView继续分发事件,return记得一定要包含super.dispatchTouchEvent(ev),否则就算为true也不会将事件继续分发。

onFrameTouchListener

悬浮视窗的移动事件:

private val onFrameTouchListener = View.OnTouchListener { _, motionEvent ->
    val params = binding.root.layoutParams as WindowManager.LayoutParams
    when (motionEvent.action) {
        MotionEvent.ACTION_DOWN -> {
            return@OnTouchListener true
        }
        MotionEvent.ACTION_MOVE -> {
            if (motionEvent.historySize < 2) return@OnTouchListener true
            val dx = motionEvent.getHistoricalX(1) - motionEvent.getHistoricalX(0)
            val dy = motionEvent.getHistoricalY(1) - motionEvent.getHistoricalY(0)
            params.x = (params.x + dx).toInt()
            params.y = (params.y + dy).toInt()

            windowManager.updateViewLayout(binding.root, params)
            return@OnTouchListener true
        }
    }

    return@OnTouchListener false
}

透过Move event的HistoricalXHistoricalY来判断移动的距离,并更新目前的LayoutParams即可。
目前在模拟器里面测试起来感觉有点误差,这部分後日再看看怎麽处理。

关闭Service

binding.close.setOnClickListener {
    stopSelf()
    PttClient.getInstance().end()
}

首先是使用stopSelf关闭Service,关闭Service会进入onDestroy,在这边把其他物件给释放掉。

override fun onDestroy() {
    super.onDestroy()
    updateHandler.removeCallbacks(updateRunnable)
    removeFloatingWindow()
    stopForeground(true)
}

接着要记得把PttClient也关掉,关闭连线、stream并释放singleton的reference。

class PttClient private constructor(serverUri: URI, header: MutableMap<String, String>) :
    WebSocketClient(serverUri, Draft_6455(), header) {
    // ...
    public fun end() {
        close()
        pipedOutputStream.close()
        pipedInputStream.close()
        instance = null
    }
    // ...
}

目前画面

https://imgur.com/VVoy14I.gif


<<:  【Day 25】C String

>>:  23 搞半天终於在网页上启动游戏了

DAY28: 光速了解与操作NVM

NVM与NPM虽然差一个字,但两者兼具着不同的工作。 NVM全名Node Version Manag...

【Online Assessment】CS fundamentals、资结、演算法

前测是敲门砖,决定他们要不要和你面试, 我听说过有人前测找代打, 也碰过某个公司的前测题目同期完全一...

Array筛选特定值

缘由: 对本科生或天资聪颖的人来说这问题真的没什麽,但对於初学者且还只有教科书程度的我,要我从一组复...

[Day-01] - Learn Spring Framework In One Month. ​

本文章「Learn Spring Framework In One Month」目的在提供读者可快速...

Day 04-Azure介绍

在上一篇我们看到,即便我们能不写程序就设定一些自动回覆,仍然相当麻烦,如果需要的功能更多,更无法应付...