新しめのFireタブレットの一部で、Composeアプリのタップ位置がずれる
何を話すのか?
- タイトルの問題についての説明
- なぜ起きるのか
- どのような回避策があるか
タイトルの問題についての説明
とりあえずこちらをご覧下さい。
少しわかりづらいかもしれませんが、
2回目以降のタップは、その直前のタップ位置がタップされた事になっています。
発生条件は下記の通りです。
- Fire Max 11 (第13世代) or Fire HD 10(第13世代)
- ただし、個体差があるようで発生する個体と発生しない個体があるっぽい
- UIをCompose 1.5以上で作成している
なぜ起きるのか
問題が発生する端末の場合、画面をタップした時に発行されるMotionEventがおかしいようです。
dispatchTouchEvent: MotionEvent { action=ACTION_DOWN, actionButton=0, id[0]=0, x[0]=36.981445, y[0]=648.45966, toolType[0]=TOOL_TYPE_FINGER, buttonState=0, classification=NONE, metaState=0, flags=0x0, edgeFlags=0x0, pointerCount=1, historySize=0, eventTime=21217614, downTime=21217614, deviceId=5, source=0x1002, displayId=0 }
dispatchTouchEvent: MotionEvent { action=ACTION_MOVE, actionButton=0, id[0]=0, x[0]=36.981445, y[0]=648.45966, toolType[0]=TOOL_TYPE_FINGER, buttonState=0, classification=NONE, metaState=0, flags=0x0, edgeFlags=0x0, pointerCount=1, historySize=1, eventTime=21217636, downTime=21217614, deviceId=5, source=0x1002, displayId=0 }
dispatchTouchEvent: MotionEvent { action=ACTION_MOVE, actionButton=0, id[0]=0, x[0]=36.981445, y[0]=648.45966, toolType[0]=TOOL_TYPE_FINGER, buttonState=0, classification=NONE, metaState=0, flags=0x0, edgeFlags=0x0, pointerCount=1, historySize=1, eventTime=21217652, downTime=21217614, deviceId=5, source=0x1002, displayId=0 }
dispatchTouchEvent: MotionEvent { action=ACTION_MOVE, actionButton=0, id[0]=0, x[0]=36.981445, y[0]=648.45966, toolType[0]=TOOL_TYPE_FINGER, buttonState=0, classification=NONE, metaState=0, flags=0x0, edgeFlags=0x0, pointerCount=1, historySize=0, eventTime=21217669, downTime=21217614, deviceId=5, source=0x1002, displayId=0 }
dispatchTouchEvent: MotionEvent { action=ACTION_MOVE, actionButton=0, id[0]=0, x[0]=36.981445, y[0]=648.45966, toolType[0]=TOOL_TYPE_FINGER, buttonState=0, classification=NONE, metaState=0, flags=0x0, edgeFlags=0x0, pointerCount=1, historySize=0, eventTime=21217702, downTime=21217614, deviceId=5, source=0x1002, displayId=0 }
dispatchTouchEvent: MotionEvent { action=ACTION_UP, actionButton=0, id[0]=0, x[0]=36.981445, y[0]=648.45966, toolType[0]=TOOL_TYPE_FINGER, buttonState=0, classification=NONE, metaState=0, flags=0x0, edgeFlags=0x0, pointerCount=1, historySize=0, eventTime=21217711, downTime=21217614, deviceId=5, source=0x1002, displayId=0 }
dispatchHoverEvent: MotionEvent { action=ACTION_HOVER_ENTER, actionButton=0, id[0]=0, x[0]=36.981445, y[0]=648.45966, toolType[0]=TOOL_TYPE_FINGER, buttonState=0, classification=NONE, metaState=0, flags=0x0, edgeFlags=0x0, pointerCount=1, historySize=0, eventTime=21217711, downTime=21217614, deviceId=5, source=0x1002, displayId=0 }
dispatchHoverEvent: MotionEvent { action=ACTION_HOVER_MOVE, actionButton=0, id[0]=0, x[0]=36.981445, y[0]=648.45966, toolType[0]=TOOL_TYPE_FINGER, buttonState=0, classification=NONE, metaState=0, flags=0x0, edgeFlags=0x0, pointerCount=1, historySize=0, eventTime=21217711, downTime=21217614, deviceId=5, source=0x1002, displayId=0 }
dispatchHoverEvent: MotionEvent { action=ACTION_HOVER_EXIT, actionButton=0, id[0]=0, x[0]=36.981445, y[0]=648.45966, toolType[0]=TOOL_TYPE_FINGER, buttonState=0, classification=NONE, metaState=0, flags=0x0, edgeFlags=0x0, pointerCount=1, historySize=0, eventTime=21217719, downTime=21217614, deviceId=5, source=0x1002, displayId=0 }
上記が問題が発生する場合のMotionEvent(ComposeViewのdispatchTouchEvent/dispatchHoverEventdで取得)のログですが、
問題が発生しない端末と比較すると
ACTION_UP後、即座にACTION_HOVER_ENTER、ACTION_HOVER_MOVE、ACTION_HOVER_EXITイベントが発生している。
という差分があります。
Compose内の処理を色々と調べたところ、上記の差分により下記のような現象が起きているらしい事がわかりました。
(実際の解析の順番としては、先にCompose内の処理にnot suspendなBreakPointでログを足して下記の現象を見つけ、そのトリガーを探した結果上記のMotionEvent差分を発見しました)
- タップ中、現在のタップがHover可能と判定される
- ACTION_UPでタップ操作が終わった事にしたいが、Hover可能なのでまだ操作が続いていると誤判定してしまう
- その後のタップ処理は以前のタップ操作の続きだと誤判定され、結果としてタップ位置がおかしくなる
具体的な判定箇所を抜粋します。抜粋元は MotionEventAdapter
です。
internal fun convertToPointerInputEvent(
motionEvent: MotionEvent,
positionCalculator: PositionCalculator
): PointerInputEvent? {
val action = motionEvent.actionMasked
if (action == ACTION_CANCEL) {
motionEventToComposePointerIdMap.clear()
canHover.clear()
return null
}
clearOnDeviceChange(motionEvent)
addFreshIds(motionEvent)
val isHover = action == ACTION_HOVER_EXIT || action == ACTION_HOVER_MOVE ||
action == ACTION_HOVER_ENTER
val isScroll = action == ACTION_SCROLL
if (isHover) { // ★ACTION_HOVER_*のMotionEventで、isHover == trueになる
val hoverId = motionEvent.getPointerId(motionEvent.actionIndex)
canHover.put(hoverId, true) // ★Hover可能と誤判定される
}
// 省略
}
/**
* Remove any raised pointers if they didn't previously hover. Anything that hovers
* will stay until a different event causes it to be removed.
*/
private fun removeStaleIds(motionEvent: MotionEvent) {
when (motionEvent.actionMasked) {
ACTION_POINTER_UP,
ACTION_UP -> {
val actionIndex = motionEvent.actionIndex
val pointerId = motionEvent.getPointerId(actionIndex)
if (!canHover.get(pointerId, false)) {
motionEventToComposePointerIdMap.delete(pointerId) // ★Hover可能と判定されているので、ここの削除処理が実行されない
canHover.delete(pointerId)
}
}
}
// 省略
}
どのような回避策があるか
各Activityで使っているComposeViewを差し替えて、その差し替えたComposeViewで不要なMotionEventをフィルタする事にしました。
まず、ComposeViewをコピペして独自のComposeViewを作ります。
//region workaround
... //endregion workaround
が追加したコードです。
class AmazonWorkaroundComposeView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0
) : AbstractComposeView(context, attrs, defStyleAttr) {
private val content = mutableStateOf<(@Composable () -> Unit)?>(null)
@Suppress("RedundantVisibilityModifier")
protected override var shouldCreateCompositionOnAttachedToWindow: Boolean = false
private set
@Composable
override fun Content() {
content.value?.invoke()
}
override fun getAccessibilityClassName(): CharSequence {
return javaClass.name
}
//region workaround
private val motionEventTouchDeviceMap =
mutableMapOf<Int, Boolean>() // deviceId to hasSourceTouchScreen
override fun dispatchHoverEvent(event: MotionEvent?): Boolean {
/*
* Ignore finger pointer hover events from touch screen devices.
*
* Q. Why doesn't ignore all hover events?
* A. If all hover events are ignored, the click position will be displaced when an external
* mouse is connected. When an external mouse is connected, mouse-related events flow to
* dispatchGenericPointerEvent as well, so if there is a discrepancy between these events
* and hover events, click processing will not be performed correctly.
*/
return if (event?.hasToolTypeFingerPointer() == true && event.fromTouchScreenDevice()) {
false
} else {
super.dispatchHoverEvent(event)
}
}
override fun dispatchGenericPointerEvent(event: MotionEvent?): Boolean {
return super.dispatchGenericPointerEvent(event)
}
private fun MotionEvent.hasToolTypeFingerPointer(): Boolean {
pointerCount.takeIf { it > 0 }?.let { count ->
for (i in 0 until count) {
if (getToolType(i) == MotionEvent.TOOL_TYPE_FINGER) {
return true
}
}
}
return false
}
private fun MotionEvent.fromTouchScreenDevice(): Boolean {
val deviceId = deviceId
val hasSourceTouchScreen = motionEventTouchDeviceMap[deviceId] ?: run {
val device = InputDevice.getDevice(deviceId)
val hasSourceTouchScreen =
device?.sources?.and(InputDevice.SOURCE_TOUCHSCREEN) == InputDevice.SOURCE_TOUCHSCREEN
motionEventTouchDeviceMap[deviceId] = hasSourceTouchScreen
hasSourceTouchScreen
}
return hasSourceTouchScreen
}
//endregion workaround
/**
* Set the Jetpack Compose UI content for this view.
* Initial composition will occur when the view becomes attached to a window or when
* [createComposition] is called, whichever comes first.
*/
fun setContent(content: @Composable () -> Unit) {
shouldCreateCompositionOnAttachedToWindow = true
this.content.value = content
if (isAttachedToWindow) {
createComposition()
}
}
}
ComponentActivity.setContentもコピペして、Amazonデバイスなら独自ComposeViewを使うようにします。
fun ComponentActivity.setContentWithAmazonWorkaroundIfNeeded(
parent: CompositionContext? = null,
content: @Composable () -> Unit
) {
if (isAmazonDevice()) {
val existingComposeView = window.decorView
.findViewById<ViewGroup>(android.R.id.content)
.getChildAt(0) as? AmazonWorkaroundComposeView
if (existingComposeView != null) with(existingComposeView) {
setParentCompositionContext(parent)
setContent(content)
} else AmazonWorkaroundComposeView(this).apply {
// Set content and parent **before** setContentView
// to have ComposeView create the composition on attach
setParentCompositionContext(parent)
setContent(content)
// Set the view tree owners before setting the content view so that the inflation process
// and attach listeners will see them already present
setOwners()
setContentView(this, DefaultActivityContentLayoutParams)
}
} else {
setContent(parent, content)
}
}
private val DefaultActivityContentLayoutParams = ViewGroup.LayoutParams(
ViewGroup.LayoutParams.WRAP_CONTENT,
ViewGroup.LayoutParams.WRAP_CONTENT
)
/**
* These owners are not set before AppCompat 1.3+ due to a bug, so we need to set them manually in
* case developers are using an older version of AppCompat.
*/
private fun ComponentActivity.setOwners() {
val decorView = window.decorView
if (decorView.findViewTreeLifecycleOwner() == null) {
decorView.setViewTreeLifecycleOwner(this)
}
if (decorView.findViewTreeViewModelStoreOwner() == null) {
decorView.setViewTreeViewModelStoreOwner(this)
}
if (decorView.findViewTreeSavedStateRegistryOwner() == null) {
decorView.setViewTreeSavedStateRegistryOwner(this)
}
}
@Stable
private fun isAmazonDevice(): Boolean {
return Build.MANUFACTURER.equals("Amazon", ignoreCase = true)
}
あとは、各Activityで使用しているsetContentを上記のsetContentWithAmazonWorkaroundIfNeededに置き換えるだけです。お疲れ様でした。
Discussion