/*
* Copyright 2019 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package androidx.compose.ui.input.pointer
import android.os.Build
import android.util.SparseBooleanArray
import android.util.SparseLongArray
import android.view.MotionEvent
import android.view.MotionEvent.ACTION_CANCEL
import android.view.MotionEvent.ACTION_DOWN
import android.view.MotionEvent.ACTION_HOVER_ENTER
import android.view.MotionEvent.ACTION_HOVER_EXIT
import android.view.MotionEvent.ACTION_HOVER_MOVE
import android.view.MotionEvent.ACTION_POINTER_DOWN
import android.view.MotionEvent.ACTION_POINTER_UP
import android.view.MotionEvent.ACTION_SCROLL
import android.view.MotionEvent.ACTION_UP
import android.view.MotionEvent.TOOL_TYPE_ERASER
import android.view.MotionEvent.TOOL_TYPE_FINGER
import android.view.MotionEvent.TOOL_TYPE_MOUSE
import android.view.MotionEvent.TOOL_TYPE_STYLUS
import android.view.MotionEvent.TOOL_TYPE_UNKNOWN
import androidx.annotation.DoNotInline
import androidx.annotation.RequiresApi
import androidx.annotation.VisibleForTesting
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.geometry.Offset
/**
* Converts Android framework [MotionEvent]s into Compose [PointerInputEvent]s.
*/
internal class MotionEventAdapter {
private var nextId = 0L
/**
* Whenever a new MotionEvent pointer is added, we create a new PointerId that is associated
* with it. This holds that association.
*/
@VisibleForTesting
internal val motionEventToComposePointerIdMap = SparseLongArray()
private val canHover = SparseBooleanArray()
private val pointers = mutableListOf<PointerInputEventData>()
/**
* The previous event's tool type. This is used in combination with [previousSource] to
* determine when a different device was used to send events.
*/
private var previousToolType = -1
/**
* The previous event's source. This is used in combination with [previousToolType] to
* determine when a different device was used to send events.
*/
private var previousSource = -1
/**
* Converts a single [MotionEvent] from an Android event stream into a [PointerInputEvent], or
* null if the [MotionEvent.getActionMasked] is [ACTION_CANCEL].
*
* All MotionEvents should be passed to this method so that it can correctly maintain it's
* internal state.
*
* @param motionEvent The MotionEvent to process.
*
* @return The PointerInputEvent or null if the event action was ACTION_CANCEL.
*/
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) {
val hoverId = motionEvent.getPointerId(motionEvent.actionIndex)
canHover.put(hoverId, true)
}
val upIndex = when (action) {
ACTION_UP -> 0
ACTION_POINTER_UP -> motionEvent.actionIndex
else -> -1
}
pointers.clear()
// This converts the MotionEvent into a list of PointerInputEventData, and updates
// internal record keeping.
for (i in 0 until motionEvent.pointerCount) {
pointers.add(
createPointerInputEventData(
positionCalculator,
motionEvent,
i,
// "pressed" means:
// 1. we're not hovered
// 2. we didn't get UP event for a pointer
// 3. button on the mouse is pressed BUT it's not a "scroll" simulated button
!isHover && i != upIndex && (!isScroll || motionEvent.buttonState != 0)
)
)
}
removeStaleIds(motionEvent)
return PointerInputEvent(
motionEvent.eventTime,
pointers,
motionEvent
)
}
/**
* An ACTION_DOWN or ACTION_POINTER_DOWN was received, but not handled, so the stream should
* be considered ended.
*/
fun endStream(pointerId: Int) {
canHover.delete(pointerId)
motionEventToComposePointerIdMap.delete(pointerId)
}
/**
* Add any new pointer IDs.
*/
private fun addFreshIds(motionEvent: MotionEvent) {
when (motionEvent.actionMasked) {
ACTION_HOVER_ENTER -> {
val pointerId = motionEvent.getPointerId(0)
if (motionEventToComposePointerIdMap.indexOfKey(pointerId) < 0) {
motionEventToComposePointerIdMap.put(pointerId, nextId++)
}
}
ACTION_DOWN,
ACTION_POINTER_DOWN -> {
val actionIndex = motionEvent.actionIndex
val pointerId = motionEvent.getPointerId(actionIndex)
if (motionEventToComposePointerIdMap.indexOfKey(pointerId) < 0) {
motionEventToComposePointerIdMap.put(pointerId, nextId++)
if (motionEvent.getToolType(actionIndex) == TOOL_TYPE_MOUSE) {
canHover.put(pointerId, true)
}
}
}
}
}
/**
* 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)
canHover.delete(pointerId)
}
}
}
// Remove any IDs that don't currently exist in the MotionEvent.
// This can happen, for example, when a mouse cursor disappears and the next
// event is a touch event.
if (motionEventToComposePointerIdMap.size() > motionEvent.pointerCount) {
for (i in motionEventToComposePointerIdMap.size() - 1 downTo 0) {
val pointerId = motionEventToComposePointerIdMap.keyAt(i)
if (!motionEvent.hasPointerId(pointerId)) {
motionEventToComposePointerIdMap.removeAt(i)
canHover.delete(pointerId)
}
}
}
}
private fun MotionEvent.hasPointerId(pointerId: Int): Boolean {
for (i in 0 until pointerCount) {
if (getPointerId(i) == pointerId) {
return true
}
}
return false
}
private fun getComposePointerId(motionEventPointerId: Int): PointerId {
val pointerIndex = motionEventToComposePointerIdMap.indexOfKey(motionEventPointerId)
val id = if (pointerIndex >= 0) {
motionEventToComposePointerIdMap.valueAt(pointerIndex)
} else {
// An unexpected pointer was added or we may have previously removed it
val newId = nextId++
motionEventToComposePointerIdMap.put(motionEventPointerId, newId)
newId
}
return PointerId(id)
}
/**
* When the device has changed (noted by source and tool type), we don't need to track
* any of the previous pointers.
*/
private fun clearOnDeviceChange(motionEvent: MotionEvent) {
if (motionEvent.pointerCount != 1) {
return
}
val toolType = motionEvent.getToolType(0)
val source = motionEvent.source
if (toolType != previousToolType || source != previousSource) {
previousToolType = toolType
previousSource = source
canHover.clear()
motionEventToComposePointerIdMap.clear()
}
}
/**
* Creates a new PointerInputEventData.
*/
@OptIn(ExperimentalComposeUiApi::class)
private fun createPointerInputEventData(
positionCalculator: PositionCalculator,
motionEvent: MotionEvent,
index: Int,
pressed: Boolean
): PointerInputEventData {
val motionEventPointerId = motionEvent.getPointerId(index)
val pointerId = getComposePointerId(motionEventPointerId)
var position = Offset(motionEvent.getX(index), motionEvent.getY(index))
val rawPosition: Offset
if (index == 0) {
rawPosition = Offset(motionEvent.rawX, motionEvent.rawY)
position = positionCalculator.screenToLocal(rawPosition)
} else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
rawPosition = MotionEventHelper.toRawOffset(motionEvent, index)
position = positionCalculator.screenToLocal(rawPosition)
} else {
rawPosition = positionCalculator.localToScreen(position)
}
val toolType = when (motionEvent.getToolType(index)) {
TOOL_TYPE_UNKNOWN -> PointerType.Unknown
TOOL_TYPE_FINGER -> PointerType.Touch
TOOL_TYPE_STYLUS -> PointerType.Stylus
TOOL_TYPE_MOUSE -> PointerType.Mouse
TOOL_TYPE_ERASER -> PointerType.Eraser
else -> PointerType.Unknown
}
val historical = mutableListOf<HistoricalChange>()
with(motionEvent) {
repeat(historySize) { pos ->
val x = getHistoricalX(index, pos)
val y = getHistoricalY(index, pos)
if (x.isFinite() && y.isFinite()) {
val historicalChange = HistoricalChange(
getHistoricalEventTime(pos),
Offset(x, y)
)
historical.add(historicalChange)
}
}
}
val scrollDelta = if (motionEvent.actionMasked == ACTION_SCROLL) {
val x = motionEvent.getAxisValue(MotionEvent.AXIS_HSCROLL)
val y = motionEvent.getAxisValue(MotionEvent.AXIS_VSCROLL)
// NOTE: we invert the y scroll offset because android is special compared to other
// platforms and uses the opposite sign for vertical mouse wheel scrolls. In order to
// support better x-platform mouse scroll, we invert the y-offset to be in line with
// desktop and web.
//
// This looks more natural, because when we scroll mouse wheel up,
// we move the wheel point (that touches the finger) up. And if we work in the usual
// coordinate system, it means we move that point by "-1".
//
// Web also behaves this way. See deltaY:
// https://developer.mozilla.org/en-US/docs/Web/API/Element/wheel_event
// https://jsfiddle.net/27zwteog
// (wheelDelta on the other hand is deprecated and inverted)
//
// We then add 0f to prevent injecting -0.0f into the pipeline, which can be
// problematic when doing comparisons.
Offset(x, -y + 0f)
} else {
Offset.Zero
}
val issuesEnterExit = canHover.get(motionEvent.getPointerId(index), false)
return PointerInputEventData(
pointerId,
motionEvent.eventTime,
rawPosition,
position,
pressed,
toolType,
issuesEnterExit,
historical,
scrollDelta
)
}
}
/**
* This class is here to ensure that the classes that use this API will get verified and can be
* AOT compiled. It is expected that this class will soft-fail verification, but the classes
* which use this method will pass.
*/
@RequiresApi(Build.VERSION_CODES.Q)
private object MotionEventHelper {
@DoNotInline
fun toRawOffset(motionEvent: MotionEvent, index: Int): Offset {
return Offset(motionEvent.getRawX(index), motionEvent.getRawY(index))
}
}