/*
* Copyright 2020 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.SystemClock
import android.view.MotionEvent
import android.view.MotionEvent.ACTION_CANCEL
import android.view.MotionEvent.ACTION_DOWN
import android.view.MotionEvent.ACTION_MOVE
import android.view.MotionEvent.ACTION_OUTSIDE
import android.view.MotionEvent.ACTION_POINTER_DOWN
import android.view.MotionEvent.ACTION_POINTER_UP
import android.view.MotionEvent.ACTION_UP
import android.view.View
import android.view.ViewGroup
import android.view.ViewParent
import androidx.compose.runtime.remember
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.composed
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.platform.debugInspectorInfo
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.util.fastAll
import androidx.compose.ui.util.fastAny
import androidx.compose.ui.util.fastForEach
import androidx.compose.ui.viewinterop.AndroidViewHolder
/**
* A special PointerInputModifier that provides access to the underlying [MotionEvent]s originally
* dispatched to Compose. Prefer [pointerInput] and use this only for interoperation with
* existing code that consumes [MotionEvent]s.
*
* While the main intent of this Modifier is to allow arbitrary code to access the original
* [MotionEvent] dispatched to Compose, for completeness, analogs are provided to allow arbitrary
* code to interact with the system as if it were an Android View.
*
* This includes 2 APIs,
*
* 1. [onTouchEvent] has a Boolean return type which is akin to the return type of
* [View.onTouchEvent]. If the provided [onTouchEvent] returns true, it will continue to receive
* the event stream (unless the event stream has been intercepted) and if it returns false, it will
* not.
*
* 2. [requestDisallowInterceptTouchEvent] is a lambda that you can optionally provide so that
* you can later call it (yes, in this case, you call the lambda that you provided) which is akin
* to calling [ViewParent.requestDisallowInterceptTouchEvent]. When this is called, any
* associated ancestors in the tree that abide by the contract will act accordingly and will not
* intercept the even stream.
*
* @see [View.onTouchEvent]
* @see [ViewParent.requestDisallowInterceptTouchEvent]
*/
@ExperimentalComposeUiApi
fun Modifier.pointerInteropFilter(
requestDisallowInterceptTouchEvent: (RequestDisallowInterceptTouchEvent)? = null,
onTouchEvent: (MotionEvent) -> Boolean
): Modifier = composed(
inspectorInfo = debugInspectorInfo {
name = "pointerInteropFilter"
properties["requestDisallowInterceptTouchEvent"] = requestDisallowInterceptTouchEvent
properties["onTouchEvent"] = onTouchEvent
}
) {
val filter = remember { PointerInteropFilter() }
filter.onTouchEvent = onTouchEvent
filter.requestDisallowInterceptTouchEvent = requestDisallowInterceptTouchEvent
filter
}
/**
* Function that can be passed to [pointerInteropFilter] and then later invoked which provides an
* analog to [ViewParent.requestDisallowInterceptTouchEvent].
*/
@ExperimentalComposeUiApi
class RequestDisallowInterceptTouchEvent : (Boolean) -> Unit {
internal var pointerInteropFilter: PointerInteropFilter? = null
override fun invoke(disallowIntercept: Boolean) {
pointerInteropFilter?.disallowIntercept = disallowIntercept
}
}
/**
* Similar to the 2 argument overload of [pointerInteropFilter], but connects
* directly to an [AndroidViewHolder] for more seamless interop with Android.
*/
@ExperimentalComposeUiApi
internal fun Modifier.pointerInteropFilter(view: AndroidViewHolder): Modifier {
val filter = PointerInteropFilter()
filter.onTouchEvent = { motionEvent ->
when (motionEvent.actionMasked) {
ACTION_DOWN,
ACTION_POINTER_DOWN,
ACTION_MOVE,
ACTION_UP,
ACTION_POINTER_UP,
ACTION_OUTSIDE,
ACTION_CANCEL -> view.dispatchTouchEvent(motionEvent)
// ACTION_HOVER_ENTER,
// ACTION_HOVER_MOVE,
// ACTION_HOVER_EXIT,
// ACTION_BUTTON_PRESS,
// ACTION_BUTTON_RELEASE,
else -> view.dispatchGenericMotionEvent(motionEvent)
}
}
val requestDisallowInterceptTouchEvent = RequestDisallowInterceptTouchEvent()
filter.requestDisallowInterceptTouchEvent = requestDisallowInterceptTouchEvent
view.onRequestDisallowInterceptTouchEvent = requestDisallowInterceptTouchEvent
return this.then(filter)
}
/**
* The stateful part of pointerInteropFilter that manages the interop with Android.
*
* The intent of this PointerInputModifier is to allow Android Views and PointerInputModifiers to
* interact seamlessly despite the differences in the 2 systems. Below is a detailed explanation
* for how the interop is accomplished.
*
* When the type of event is not a movement event, we dispatch to the Android View as soon as
* possible (during [PointerEventPass.Initial]) so that the Android View can react to down
* and up events before Compose PointerInputModifiers normally would.
*
* When the type of event is a movement event, we dispatch to the Android View during
* [PointerEventPass.Final] to allow Compose PointerInputModifiers to react to movement first,
* which mimics a parent [ViewGroup] intercepting the event stream.
*
* Whenever we are about to call [onTouchEvent], we check to see if anything in Compose
* consumed any aspect of the pointer input changes, and if they did, we intercept the stream and
* dispatch ACTION_CANCEL to the Android View if they have already returned true for a call to
* View#dispatchTouchEvent(...).
*
* If we do call [onTouchEvent], and it returns true, we consume all of the changes so that
* nothing in Compose also responds.
*
* If the [requestDisallowInterceptTouchEvent] is provided and called with true, we simply dispatch move
* events during [PointerEventPass.Initial] so that normal PointerInputModifiers don't get a
* chance to consume first. Note: This does mean that it is possible for a Compose
* PointerInputModifier to "intercept" even after requestDisallowInterceptTouchEvent has been
* called because consumption can occur during [PointerEventPass.Initial]. This may seem
* like a flaw, but in reality, any PointerInputModifier that consumes that aggressively would
* likely only do so after some consumption already occurred on a later pass, and this ability to
* do so is on par with a [ViewGroup]'s ability to override [ViewGroup.dispatchTouchEvent]
* instead of overriding the more usual [ViewGroup.onTouchEvent] and [ViewGroup
* .onInterceptTouchEvent].
*
* If [requestDisallowInterceptTouchEvent] is later called with false (the Android equivalent of
* calling [ViewParent.requestDisallowInterceptTouchEvent] is exceedingly rare), we revert back to
* the normal behavior.
*
* If all pointers go up on the pointer interop filter, parents will be set to be allowed to
* intercept when new pointers go down. [requestDisallowInterceptTouchEvent] must be called again to
* change that state.
*/
@ExperimentalComposeUiApi
internal class PointerInteropFilter : PointerInputModifier {
lateinit var onTouchEvent: (MotionEvent) -> Boolean
var requestDisallowInterceptTouchEvent: RequestDisallowInterceptTouchEvent? = null
set(value) {
field?.pointerInteropFilter = null
field = value
field?.pointerInteropFilter = this
}
internal var disallowIntercept = false
/**
* The 3 possible states
*/
private enum class DispatchToViewState {
/**
* We have yet to dispatch a new event stream to the child Android View.
*/
Unknown,
/**
* We have dispatched to the child Android View and it wants to continue to receive
* events for the current event stream.
*/
Dispatching,
/**
* We intercepted the event stream, or the Android View no longer wanted to receive
* events for the current event stream.
*/
NotDispatching
}
override val pointerInputFilter =
object : PointerInputFilter() {
private var state = DispatchToViewState.Unknown
override val shareWithSiblings
get() = true
override fun onPointerEvent(
pointerEvent: PointerEvent,
pass: PointerEventPass,
bounds: IntSize
) {
val changes = pointerEvent.changes
// If we were told to disallow intercept, or if the event was a down or up event,
// we dispatch to Android as early as possible. If the event is a move event and
// we can still intercept, we dispatch to Android after we have a chance to
// intercept due to movement.
val dispatchDuringInitialTunnel = disallowIntercept ||
changes.fastAny {
it.changedToDownIgnoreConsumed() || it.changedToUpIgnoreConsumed()
}
if (state !== DispatchToViewState.NotDispatching) {
if (pass == PointerEventPass.Initial && dispatchDuringInitialTunnel) {
dispatchToView(pointerEvent)
}
if (pass == PointerEventPass.Final && !dispatchDuringInitialTunnel) {
dispatchToView(pointerEvent)
}
}
if (pass == PointerEventPass.Final) {
// If all of the changes were up changes, then the "event stream" has ended
// and we reset.
if (changes.fastAll { it.changedToUpIgnoreConsumed() }) {
reset()
}
}
}
override fun onCancel() {
// If we are still dispatching to the Android View, we have to send them a
// cancel event, otherwise, we should not.
if (state === DispatchToViewState.Dispatching) {
emptyCancelMotionEventScope(
SystemClock.uptimeMillis()
) { motionEvent ->
onTouchEvent(motionEvent)
}
reset()
}
}
/**
* Resets all of our state to be ready for a "new event stream".
*/
private fun reset() {
state = DispatchToViewState.Unknown
disallowIntercept = false
}
/**
* Dispatches to the Android View.
*
* Also consumes aspects of [pointerEvent] and updates our [state] accordingly.
*
* Will dispatch ACTION_CANCEL if any aspect of [pointerEvent] has been consumed and
* update our [state] accordingly.
*
* @param pointerEvent The change to dispatch.
* @return The resulting changes (fully consumed or untouched).
*/
private fun dispatchToView(pointerEvent: PointerEvent) {
val changes = pointerEvent.changes
if (changes.fastAny { it.isConsumed }) {
// We should no longer dispatch to the Android View.
if (state === DispatchToViewState.Dispatching) {
// If we were dispatching, send ACTION_CANCEL.
pointerEvent.toCancelMotionEventScope(
this.layoutCoordinates?.localToRoot(Offset.Zero)
?: error("layoutCoordinates not set")
) { motionEvent ->
onTouchEvent(motionEvent)
}
}
state = DispatchToViewState.NotDispatching
} else {
// Dispatch and update our state with the result.
pointerEvent.toMotionEventScope(
this.layoutCoordinates?.localToRoot(Offset.Zero)
?: error("layoutCoordinates not set")
) { motionEvent ->
if (motionEvent.actionMasked == MotionEvent.ACTION_DOWN) {
// If the action is ACTION_DOWN, we care about the return value of
// onTouchEvent and use it to set our initial dispatching state.
state = if (onTouchEvent(motionEvent)) {
DispatchToViewState.Dispatching
} else {
DispatchToViewState.NotDispatching
}
} else {
// Otherwise, we don't care about the return value. This is intended
// to be in accordance with how the Android View system works.
onTouchEvent(motionEvent)
}
}
if (state === DispatchToViewState.Dispatching) {
// If the Android View claimed the event, consume all changes.
changes.fastForEach {
it.consume()
}
pointerEvent.internalPointerEvent?.suppressMovementConsumption =
!disallowIntercept
}
}
}
}
}
/**
* Calls [watcher] with each [MotionEvent] that the layout area or any child [pointerInput]
* receives. The [MotionEvent] may or may not have been transformed to the local coordinate system.
* The Compose View will be considered as handling the [MotionEvent] in the area that the
* [motionEventSpy] is active.
*
* This method can only be used to observe [MotionEvent]s and can not be used to capture an event
* stream. Use [pointerInteropFilter] to handle [MotionEvent]s and consume the events.
*
* [watcher] is called during the [PointerEventPass.Initial] pass.
*
* Developers should prefer to use [pointerInput] to handle pointer input processing within
* Compose. [motionEventSpy] is only useful as part of Android View interoperability.
*/
@ExperimentalComposeUiApi
fun Modifier.motionEventSpy(watcher: (motionEvent: MotionEvent) -> Unit): Modifier =
this.pointerInput(watcher) {
interceptOutOfBoundsChildEvents = true
awaitPointerEventScope {
while (true) {
val event = awaitPointerEvent(PointerEventPass.Initial)
event.motionEvent?.let(watcher)
}
}
}