fix: prevent overlay pill bar from trembling by using fixed-height window

The WRAP_CONTENT window height caused WindowManager to relayout every
frame during animateContentSize() animations. Use a fixed 350dp window
with a touch-passthrough FrameLayout wrapper that forwards only touches
within the actual pill bar bounds.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Kohei 2026-02-21 18:59:21 +09:00
parent 4282ac4cff
commit 18309c3f9d

View file

@ -5,9 +5,18 @@ import android.content.Intent
import android.graphics.PixelFormat import android.graphics.PixelFormat
import android.os.IBinder import android.os.IBinder
import android.view.Gravity import android.view.Gravity
import android.view.MotionEvent
import android.view.View
import android.view.WindowManager import android.view.WindowManager
import android.widget.FrameLayout
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.layout.positionInWindow
import androidx.compose.ui.platform.ComposeView import androidx.compose.ui.platform.ComposeView
import androidx.core.app.NotificationCompat import androidx.core.app.NotificationCompat
import androidx.lifecycle.LifecycleService import androidx.lifecycle.LifecycleService
@ -46,7 +55,7 @@ class AssistantService : LifecycleService(), SavedStateRegistryOwner {
private lateinit var sttWrapper: SpeechRecognizerWrapper private lateinit var sttWrapper: SpeechRecognizerWrapper
private lateinit var cameraCaptureManager: CameraCaptureManager private lateinit var cameraCaptureManager: CameraCaptureManager
private var overlayView: ComposeView? = null private var overlayView: View? = null
private val windowManager by lazy { getSystemService(WINDOW_SERVICE) as WindowManager } private val windowManager by lazy { getSystemService(WINDOW_SERVICE) as WindowManager }
private val savedStateRegistryController = SavedStateRegistryController.create(this) private val savedStateRegistryController = SavedStateRegistryController.create(this)
@ -112,9 +121,12 @@ class AssistantService : LifecycleService(), SavedStateRegistryOwner {
private fun addOverlay() { private fun addOverlay() {
if (overlayView != null) return if (overlayView != null) return
val density = resources.displayMetrics.density
val fixedHeightPx = (350 * density).toInt()
val params = WindowManager.LayoutParams( val params = WindowManager.LayoutParams(
WindowManager.LayoutParams.MATCH_PARENT, WindowManager.LayoutParams.MATCH_PARENT,
WindowManager.LayoutParams.WRAP_CONTENT, fixedHeightPx,
WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY, WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY,
WindowManager.LayoutParams.FLAG_NOT_TOUCH_MODAL or WindowManager.LayoutParams.FLAG_NOT_TOUCH_MODAL or
WindowManager.LayoutParams.FLAG_LAYOUT_IN_SCREEN, WindowManager.LayoutParams.FLAG_LAYOUT_IN_SCREEN,
@ -123,26 +135,50 @@ class AssistantService : LifecycleService(), SavedStateRegistryOwner {
gravity = Gravity.BOTTOM gravity = Gravity.BOTTOM
} }
val view = ComposeView(this).apply { val wrapper = object : FrameLayout(this@AssistantService) {
setViewTreeLifecycleOwner(this@AssistantService) @Volatile var contentTop = fixedHeightPx
setViewTreeSavedStateRegistryOwner(this@AssistantService) private var gestureInContent = false
override fun dispatchTouchEvent(ev: MotionEvent): Boolean {
if (ev.actionMasked == MotionEvent.ACTION_DOWN) {
gestureInContent = ev.y >= contentTop
}
if (!gestureInContent) return false
return super.dispatchTouchEvent(ev)
}
}
wrapper.setViewTreeLifecycleOwner(this)
wrapper.setViewTreeSavedStateRegistryOwner(this)
ComposeView(this).apply {
setContent { setContent {
PicoClawTheme { PicoClawTheme {
val state by assistantManager.state.collectAsState() val state by assistantManager.state.collectAsState()
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.BottomCenter
) {
AssistantPillBar( AssistantPillBar(
state = state, state = state,
onClose = { shutdown() }, onClose = { shutdown() },
onInterrupt = { assistantManager.interrupt() }, onInterrupt = { assistantManager.interrupt() },
onCameraToggle = { assistantManager.toggleCamera() }, onCameraToggle = { assistantManager.toggleCamera() },
cameraCaptureManager = cameraCaptureManager cameraCaptureManager = cameraCaptureManager,
modifier = Modifier.onGloballyPositioned { coordinates ->
wrapper.contentTop = coordinates.positionInWindow().y.toInt()
}
) )
} }
} }
} }
wrapper.addView(this, FrameLayout.LayoutParams(
FrameLayout.LayoutParams.MATCH_PARENT,
FrameLayout.LayoutParams.MATCH_PARENT
))
}
windowManager.addView(view, params) windowManager.addView(wrapper, params)
overlayView = view overlayView = wrapper
} }
private fun removeOverlay() { private fun removeOverlay() {