悬浮视窗的原理其实很简单,建立一个背景运作的Service,并且透过WindowManager将自定义的Layout显示出来。
How to Make a Floating Window Application in Android?
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
分别是悬浮视窗以及Foreground Service需要的权限
为了确保我们的悬浮视窗能在其他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()
}
Day21加入的popup按钮的点击事件
binding.popup.setOnClickListener {
//TODO save search items.
val serviceIntent = Intent(requireActivity(), ObserveService::class.java)
it.context.startForegroundService(serviceIntent)
requireActivity().finish()
}
基本上就是开启Service并结束MainActivity,TODO
的部分是日後要处理的事情,不会在今天的内容中。
使用startForegroundService启动的话要在Service中建立Notification并使用对应的startForeground方法,这部分已经更新很久就不另外放程序码了。
<?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>
预览画面
跟Day02的草图有些差异,目前这边基本只保留调整外框大小跟位置的按钮了。
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後画面就会显示出来了。
这部分的内容与Day17、Day19、Day20基本一致,差别只在於目前已经在文章内了,不需要多做进入文章并置底的前置动作。
今天除了建立悬浮视窗,也一并把移动和关闭的功能加入了,退回和修改大小因为我目前想起来比较难一些,预计会放在接下来几天的内容。
移动按钮的点击事件很单纯:
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,为了避免事件被拦截需要做另外的处理。
自定义一个新的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也不会将事件继续分发。
悬浮视窗的移动事件:
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的HistoricalX及HistoricalY来判断移动的距离,并更新目前的LayoutParams即可。
目前在模拟器里面测试起来感觉有点误差,这部分後日再看看怎麽处理。
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
}
// ...
}
NVM与NPM虽然差一个字,但两者兼具着不同的工作。 NVM全名Node Version Manag...
前测是敲门砖,决定他们要不要和你面试, 我听说过有人前测找代打, 也碰过某个公司的前测题目同期完全一...
缘由: 对本科生或天资聪颖的人来说这问题真的没什麽,但对於初学者且还只有教科书程度的我,要我从一组复...
本文章「Learn Spring Framework In One Month」目的在提供读者可快速...
在上一篇我们看到,即便我们能不写程序就设定一些自动回覆,仍然相当麻烦,如果需要的功能更多,更无法应付...