新しめのFireタブレットの一部で、Composeアプリのタップ位置がずれる

2024/02/02に公開

何を話すのか?

  • タイトルの問題についての説明
  • なぜ起きるのか
  • どのような回避策があるか

タイトルの問題についての説明

とりあえずこちらをご覧下さい。

少しわかりづらいかもしれませんが、
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 です。

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可能と誤判定される
        }

        // 省略
    }

MotionEventAdapter
    /**
     * 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