跳到正文
DEV Community· StareBrain·· 3 小時前AI 評分41

StareBrain 第 16 部:打造不妨礙操作的浮層 UI

Building StareBrain, Part 16: The Overlay UI That Stays Out of the Way

AI 導讀

StareBrain 第 16 部實作 Android 浮層 UI,讓任務進度與確認提示顯示在其他應用程式上方,同時避免阻擋畫面或搶佔焦點。浮層設有 Idle、Running 和 AwaitingConfirmation 三種狀態,只有等待確認時會移除 FLAG_NOT_FOCUSABLE 以接收觸控。

正文

StareBrain

Building StareBrain, Part 16: The Overlay UI That Stays Out of the Way

Every part of this series so far has been invisible to the user — task execution, obstruction detection, replanning, context building. Today we surface it.

StareBrain runs as an overlay: a persistent UI layer that floats above whatever app the user is in. It has to show task progress without blocking the screen, surface confirmation prompts without breaking flow, and collapse to almost nothing when it's not needed.

Here's how we built it.


The Window

Android overlays use TYPE_APPLICATION_OVERLAY (requires SYSTEM_ALERT_WINDOW permission). The window is added via WindowManager, not an Activity — it lives outside the normal view hierarchy.

class OverlayManager(private val context: Context) {

    private val windowManager = context.getSystemService(WindowManager::class.java)
    private var overlayView: OverlayView? = null

    fun show() {
        if (overlayView != null) return
        val params = WindowManager.LayoutParams(
            WindowManager.LayoutParams.WRAP_CONTENT,
            WindowManager.LayoutParams.WRAP_CONTENT,
            WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY,
            WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE or
                WindowManager.LayoutParams.FLAG_LAYOUT_NO_LIMITS,
            PixelFormat.TRANSLUCENT
        ).apply {
            gravity = Gravity.TOP or Gravity.END
            x = 16
            y = 120
        }
        overlayView = OverlayView(context).also {
            windowManager.addView(it, params)
        }
    }

    fun dismiss() {
        overlayView?.let { windowManager.removeView(it) }
        overlayView = null
    }
}

FLAG_NOT_FOCUSABLE is important — it means touch events pass through to the app beneath. The overlay reacts to taps on itself, but doesn't steal focus from the app the user is actually using.


Three States

The overlay has three visual states:

sealed class OverlayState {
    object Idle : OverlayState()
    data class Running(
        val taskDescription: String,
        val currentStep: Int,
        val totalSteps: Int,
        val stepDescription: String
    ) : OverlayState()
    data class AwaitingConfirmation(
        val message: String,
        val onApprove: () -> Unit,
        val onReject: () -> Unit
    ) : OverlayState()
}

Idle renders as a small dot — barely visible, just enough to show the system is active.

Running expands to a compact progress strip: task name, step count, current step description.

AwaitingConfirmation expands further and becomes focusable — the user has to actually respond. This is the only state where FLAG_NOT_FOCUSABLE is removed.


Making Confirmation Focusable

When ConfirmationGuard fires, the overlay needs to accept touch input. We update the window params:

fun enterConfirmationMode(
    message: String,
    onApprove: () -> Unit,
    onReject: () -> Unit
) {
    val view = overlayView ?: return
    val params = view.layoutParams as WindowManager.LayoutParams
    params.flags = params.flags and WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE.inv()
    windowManager.updateViewLayout(view, params)
    view.setState(OverlayState.AwaitingConfirmation(message, onApprove, onReject))
}

fun exitConfirmationMode() {
    val view = overlayView ?: return
    val params = view.layoutParams as WindowManager.LayoutParams
    params.flags = params.flags or WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE
    windowManager.updateViewLayout(view, params)
}

After the user approves or rejects, we restore FLAG_NOT_FOCUSABLE and hand control back to TaskExecutor.resume().


Connecting to TaskExecutor

OverlayManager observes task state via a StateFlow:

class StareBrainService : AccessibilityService() {

    private val overlayManager by lazy { OverlayManager(this) }
    private val taskExecutor by lazy { TaskExecutor() }

    override fun onServiceConnected() {
        overlayManager.show()
        observeTaskState()
    }

    private fun observeTaskState() {
        lifecycleScope.launch {
            taskExecutor.taskState.collect { task ->
                when (task?.status) {
                    null, TaskStatus.PENDING -> overlayManager.setState(OverlayState.Idle)
                    TaskStatus.IN_PROGRESS -> overlayManager.setState(
                        OverlayState.Running(
                            taskDescription = task.description,
                            currentStep = task.currentStepIndex + 1,
                            totalSteps = task.steps.size,
                            stepDescription = task.steps[task.currentStepIndex].instruction
                        )
                    )
                    TaskStatus.AWAITING_CONFIRMATION -> {
                        val step = task.steps[task.currentStepIndex]
                        overlayManager.enterConfirmationMode(
                            message = "Confirm: ${step.instruction}?",
                            onApprove = { lifecycleScope.launch { taskExecutor.resume(task, true) } },
                            onReject = { lifecycleScope.launch { taskExecutor.resume(task, false) } }
                        )
                    }
                    TaskStatus.COMPLETED, TaskStatus.FAILED -> {
                        overlayManager.exitConfirmationMode()
                        overlayManager.setState(OverlayState.Idle)
                    }
                }
            }
        }
    }
}

What's Next

Part 17 will cover drag-to-reposition — letting users move the overlay so it doesn't cover UI they need — and persisting overlay position across sessions via SessionMemory.

來源:DEV Community · dev.to