package com.swmansion.gesturehandler.core import android.content.Context import android.os.SystemClock import android.view.MotionEvent import android.view.View import android.view.ViewConfiguration import android.view.ViewGroup import android.widget.ScrollView import com.facebook.react.bridge.ReadableMap import com.facebook.react.views.scroll.ReactHorizontalScrollView import com.facebook.react.views.scroll.ReactScrollView import com.facebook.react.views.swiperefresh.ReactSwipeRefreshLayout import com.facebook.react.views.text.ReactTextView import com.facebook.react.views.textinput.ReactEditText import com.facebook.react.views.view.ReactViewGroup import com.swmansion.gesturehandler.react.RNGestureHandlerRootHelper import com.swmansion.gesturehandler.react.events.eventbuilders.NativeGestureHandlerEventDataBuilder class NativeViewGestureHandler : GestureHandler() { override val isContinuous = true private var shouldActivateOnStart = false /** * Set this to `true` when wrapping native components that are supposed to be an exclusive * target for a touch stream. Like for example switch or slider component which when activated * aren't supposed to be cancelled by scrollview or other container that may also handle touches. */ var disallowInterruption = false private set /** * Composes with [disallowInterruption]. When both are `true`, the handler still resists * discrete gesture peers but yields to continuous peers. No-op when [disallowInterruption] is `false`. */ var yieldsToContinuousGestures = false private set private var hook: NativeViewGestureHandlerHook = defaultHook private data class ActiveUpdateSnapshot(val pointerInside: Boolean, val numberOfPointers: Int, val pointerType: Int) private var lastActiveUpdate: ActiveUpdateSnapshot? = null init { shouldCancelWhenOutside = true } override fun resetConfig() { super.resetConfig() shouldActivateOnStart = DEFAULT_SHOULD_ACTIVATE_ON_START disallowInterruption = DEFAULT_DISALLOW_INTERRUPTION yieldsToContinuousGestures = DEFAULT_YIELDS_TO_CONTINUOUS_GESTURES shouldCancelWhenOutside = DEFAULT_SHOULD_CANCEL_WHEN_OUTSIDE } override fun shouldRecognizeSimultaneously(handler: GestureHandler): Boolean { // if the gesture is marked by user as simultaneous with other or the hook return true hook.shouldRecognizeSimultaneously(handler)?.let { return@shouldRecognizeSimultaneously it } if (super.shouldRecognizeSimultaneously(handler)) { return true } if (handler is NativeViewGestureHandler) { // Special case when the peer handler is also an instance of NativeViewGestureHandler: // For the `disallowInterruption` to work correctly we need to check the property when // accessed as a peer, because simultaneous recognizers can be set on either side of the // connection. if (handler.state == STATE_ACTIVE && handler.disallowInterruption && !handler.yieldsToContinuousGestures ) { // other handler is active and it disallows interruption, we don't want to get into its way return false } } val canBeInterrupted = canBeInterruptedBy(handler) val otherState = handler.state return if (state == STATE_ACTIVE && otherState == STATE_ACTIVE && canBeInterrupted) { // if both handlers are active and the current handler can be interrupted it we return `false` // as it means the other handler has turned active and returning `true` would prevent it from // interrupting the current handler false } else { state == STATE_ACTIVE && canBeInterrupted && (!hook.shouldCancelRootViewGestureHandlerIfNecessary() || handler.tag > 0) } // otherwise we can only return `true` if already in an active state } override fun shouldBeCancelledBy(handler: GestureHandler): Boolean = canBeInterruptedBy(handler) /** * Whether this handler permits [other] to take over the touch stream, given its * `disallowInterruption` and `yieldsToContinuousGestures` configuration. */ fun canBeInterruptedBy(other: GestureHandler): Boolean = !disallowInterruption || (yieldsToContinuousGestures && other.isContinuous) override fun shouldBeginWithRecordedHandlers(recorded: List): Boolean = hook.shouldBeginWithRecordedHandlers(recorded, this) override fun onPrepare() { when (val view = view) { is NativeViewGestureHandlerHook -> this.hook = view is ReactEditText -> this.hook = EditTextHook(this, view) is ReactSwipeRefreshLayout -> this.hook = SwipeRefreshLayoutHook(this, view) is ReactScrollView -> this.hook = ScrollViewHook() is ReactHorizontalScrollView -> this.hook = ScrollViewHook() is ReactTextView -> this.hook = TextViewHook() is ReactViewGroup -> this.hook = ReactViewGroupHook() } } override fun onHandle(event: MotionEvent, sourceEvent: MotionEvent) { val view = view!! if (event.actionMasked == MotionEvent.ACTION_UP) { if (state == STATE_UNDETERMINED && !hook.canBegin(event)) { cancel() } else { hook.sendTouchEvent(view, event) if ((state == STATE_UNDETERMINED || state == STATE_BEGAN) && hook.canActivate(view)) { activate() } if (state == STATE_UNDETERMINED) { cancel() } else { end() } } hook.afterGestureEnd(event) } else if (state == STATE_UNDETERMINED || state == STATE_BEGAN) { if (state != STATE_BEGAN && hook.canBegin(event)) { begin() } when { shouldActivateOnStart -> { tryIntercept(view, event) hook.sendTouchEvent(view, event) activate() } tryIntercept(view, event) -> { hook.sendTouchEvent(view, event) activate() } hook.wantsToHandleEventBeforeActivation() -> { hook.handleEventBeforeActivation(event) } } } else if (state == STATE_ACTIVE) { hook.sendTouchEvent(view, event) } } private fun dispatchCancelEventToView() { val time = SystemClock.uptimeMillis() val event = MotionEvent.obtain(time, time, MotionEvent.ACTION_CANCEL, 0f, 0f, 0).apply { action = MotionEvent.ACTION_CANCEL } hook.sendTouchEvent(view, event) event.recycle() } override fun onCancel() = dispatchCancelEventToView() override fun onFail() = dispatchCancelEventToView() override fun onReset() { this.hook = defaultHook lastActiveUpdate = null } override fun dispatchHandlerUpdate(event: MotionEvent) { val snapshot = ActiveUpdateSnapshot(isWithinBounds, numberOfPointers, pointerType) if (snapshot == lastActiveUpdate) { return } lastActiveUpdate = snapshot super.dispatchHandlerUpdate(event) } override fun dispatchStateChange(newState: Int, prevState: Int) { lastActiveUpdate = null super.dispatchStateChange(newState, prevState) } override fun wantsToAttachDirectlyToView() = true class Factory : GestureHandler.Factory() { override val type = NativeViewGestureHandler::class.java override val name = "NativeViewGestureHandler" override fun create(context: Context?): NativeViewGestureHandler = NativeViewGestureHandler() override fun updateConfig(handler: NativeViewGestureHandler, config: ReadableMap) { super.updateConfig(handler, config) if (config.hasKey(KEY_SHOULD_ACTIVATE_ON_START)) { handler.shouldActivateOnStart = config.getBoolean(KEY_SHOULD_ACTIVATE_ON_START) } if (config.hasKey(KEY_DISALLOW_INTERRUPTION)) { handler.disallowInterruption = config.getBoolean(KEY_DISALLOW_INTERRUPTION) } if (config.hasKey(KEY_YIELDS_TO_CONTINUOUS_GESTURES)) { handler.yieldsToContinuousGestures = config.getBoolean(KEY_YIELDS_TO_CONTINUOUS_GESTURES) } } override fun createEventBuilder(handler: NativeViewGestureHandler) = NativeGestureHandlerEventDataBuilder(handler) companion object { private const val KEY_SHOULD_ACTIVATE_ON_START = "shouldActivateOnStart" private const val KEY_DISALLOW_INTERRUPTION = "disallowInterruption" private const val KEY_YIELDS_TO_CONTINUOUS_GESTURES = "yieldsToContinuousGestures" } } companion object { private const val DEFAULT_SHOULD_CANCEL_WHEN_OUTSIDE = true private const val DEFAULT_SHOULD_ACTIVATE_ON_START = false private const val DEFAULT_DISALLOW_INTERRUPTION = false private const val DEFAULT_YIELDS_TO_CONTINUOUS_GESTURES = false private fun tryIntercept(view: View, event: MotionEvent) = view is ViewGroup && view.onInterceptTouchEvent(event) private val defaultHook = object : NativeViewGestureHandlerHook {} enum class ScrollDirection(val value: Int) { UP(-1), DOWN(1), NONE(0), } } interface NativeViewGestureHandlerHook { /** * Called when gesture is in the UNDETERMINED state, shouldActivateOnStart is set to false, * and both tryIntercept and wantsToHandleEventBeforeActivation returned false. * * @return Boolean value signalling whether the handler can transition to the BEGAN state. If false * the gesture will be cancelled. */ fun canBegin(event: MotionEvent) = true /** * Checks whether handler can activate. Used by TextViewHook. */ fun canActivate(view: View) = view.isPressed /** * Called after the gesture transitions to the END state. */ fun afterGestureEnd(event: MotionEvent) = Unit /** * @return Boolean value signalling whether the gesture can be recognized simultaneously with * other (handler). Returning false doesn't necessarily prevent it from happening. */ fun shouldRecognizeSimultaneously(handler: GestureHandler): Boolean? = null /** * shouldActivateOnStart and tryIntercept have priority over this method * * @return Boolean value signalling if the hook wants to handle events passed to the handler * before it activates (after that the events are passed to the underlying view). */ fun wantsToHandleEventBeforeActivation() = false /** * Will be called with events if wantsToHandleEventBeforeActivation returns true. */ fun handleEventBeforeActivation(event: MotionEvent) = Unit /** * @return Boolean value indicating whether the RootViewGestureHandler should be cancelled * by this one. */ fun shouldCancelRootViewGestureHandlerIfNecessary() = false /** * Called when the handler is being recorded by the orchestrator, before any pointer events * are delivered. Returning `false` cancels the handler immediately. * * @param recorded handlers already recorded for the current touch * @param handler the handler being recorded */ fun shouldBeginWithRecordedHandlers(recorded: List, handler: NativeViewGestureHandler): Boolean = true /** * Passes the event down to the underlying view using the correct method. */ fun sendTouchEvent(view: View?, event: MotionEvent) = view?.onTouchEvent(event) } private class TextViewHook : NativeViewGestureHandlerHook { override fun shouldRecognizeSimultaneously(handler: GestureHandler) = false // We have to explicitly check for ReactTextView, since its `isPressed` flag is not set to `true`, // in contrast to e.g. Touchable override fun canActivate(view: View) = view is ReactTextView } private class EditTextHook(private val handler: NativeViewGestureHandler, private val editText: ReactEditText) : NativeViewGestureHandlerHook { private var startX = 0f private var startY = 0f private var touchSlopSquared: Int init { val vc = ViewConfiguration.get(editText.context) touchSlopSquared = vc.scaledTouchSlop * vc.scaledTouchSlop } override fun afterGestureEnd(event: MotionEvent) { if ( (event.x - startX) * (event.x - startX) + (event.y - startY) * (event.y - startY) < touchSlopSquared ) { editText.requestFocusFromJS() } } // recognize alongside every handler besides RootViewGestureHandler; // also if other handler is NativeViewGestureHandler then don't override the default implementation override fun shouldRecognizeSimultaneously(handler: GestureHandler): Boolean? = if (handler is NativeViewGestureHandler) { null } else { handler !is RNGestureHandlerRootHelper.RootViewGestureHandler } override fun wantsToHandleEventBeforeActivation() = true override fun handleEventBeforeActivation(event: MotionEvent) { handler.activate() editText.onTouchEvent(event) startX = event.x startY = event.y } override fun shouldCancelRootViewGestureHandlerIfNecessary() = true } private class SwipeRefreshLayoutHook( private val handler: NativeViewGestureHandler, private val swipeRefreshLayout: ReactSwipeRefreshLayout, ) : NativeViewGestureHandlerHook { private var lastY: Float? = null override fun wantsToHandleEventBeforeActivation() = true override fun handleEventBeforeActivation(event: MotionEvent) { // RefreshControl from GH is set up in a way that ScrollView wrapped with it should wait for // it to fail. This way the RefreshControl is not canceled by the scroll handler. // The problem with this approach is that the RefreshControl handler stays active all the time // preventing scroll from activating. // This is a workaround to prevent it from happening. // First get the ScrollView under the RefreshControl, if there is none, return. val scroll = swipeRefreshLayout.getChildAt(0) as? ScrollView ?: return // Then find the first NativeViewGestureHandler attached to it val scrollHandler = handler.orchestrator ?.getHandlersForView(scroll) ?.first { it is NativeViewGestureHandler } // In old API ScrollView was detecting scroll even if RefreshControl hasn't been cancelled yet. // This doesn't work on new API, therefore we check scroll direction. This shouldn't affect old APIs. // To determine scroll direction, we will compare current event with previous one. // Note: Scrolling up is handled by `canScrollVertically` method. val scrollDirection = lastY?.let { val dy = it - event.y when { dy < 0 -> ScrollDirection.UP dy > 0 -> ScrollDirection.DOWN else -> ScrollDirection.NONE } } ?: ScrollDirection.NONE // We want to fail RefreshControl if we find active ScrollView handler and we either: // 1. scroll down, // 2. scroll up when we are not at the top of the list. if (scrollHandler != null && scrollHandler.state == STATE_ACTIVE && (scrollDirection == ScrollDirection.DOWN || scroll.canScrollVertically(ScrollDirection.UP.value)) ) { handler.fail() } lastY = event.y // The drawback is that the smooth transition from scrolling to refreshing in a single swipe // is impossible this way and two swipes are required: // - one to go back to top // - one to actually refresh // oh well ¯\_(ツ)_/¯ } } private class ScrollViewHook : NativeViewGestureHandlerHook { override fun shouldCancelRootViewGestureHandlerIfNecessary() = true } private class ReactViewGroupHook : NativeViewGestureHandlerHook { // There are cases where a native component is wrapped with a `ReactViewGroup` (the component is rendered // inside a `` component in JS). In such cases, calling `onTouchEvent` wouldn't work as those are // ignored by the wrapper view. Instead `dispatchTouchEvent` can be used, which causes the view to dispatch // the event to its children. override fun sendTouchEvent(view: View?, event: MotionEvent) = view?.dispatchTouchEvent(event) } }