feat(smart-app): implement complete mobile app MVP

- App.tsx: full navigation (Auth stack + Main tabs with 5 screens)
- Auth: LoginScreen, RegisterScreen, ForgotPasswordScreen
- HomeScreen: dashboard with IoT metrics, weather widget, alerts, quick actions, sensors
- MapScreen: interactive map with layer toggles (6 layers)
- MarketplaceScreen: categories (6), products (5), search
- ChatScreen: AI chat with quick prompts (4), bot responses
- ProfileScreen: user info, stats, menu (9 items), logout
- AlertsScreen: alert list with severity, acknowledge
- SensorsScreen: sensor list with type filters (6 types), search
- ZonesScreen: zone cards with stats
- SettingsScreen: language picker (FR/EN/ES/DE), privacy, about
- Stores: iotStore (sensors, zones, alerts), notificationStore, uiStore + i18n
- Hooks: useSensors, useAlerts, useNotifications, useLocation
- Components: Card, Button, LoadingSpinner, ErrorBoundary, Header
- Services: iotService, notificationService (with axios API client)
- Utils: formatters (temp, AQI, noise, dates), validators (email, password, IBAN)
- Theme: colors.ts with full design system (Blue Ocean palette)
- Ditto: fixed MongoDB connection, new JWT secrets, official gateway image
This commit is contained in:
Eric FELIXINE
2026-06-01 18:00:35 -04:00
parent 08ca495bde
commit e30ae8ed09
35578 changed files with 3703534 additions and 43 deletions

View File

@@ -0,0 +1,209 @@
import groovy.json.JsonSlurper
import javax.inject.Inject
import java.nio.file.Files
buildscript {
def kotlin_version = rootProject.ext.has('kotlinVersion') ? rootProject.ext.get('kotlinVersion') : project.properties['RNGH_kotlinVersion']
repositories {
mavenCentral()
google()
}
dependencies {
classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version")
classpath("com.android.tools.build:gradle:7.2.1")
classpath("com.diffplug.spotless:spotless-plugin-gradle:6.7.2")
}
}
def isNewArchitectureEnabled() {
// To opt-in for the New Architecture, you can either:
// - Set `newArchEnabled` to true inside the `gradle.properties` file
// - Invoke gradle with `-newArchEnabled=true`
// - Set an environment variable `ORG_GRADLE_PROJECT_newArchEnabled=true`
return project.hasProperty("newArchEnabled") && project.newArchEnabled == "true"
}
def safeExtGet(prop, fallback) {
rootProject.ext.has(prop) ? rootProject.ext.get(prop) : fallback
}
def resolveReactNativeDirectory() {
def reactNativeLocation = safeExtGet("REACT_NATIVE_NODE_MODULES_DIR", null)
if (reactNativeLocation != null) {
return file(reactNativeLocation)
}
// monorepo workaround
// react-native can be hoisted or in project's own node_modules
def reactNativeFromProjectNodeModules = file("${rootProject.projectDir}/../node_modules/react-native")
if (reactNativeFromProjectNodeModules.exists()) {
return reactNativeFromProjectNodeModules
}
def reactNativeFromNodeModulesWithReanimated = file("${projectDir}/../../react-native")
if (reactNativeFromNodeModulesWithReanimated.exists()) {
return reactNativeFromNodeModulesWithReanimated
}
throw new Exception(
"[react-native-gesture-handler] Unable to resolve react-native location in " +
"node_modules. You should add project extension property (in app/build.gradle) " +
"`REACT_NATIVE_NODE_MODULES_DIR` with path to react-native."
)
}
if (isNewArchitectureEnabled()) {
apply plugin: 'com.facebook.react'
}
apply plugin: 'com.android.library'
apply plugin: 'kotlin-android'
if (project == rootProject) {
apply from: "spotless.gradle"
}
// Check whether Reanimated 2.3 or higher is installed alongside Gesture Handler
def shouldUseCommonInterfaceFromReanimated() {
def reanimated = rootProject.subprojects.find { it.name == 'react-native-reanimated' }
if (reanimated != null) {
def inputFile = new File(reanimated.projectDir, '../package.json')
def json = new JsonSlurper().parseText(inputFile.text)
def reanimatedVersion = json.version as String
def (major, minor, patch) = reanimatedVersion.tokenize('.')
return (Integer.parseInt(major) == 2 && Integer.parseInt(minor) >= 3) || Integer.parseInt(major) == 3
} else {
return false
}
}
def reactNativeArchitectures() {
def value = project.getProperties().get("reactNativeArchitectures")
return value ? value.split(",") : ["armeabi-v7a", "x86", "x86_64", "arm64-v8a"]
}
def REACT_NATIVE_DIR = resolveReactNativeDirectory()
def reactProperties = new Properties()
file("$REACT_NATIVE_DIR/ReactAndroid/gradle.properties").withInputStream { reactProperties.load(it) }
def REACT_NATIVE_VERSION = reactProperties.getProperty("VERSION_NAME")
def REACT_NATIVE_MINOR_VERSION = REACT_NATIVE_VERSION.startsWith("0.0.0-") ? 1000 : REACT_NATIVE_VERSION.split("\\.")[1].toInteger()
repositories {
mavenCentral()
}
android {
compileSdkVersion safeExtGet("compileSdkVersion", 33)
def agpVersion = com.android.Version.ANDROID_GRADLE_PLUGIN_VERSION
if (agpVersion.tokenize('.')[0].toInteger() >= 7) {
namespace "com.swmansion.gesturehandler"
}
if (agpVersion.tokenize('.')[0].toInteger() >= 8) {
buildFeatures {
buildConfig = true
}
}
// Used to override the NDK path/version on internal CI or by allowing
// users to customize the NDK path/version from their root project (e.g. for M1 support)
if (rootProject.hasProperty("ndkPath")) {
ndkPath rootProject.ext.ndkPath
}
if (rootProject.hasProperty("ndkVersion")) {
ndkVersion rootProject.ext.ndkVersion
}
if (REACT_NATIVE_MINOR_VERSION >= 71) {
buildFeatures {
prefab true
}
}
defaultConfig {
minSdkVersion safeExtGet('minSdkVersion', 21)
targetSdkVersion safeExtGet('targetSdkVersion', 33)
versionCode 1
versionName "1.0"
buildConfigField "boolean", "IS_NEW_ARCHITECTURE_ENABLED", isNewArchitectureEnabled().toString()
buildConfigField "int", "REACT_NATIVE_MINOR_VERSION", REACT_NATIVE_MINOR_VERSION.toString()
if (isNewArchitectureEnabled()) {
var appProject = rootProject.allprojects.find {it.plugins.hasPlugin('com.android.application')}
externalNativeBuild {
cmake {
cppFlags "-O2", "-frtti", "-fexceptions", "-Wall", "-Werror", "-std=c++20", "-DANDROID"
arguments "-DAPP_BUILD_DIR=${appProject.buildDir}",
"-DREACT_NATIVE_DIR=${REACT_NATIVE_DIR}",
"-DREACT_NATIVE_MINOR_VERSION=${REACT_NATIVE_MINOR_VERSION}",
"-DANDROID_STL=c++_shared"
abiFilters (*reactNativeArchitectures())
}
}
}
}
compileOptions {
sourceCompatibility JavaVersion.VERSION_1_8
targetCompatibility JavaVersion.VERSION_1_8
}
if (isNewArchitectureEnabled()) {
externalNativeBuild {
cmake {
path "src/main/jni/CMakeLists.txt"
}
}
}
packagingOptions {
// For some reason gradle only complains about the duplicated version of libreact_render libraries
// while there are more libraries copied in intermediates folder of the lib build directory, we exclude
// only the ones that make the build fail (ideally we should only include libgesturehandler but we
// are only allowed to specify exclude patterns)
exclude "**/libreact_render*.so"
}
sourceSets.main {
java {
// Include "common/" only when it's not provided by Reanimated to mitigate
// multiple definitions of the same class preventing build
if (shouldUseCommonInterfaceFromReanimated()) {
srcDirs += 'reanimated/src/main/java'
} else {
srcDirs += 'common/src/main/java'
srcDirs += 'noreanimated/src/main/java'
}
if (isNewArchitectureEnabled()) {
srcDirs += 'fabric/src/main/java'
} else {
// this folder also includes files from codegen so the library can compile with
// codegen turned off
srcDirs += 'paper/src/main/java'
}
}
}
}
def kotlin_version = safeExtGet('kotlinVersion', project.properties['RNGH_kotlinVersion'])
dependencies {
implementation 'com.facebook.react:react-native:+' // from node_modules
if (shouldUseCommonInterfaceFromReanimated()) {
// Include Reanimated as dependency to load the common interface
implementation (rootProject.subprojects.find { it.name == 'react-native-reanimated' }) {
exclude group:'com.facebook.fbjni' // resolves "Duplicate class com.facebook.jni.CppException"
}
}
implementation 'androidx.appcompat:appcompat:1.2.0'
implementation "androidx.core:core-ktx:1.6.0"
implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version"
}

View File

@@ -0,0 +1,5 @@
package com.swmansion.common
interface GestureHandlerStateManager {
fun setGestureHandlerState(handlerTag: Int, newState: Int)
}

View File

@@ -0,0 +1,29 @@
package com.swmansion.gesturehandler.react;
import com.facebook.jni.HybridData;
import com.facebook.proguard.annotations.DoNotStrip;
import com.facebook.react.fabric.ComponentFactory;
import com.facebook.soloader.SoLoader;
@DoNotStrip
public class RNGestureHandlerComponentsRegistry {
static {
SoLoader.loadLibrary("fabricjni");
SoLoader.loadLibrary("gesturehandler");
}
@DoNotStrip private final HybridData mHybridData;
@DoNotStrip
private native HybridData initHybrid(ComponentFactory componentFactory);
@DoNotStrip
private RNGestureHandlerComponentsRegistry(ComponentFactory componentFactory) {
mHybridData = initHybrid(componentFactory);
}
@DoNotStrip
public static RNGestureHandlerComponentsRegistry register(ComponentFactory componentFactory) {
return new RNGestureHandlerComponentsRegistry(componentFactory);
}
}

View File

@@ -0,0 +1,12 @@
package com.swmansion.gesturehandler
import com.facebook.react.bridge.ReactContext
import com.facebook.react.fabric.FabricUIManager
import com.facebook.react.uimanager.UIManagerHelper
import com.facebook.react.uimanager.common.UIManagerType
import com.facebook.react.uimanager.events.Event
fun ReactContext.dispatchEvent(event: Event<*>) {
val fabricUIManager = UIManagerHelper.getUIManager(this, UIManagerType.FABRIC) as FabricUIManager
fabricUIManager.eventDispatcher.dispatchEvent(event)
}

View File

@@ -0,0 +1,19 @@
# Project-wide Gradle settings.
# IDE (e.g. Android Studio) users:
# Gradle settings configured through the IDE *will override*
# any settings specified in this file.
# For more details on how to configure your build environment visit
# http://www.gradle.org/docs/current/userguide/build_environment.html
# Specifies the JVM arguments used for the daemon process.
# The setting is particularly useful for tweaking memory settings.
# Default value: -Xmx10248m -XX:MaxMetaspaceSize=256m
org.gradle.jvmargs=-Xmx2048m -XX:MaxMetaspaceSize=512m -XX:+HeapDumpOnOutOfMemoryError -Dfile.encoding=UTF-8
# When configured, Gradle will run in incubating parallel mode.
# This option should only be used with decoupled projects. More details, visit
# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects
# org.gradle.parallel=true
RNGH_kotlinVersion=1.6.21

View File

@@ -0,0 +1,12 @@
package com.swmansion.gesturehandler
import com.facebook.react.bridge.ReactContext
import com.facebook.react.uimanager.events.Event
class ReanimatedEventDispatcher {
@Suppress("UNUSED_PARAMETER", "COMMENT_IN_SUPPRESSION")
// This is necessary on new architecture
fun <T : Event<T>>sendEvent(event: T, reactApplicationContext: ReactContext) {
// no-op
}
}

View File

@@ -0,0 +1,59 @@
/**
* This code was generated by [react-native-codegen](https://www.npmjs.com/package/react-native-codegen).
*
* Do not edit this file as changes may cause incorrect behavior and will be lost
* once the code is regenerated.
*
* @generated by codegen project: GeneratePropsJavaDelegate.js
*/
package com.facebook.react.viewmanagers;
import android.view.View;
import androidx.annotation.Nullable;
import com.facebook.react.bridge.ColorPropConverter;
import com.facebook.react.uimanager.BaseViewManagerDelegate;
import com.facebook.react.uimanager.BaseViewManagerInterface;
public class RNGestureHandlerButtonManagerDelegate<T extends View, U extends BaseViewManagerInterface<T> & RNGestureHandlerButtonManagerInterface<T>> extends BaseViewManagerDelegate<T, U> {
public RNGestureHandlerButtonManagerDelegate(U viewManager) {
super(viewManager);
}
@Override
public void setProperty(T view, String propName, @Nullable Object value) {
switch (propName) {
case "exclusive":
mViewManager.setExclusive(view, value == null ? true : (boolean) value);
break;
case "foreground":
mViewManager.setForeground(view, value == null ? false : (boolean) value);
break;
case "borderless":
mViewManager.setBorderless(view, value == null ? false : (boolean) value);
break;
case "enabled":
mViewManager.setEnabled(view, value == null ? true : (boolean) value);
break;
case "rippleColor":
mViewManager.setRippleColor(view, ColorPropConverter.getColor(value, view.getContext()));
break;
case "rippleRadius":
mViewManager.setRippleRadius(view, value == null ? 0 : ((Double) value).intValue());
break;
case "touchSoundDisabled":
mViewManager.setTouchSoundDisabled(view, value == null ? false : (boolean) value);
break;
case "borderWidth":
mViewManager.setBorderWidth(view, value == null ? 0f : ((Double) value).floatValue());
break;
case "borderColor":
mViewManager.setBorderColor(view, ColorPropConverter.getColor(value, view.getContext()));
break;
case "borderStyle":
mViewManager.setBorderStyle(view, value == null ? "solid" : (String) value);
break;
default:
super.setProperty(view, propName, value);
}
}
}

View File

@@ -0,0 +1,26 @@
/**
* This code was generated by [react-native-codegen](https://www.npmjs.com/package/react-native-codegen).
*
* Do not edit this file as changes may cause incorrect behavior and will be lost
* once the code is regenerated.
*
* @generated by codegen project: GeneratePropsJavaInterface.js
*/
package com.facebook.react.viewmanagers;
import android.view.View;
import androidx.annotation.Nullable;
public interface RNGestureHandlerButtonManagerInterface<T extends View> {
void setExclusive(T view, boolean value);
void setForeground(T view, boolean value);
void setBorderless(T view, boolean value);
void setEnabled(T view, boolean value);
void setRippleColor(T view, @Nullable Integer value);
void setRippleRadius(T view, int value);
void setTouchSoundDisabled(T view, boolean value);
void setBorderWidth(T view, float value);
void setBorderColor(T view, @Nullable Integer value);
void setBorderStyle(T view, @Nullable String value);
}

View File

@@ -0,0 +1,25 @@
/**
* This code was generated by [react-native-codegen](https://www.npmjs.com/package/react-native-codegen).
*
* Do not edit this file as changes may cause incorrect behavior and will be lost
* once the code is regenerated.
*
* @generated by codegen project: GeneratePropsJavaDelegate.js
*/
package com.facebook.react.viewmanagers;
import android.view.View;
import androidx.annotation.Nullable;
import com.facebook.react.uimanager.BaseViewManagerDelegate;
import com.facebook.react.uimanager.BaseViewManagerInterface;
public class RNGestureHandlerRootViewManagerDelegate<T extends View, U extends BaseViewManagerInterface<T> & RNGestureHandlerRootViewManagerInterface<T>> extends BaseViewManagerDelegate<T, U> {
public RNGestureHandlerRootViewManagerDelegate(U viewManager) {
super(viewManager);
}
@Override
public void setProperty(T view, String propName, @Nullable Object value) {
super.setProperty(view, propName, value);
}
}

View File

@@ -0,0 +1,16 @@
/**
* This code was generated by [react-native-codegen](https://www.npmjs.com/package/react-native-codegen).
*
* Do not edit this file as changes may cause incorrect behavior and will be lost
* once the code is regenerated.
*
* @generated by codegen project: GeneratePropsJavaInterface.js
*/
package com.facebook.react.viewmanagers;
import android.view.View;
public interface RNGestureHandlerRootViewManagerInterface<T extends View> {
// No props
}

View File

@@ -0,0 +1,55 @@
package com.swmansion.gesturehandler;
import com.facebook.proguard.annotations.DoNotStrip;
import com.facebook.react.bridge.ReactApplicationContext;
import com.facebook.react.bridge.ReactContextBaseJavaModule;
import com.facebook.react.bridge.ReactMethod;
import com.facebook.react.bridge.ReadableMap;
import com.facebook.react.turbomodule.core.interfaces.TurboModule;
import javax.annotation.Nonnull;
import com.facebook.react.bridge.ReactMethod;
public abstract class NativeRNGestureHandlerModuleSpec extends ReactContextBaseJavaModule implements TurboModule {
public static final String NAME = "RNGestureHandlerModule";
public NativeRNGestureHandlerModuleSpec(ReactApplicationContext reactContext) {
super(reactContext);
}
@Override
public @Nonnull String getName() {
return NAME;
}
@DoNotStrip
@ReactMethod
public abstract void handleSetJSResponder(double tag, boolean blockNativeResponder);
@DoNotStrip
@ReactMethod
public abstract void handleClearJSResponder();
@DoNotStrip
@ReactMethod
public abstract void createGestureHandler(String handlerName, double handlerTag, ReadableMap config);
@DoNotStrip
@ReactMethod
public abstract void attachGestureHandler(double handlerTag, double newView, double actionType);
@DoNotStrip
@ReactMethod
public abstract void updateGestureHandler(double handlerTag, ReadableMap newConfig);
@DoNotStrip
@ReactMethod
public abstract void dropGestureHandler(double handlerTag);
@DoNotStrip
@ReactMethod
public abstract boolean install();
@DoNotStrip
@ReactMethod
public abstract void flushOperations();
}

View File

@@ -0,0 +1,13 @@
package com.swmansion.gesturehandler
import com.facebook.react.bridge.ReactContext
import com.facebook.react.uimanager.UIManagerModule
import com.facebook.react.uimanager.events.Event
fun ReactContext.dispatchEvent(event: Event<*>) {
try {
this.getNativeModule(UIManagerModule::class.java)!!.eventDispatcher.dispatchEvent(event)
} catch (e: NullPointerException) {
throw Exception("Couldn't get an instance of UIManagerModule. Gesture Handler is unable to send an event.", e)
}
}

View File

@@ -0,0 +1,17 @@
package com.swmansion.gesturehandler
import com.facebook.react.bridge.ReactContext
import com.facebook.react.uimanager.events.Event
import com.swmansion.reanimated.ReanimatedModule
class ReanimatedEventDispatcher {
private var reanimatedModule: ReanimatedModule? = null
fun <T : Event<T>>sendEvent(event: T, reactApplicationContext: ReactContext) {
if (reanimatedModule == null) {
reanimatedModule = reactApplicationContext.getNativeModule(ReanimatedModule::class.java)
}
reanimatedModule?.nodesManager?.onEventDispatch(event)
}
}

View File

@@ -0,0 +1,3 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="com.swmansion.gesturehandler">
</manifest>

View File

@@ -0,0 +1,86 @@
package com.swmansion.gesturehandler
import com.facebook.react.TurboReactPackage
import com.facebook.react.ViewManagerOnDemandReactPackage
import com.facebook.react.bridge.ModuleSpec
import com.facebook.react.bridge.NativeModule
import com.facebook.react.bridge.ReactApplicationContext
import com.facebook.react.module.annotations.ReactModule
import com.facebook.react.module.annotations.ReactModuleList
import com.facebook.react.module.model.ReactModuleInfo
import com.facebook.react.module.model.ReactModuleInfoProvider
import com.facebook.react.uimanager.ViewManager
import com.swmansion.gesturehandler.react.RNGestureHandlerButtonViewManager
import com.swmansion.gesturehandler.react.RNGestureHandlerModule
import com.swmansion.gesturehandler.react.RNGestureHandlerRootViewManager
@ReactModuleList(
nativeModules = [
RNGestureHandlerModule::class
]
)
class RNGestureHandlerPackage : TurboReactPackage(), ViewManagerOnDemandReactPackage {
private val viewManagers: Map<String, ModuleSpec> by lazy {
mapOf(
RNGestureHandlerRootViewManager.REACT_CLASS to ModuleSpec.viewManagerSpec {
RNGestureHandlerRootViewManager()
},
RNGestureHandlerButtonViewManager.REACT_CLASS to ModuleSpec.viewManagerSpec {
RNGestureHandlerButtonViewManager()
}
)
}
override fun createViewManagers(reactContext: ReactApplicationContext) =
listOf<ViewManager<*, *>>(
RNGestureHandlerRootViewManager(),
RNGestureHandlerButtonViewManager()
)
override fun getViewManagerNames(reactContext: ReactApplicationContext?) =
viewManagers.keys.toList()
override fun getViewManagers(reactContext: ReactApplicationContext?): MutableList<ModuleSpec> =
viewManagers.values.toMutableList()
override fun createViewManager(
reactContext: ReactApplicationContext?,
viewManagerName: String?
) = viewManagers[viewManagerName]?.provider?.get() as? ViewManager<*, *>
override fun getModule(name: String, reactContext: ReactApplicationContext): NativeModule? {
return if (name == RNGestureHandlerModule.NAME) {
RNGestureHandlerModule(reactContext)
} else {
null
}
}
override fun getReactModuleInfoProvider(): ReactModuleInfoProvider {
try {
val reactModuleInfoProviderClass =
Class.forName("com.swmansion.gesturehandler.RNGestureHandlerPackage$\$ReactModuleInfoProvider")
return reactModuleInfoProviderClass.getDeclaredConstructor().newInstance() as ReactModuleInfoProvider
} catch (e: ClassNotFoundException) {
return ReactModuleInfoProvider {
val reactModule: ReactModule = RNGestureHandlerModule::class.java.getAnnotation(ReactModule::class.java)!!
mutableMapOf(
RNGestureHandlerModule.NAME to ReactModuleInfo(
reactModule.name,
RNGestureHandlerModule::class.java.name,
reactModule.canOverrideExistingModule,
reactModule.needsEagerInit,
true, // Has constants is hardcoded to return true, so replacing it with `true` changes nothing.
reactModule.isCxxModule,
true
)
)
}
} catch (e: InstantiationException) {
throw RuntimeException("No ReactModuleInfoProvider for RNGestureHandlerPackage$\$ReactModuleInfoProvider", e)
} catch (e: IllegalAccessException) {
throw RuntimeException("No ReactModuleInfoProvider for RNGestureHandlerPackage$\$ReactModuleInfoProvider", e)
}
}
}

View File

@@ -0,0 +1,8 @@
package com.swmansion.gesturehandler.core
object DiagonalDirections {
const val DIRECTION_RIGHT_UP = GestureHandler.DIRECTION_RIGHT or GestureHandler.DIRECTION_UP
const val DIRECTION_RIGHT_DOWN = GestureHandler.DIRECTION_RIGHT or GestureHandler.DIRECTION_DOWN
const val DIRECTION_LEFT_UP = GestureHandler.DIRECTION_LEFT or GestureHandler.DIRECTION_UP
const val DIRECTION_LEFT_DOWN = GestureHandler.DIRECTION_LEFT or GestureHandler.DIRECTION_DOWN
}

View File

@@ -0,0 +1,141 @@
package com.swmansion.gesturehandler.core
import android.os.Handler
import android.os.Looper
import android.view.MotionEvent
import android.view.VelocityTracker
class FlingGestureHandler : GestureHandler<FlingGestureHandler>() {
var numberOfPointersRequired = DEFAULT_NUMBER_OF_TOUCHES_REQUIRED
var direction = DEFAULT_DIRECTION
private val maxDurationMs = DEFAULT_MAX_DURATION_MS
private val minVelocity = DEFAULT_MIN_VELOCITY
private var handler: Handler? = null
private var maxNumberOfPointersSimultaneously = 0
private val failDelayed = Runnable { fail() }
private var velocityTracker: VelocityTracker? = null
override fun resetConfig() {
super.resetConfig()
numberOfPointersRequired = DEFAULT_NUMBER_OF_TOUCHES_REQUIRED
direction = DEFAULT_DIRECTION
}
private fun startFling(event: MotionEvent) {
velocityTracker = VelocityTracker.obtain()
begin()
maxNumberOfPointersSimultaneously = 1
if (handler == null) {
handler = Handler(Looper.getMainLooper()) // lazy delegate?
} else {
handler!!.removeCallbacksAndMessages(null)
}
handler!!.postDelayed(failDelayed, maxDurationMs)
}
private fun tryEndFling(event: MotionEvent): Boolean {
addVelocityMovement(velocityTracker, event)
val velocityVector = Vector.fromVelocity(velocityTracker!!)
fun getVelocityAlignment(
direction: Int,
maxDeviationCosine: Double,
): Boolean = (
(this.direction and direction) == direction &&
velocityVector.isSimilar(Vector.fromDirection(direction), maxDeviationCosine)
)
val axialAlignmentsList = arrayOf(
DIRECTION_LEFT,
DIRECTION_RIGHT,
DIRECTION_UP,
DIRECTION_DOWN,
).map { direction -> getVelocityAlignment(direction, MAX_AXIAL_DEVIATION) }
val diagonalAlignmentsList = arrayOf(
DiagonalDirections.DIRECTION_RIGHT_UP,
DiagonalDirections.DIRECTION_RIGHT_DOWN,
DiagonalDirections.DIRECTION_LEFT_UP,
DiagonalDirections.DIRECTION_LEFT_DOWN,
).map { direction -> getVelocityAlignment(direction, MAX_DIAGONAL_DEVIATION) }
val isAligned = axialAlignmentsList.any { it } or diagonalAlignmentsList.any { it }
val isFast = velocityVector.magnitude > this.minVelocity
return if (
maxNumberOfPointersSimultaneously == numberOfPointersRequired &&
isAligned &&
isFast
) {
handler!!.removeCallbacksAndMessages(null)
activate()
true
} else {
false
}
}
override fun activate(force: Boolean) {
super.activate(force)
end()
}
private fun endFling(event: MotionEvent) {
if (!tryEndFling(event)) {
fail()
}
}
override fun onHandle(event: MotionEvent, sourceEvent: MotionEvent) {
if (!shouldActivateWithMouse(sourceEvent)) {
return
}
val state = state
if (state == STATE_UNDETERMINED) {
startFling(sourceEvent)
}
if (state == STATE_BEGAN) {
tryEndFling(sourceEvent)
if (sourceEvent.pointerCount > maxNumberOfPointersSimultaneously) {
maxNumberOfPointersSimultaneously = sourceEvent.pointerCount
}
val action = sourceEvent.actionMasked
if (action == MotionEvent.ACTION_UP) {
endFling(sourceEvent)
}
}
}
override fun onCancel() {
handler?.removeCallbacksAndMessages(null)
}
override fun onReset() {
velocityTracker?.recycle()
velocityTracker = null
handler?.removeCallbacksAndMessages(null)
}
private fun addVelocityMovement(tracker: VelocityTracker?, event: MotionEvent) {
val offsetX = event.rawX - event.x
val offsetY = event.rawY - event.y
event.offsetLocation(offsetX, offsetY)
tracker!!.addMovement(event)
event.offsetLocation(-offsetX, -offsetY)
}
companion object {
private const val DEFAULT_MAX_DURATION_MS: Long = 800
private const val DEFAULT_MIN_VELOCITY: Long = 2000
private const val DEFAULT_ALIGNMENT_CONE: Double = 30.0
private const val DEFAULT_DIRECTION = DIRECTION_RIGHT
private const val DEFAULT_NUMBER_OF_TOUCHES_REQUIRED = 1
private val MAX_AXIAL_DEVIATION: Double =
GestureUtils.coneToDeviation(DEFAULT_ALIGNMENT_CONE)
private val MAX_DIAGONAL_DEVIATION: Double =
GestureUtils.coneToDeviation(90 - DEFAULT_ALIGNMENT_CONE)
}
}

View File

@@ -0,0 +1,871 @@
package com.swmansion.gesturehandler.core
import android.app.Activity
import android.content.Context
import android.content.ContextWrapper
import android.graphics.PointF
import android.os.Build
import android.view.MotionEvent
import android.view.MotionEvent.PointerCoords
import android.view.MotionEvent.PointerProperties
import android.view.View
import com.facebook.react.bridge.Arguments
import com.facebook.react.bridge.ReactContext
import com.facebook.react.bridge.UiThreadUtil
import com.facebook.react.bridge.WritableArray
import com.facebook.react.uimanager.PixelUtil
import com.swmansion.gesturehandler.BuildConfig
import com.swmansion.gesturehandler.react.RNGestureHandlerTouchEvent
import java.lang.IllegalStateException
import java.util.*
open class GestureHandler<ConcreteGestureHandlerT : GestureHandler<ConcreteGestureHandlerT>> {
private val trackedPointerIDs = IntArray(MAX_POINTERS_COUNT)
private var trackedPointersIDsCount = 0
private val windowOffset = IntArray(2) { 0 }
var tag = 0
var view: View? = null
private set
var state = STATE_UNDETERMINED
private set
var x = 0f
private set
var y = 0f
private set
var isWithinBounds = false
private set
var isEnabled = true
private set
var actionType = 0
var changedTouchesPayload: WritableArray? = null
private set
var allTouchesPayload: WritableArray? = null
private set
var touchEventType = RNGestureHandlerTouchEvent.EVENT_UNDETERMINED
private set
var trackedPointersCount = 0
private set
private val trackedPointers: Array<PointerData?> = Array(MAX_POINTERS_COUNT) { null }
var needsPointerData = false
private var hitSlop: FloatArray? = null
var eventCoalescingKey: Short = 0
private set
var lastAbsolutePositionX = 0f
private set
var lastAbsolutePositionY = 0f
private set
private var manualActivation = false
private var lastEventOffsetX = 0f
private var lastEventOffsetY = 0f
private var shouldCancelWhenOutside = false
var numberOfPointers = 0
private set
protected var orchestrator: GestureHandlerOrchestrator? = null
private var onTouchEventListener: OnTouchEventListener? = null
private var interactionController: GestureHandlerInteractionController? = null
var pointerType: Int = POINTER_TYPE_OTHER
private set
protected var mouseButton = 0
@Suppress("UNCHECKED_CAST")
protected fun self(): ConcreteGestureHandlerT = this as ConcreteGestureHandlerT
protected inline fun applySelf(block: ConcreteGestureHandlerT.() -> Unit): ConcreteGestureHandlerT =
self().apply { block() }
// properties set and accessed only by the orchestrator
var activationIndex = 0
var isActive = false
var isAwaiting = false
var shouldResetProgress = false
open fun dispatchStateChange(newState: Int, prevState: Int) {
onTouchEventListener?.onStateChange(self(), newState, prevState)
}
open fun dispatchHandlerUpdate(event: MotionEvent) {
onTouchEventListener?.onHandlerUpdate(self(), event)
}
open fun dispatchTouchEvent() {
if (changedTouchesPayload != null) {
onTouchEventListener?.onTouchEvent(self())
}
}
open fun resetConfig() {
needsPointerData = false
manualActivation = false
shouldCancelWhenOutside = false
isEnabled = true
hitSlop = null
}
fun hasCommonPointers(other: GestureHandler<*>): Boolean {
for (i in trackedPointerIDs.indices) {
if (trackedPointerIDs[i] != -1 && other.trackedPointerIDs[i] != -1) {
return true
}
}
return false
}
fun setShouldCancelWhenOutside(shouldCancelWhenOutside: Boolean): ConcreteGestureHandlerT =
applySelf { this.shouldCancelWhenOutside = shouldCancelWhenOutside }
fun setEnabled(enabled: Boolean): ConcreteGestureHandlerT = applySelf {
// Don't cancel handler when not changing the value of the isEnabled, executing it always caused
// handlers to be cancelled on re-render because that's the moment when the config is updated.
// If the enabled prop "changed" from true to true the handler would get cancelled.
if (view != null && isEnabled != enabled) {
// If view is set then handler is in "active" state. In that case we want to "cancel" handler
// when it changes enabled state so that it gets cleared from the orchestrator
UiThreadUtil.runOnUiThread { cancel() }
}
isEnabled = enabled
}
fun setManualActivation(manualActivation: Boolean): ConcreteGestureHandlerT =
applySelf { this.manualActivation = manualActivation }
fun setHitSlop(
leftPad: Float,
topPad: Float,
rightPad: Float,
bottomPad: Float,
width: Float,
height: Float,
): ConcreteGestureHandlerT = applySelf {
if (hitSlop == null) {
hitSlop = FloatArray(6)
}
hitSlop!![HIT_SLOP_LEFT_IDX] = leftPad
hitSlop!![HIT_SLOP_TOP_IDX] = topPad
hitSlop!![HIT_SLOP_RIGHT_IDX] = rightPad
hitSlop!![HIT_SLOP_BOTTOM_IDX] = bottomPad
hitSlop!![HIT_SLOP_WIDTH_IDX] = width
hitSlop!![HIT_SLOP_HEIGHT_IDX] = height
require(!(hitSlopSet(width) && hitSlopSet(leftPad) && hitSlopSet(rightPad))) { "Cannot have all of left, right and width defined" }
require(!(hitSlopSet(width) && !hitSlopSet(leftPad) && !hitSlopSet(rightPad))) { "When width is set one of left or right pads need to be defined" }
require(!(hitSlopSet(height) && hitSlopSet(bottomPad) && hitSlopSet(topPad))) { "Cannot have all of top, bottom and height defined" }
require(!(hitSlopSet(height) && !hitSlopSet(bottomPad) && !hitSlopSet(topPad))) { "When height is set one of top or bottom pads need to be defined" }
}
fun setHitSlop(padding: Float): ConcreteGestureHandlerT {
return setHitSlop(padding, padding, padding, padding, HIT_SLOP_NONE, HIT_SLOP_NONE)
}
fun setInteractionController(controller: GestureHandlerInteractionController?): ConcreteGestureHandlerT =
applySelf { interactionController = controller }
fun setMouseButton(mouseButton: Int) = apply {
this.mouseButton = mouseButton
}
fun prepare(view: View?, orchestrator: GestureHandlerOrchestrator?) {
check(!(this.view != null || this.orchestrator != null)) { "Already prepared or hasn't been reset" }
Arrays.fill(trackedPointerIDs, -1)
trackedPointersIDsCount = 0
state = STATE_UNDETERMINED
this.view = view
this.orchestrator = orchestrator
val content = getActivity(view?.context)?.findViewById<View>(android.R.id.content)
if (content != null) {
content.getLocationOnScreen(windowOffset)
} else {
windowOffset[0] = 0
windowOffset[1] = 0
}
onPrepare()
}
protected open fun onPrepare() {}
private fun getActivity(context: Context?): Activity? =
when (context) {
is ReactContext -> context.currentActivity
is Activity -> context
is ContextWrapper -> getActivity(context.baseContext)
else -> null
}
private fun findNextLocalPointerId(): Int {
var localPointerId = 0
while (localPointerId < trackedPointersIDsCount) {
var i = 0
while (i < trackedPointerIDs.size) {
if (trackedPointerIDs[i] == localPointerId) {
break
}
i++
}
if (i == trackedPointerIDs.size) {
return localPointerId
}
localPointerId++
}
return localPointerId
}
fun startTrackingPointer(pointerId: Int) {
if (trackedPointerIDs[pointerId] == -1) {
trackedPointerIDs[pointerId] = findNextLocalPointerId()
trackedPointersIDsCount++
}
}
fun stopTrackingPointer(pointerId: Int) {
if (trackedPointerIDs[pointerId] != -1) {
trackedPointerIDs[pointerId] = -1
trackedPointersIDsCount--
}
}
private fun needAdapt(event: MotionEvent): Boolean {
if (event.pointerCount != trackedPointersIDsCount) {
return true
}
for (i in trackedPointerIDs.indices) {
val trackedPointer = trackedPointerIDs[i]
if (trackedPointer != -1 && trackedPointer != i) {
return true
}
}
return false
}
private fun adaptEvent(event: MotionEvent): MotionEvent {
if (!needAdapt(event)) {
return event
}
var action = event.actionMasked
var actionIndex = -1
if (action == MotionEvent.ACTION_DOWN || action == MotionEvent.ACTION_POINTER_DOWN) {
actionIndex = event.actionIndex
val actionPointer = event.getPointerId(actionIndex)
action = if (trackedPointerIDs[actionPointer] != -1) {
if (trackedPointersIDsCount == 1) MotionEvent.ACTION_DOWN else MotionEvent.ACTION_POINTER_DOWN
} else {
MotionEvent.ACTION_MOVE
}
} else if (action == MotionEvent.ACTION_UP || action == MotionEvent.ACTION_POINTER_UP) {
actionIndex = event.actionIndex
val actionPointer = event.getPointerId(actionIndex)
action = if (trackedPointerIDs[actionPointer] != -1) {
if (trackedPointersIDsCount == 1) MotionEvent.ACTION_UP else MotionEvent.ACTION_POINTER_UP
} else {
MotionEvent.ACTION_MOVE
}
}
initPointerProps(trackedPointersIDsCount)
var count = 0
val deltaX = event.rawX - event.x
val deltaY = event.rawY - event.y
event.offsetLocation(deltaX, deltaY)
var index = 0
val size = event.pointerCount
while (index < size) {
val origPointerId = event.getPointerId(index)
if (trackedPointerIDs[origPointerId] != -1) {
event.getPointerProperties(index, pointerProps[count])
pointerProps[count]!!.id = trackedPointerIDs[origPointerId]
event.getPointerCoords(index, pointerCoords[count])
if (index == actionIndex) {
action = action or (count shl MotionEvent.ACTION_POINTER_INDEX_SHIFT)
}
count++
}
index++
}
// introduced in 1.11.0, remove if crashes are not reported
if (pointerProps.isEmpty() || pointerCoords.isEmpty()) {
throw IllegalStateException("pointerCoords.size=${pointerCoords.size}, pointerProps.size=${pointerProps.size}")
}
val result: MotionEvent
try {
result = MotionEvent.obtain(
event.downTime,
event.eventTime,
action,
count,
pointerProps, /* props are copied and hence it is safe to use static array here */
pointerCoords, /* same applies to coords */
event.metaState,
event.buttonState,
event.xPrecision,
event.yPrecision,
event.deviceId,
event.edgeFlags,
event.source,
event.flags
)
} catch (e: IllegalArgumentException) {
throw AdaptEventException(this, event, e)
}
event.offsetLocation(-deltaX, -deltaY)
result.offsetLocation(-deltaX, -deltaY)
return result
}
// exception to help debug https://github.com/software-mansion/react-native-gesture-handler/issues/1188
class AdaptEventException(
handler: GestureHandler<*>,
event: MotionEvent,
e: IllegalArgumentException
) : Exception(
"""
handler: ${handler::class.simpleName}
state: ${handler.state}
view: ${handler.view}
orchestrator: ${handler.orchestrator}
isEnabled: ${handler.isEnabled}
isActive: ${handler.isActive}
isAwaiting: ${handler.isAwaiting}
trackedPointersCount: ${handler.trackedPointersIDsCount}
trackedPointers: ${handler.trackedPointerIDs.joinToString(separator = ", ")}
while handling event: $event
""".trimIndent(),
e
)
fun handle(transformedEvent: MotionEvent, sourceEvent: MotionEvent) {
if (!isEnabled ||
state == STATE_CANCELLED ||
state == STATE_FAILED ||
state == STATE_END ||
trackedPointersIDsCount < 1
) {
return
}
// a workaround for https://github.com/software-mansion/react-native-gesture-handler/issues/1188
val (adaptedTransformedEvent, adaptedSourceEvent) = if (BuildConfig.DEBUG) {
arrayOf(adaptEvent(transformedEvent), adaptEvent(sourceEvent))
} else {
try {
arrayOf(adaptEvent(transformedEvent), adaptEvent(sourceEvent))
} catch (e: AdaptEventException) {
fail()
return
}
}
x = adaptedTransformedEvent.x
y = adaptedTransformedEvent.y
numberOfPointers = adaptedTransformedEvent.pointerCount
isWithinBounds = isWithinBounds(view, x, y)
if (shouldCancelWhenOutside && !isWithinBounds) {
if (state == STATE_ACTIVE) {
cancel()
} else if (state == STATE_BEGAN) {
fail()
}
return
}
lastAbsolutePositionX = GestureUtils.getLastPointerX(adaptedTransformedEvent, true)
lastAbsolutePositionY = GestureUtils.getLastPointerY(adaptedTransformedEvent, true)
lastEventOffsetX = adaptedTransformedEvent.rawX - adaptedTransformedEvent.x
lastEventOffsetY = adaptedTransformedEvent.rawY - adaptedTransformedEvent.y
if (sourceEvent.action == MotionEvent.ACTION_DOWN || sourceEvent.action == MotionEvent.ACTION_HOVER_ENTER || sourceEvent.action == MotionEvent.ACTION_HOVER_MOVE) {
setPointerType(sourceEvent)
}
if (sourceEvent.action == MotionEvent.ACTION_HOVER_ENTER ||
sourceEvent.action == MotionEvent.ACTION_HOVER_MOVE ||
sourceEvent.action == MotionEvent.ACTION_HOVER_EXIT
) {
onHandleHover(adaptedTransformedEvent, adaptedSourceEvent)
} else {
onHandle(adaptedTransformedEvent, adaptedSourceEvent)
}
if (adaptedTransformedEvent != transformedEvent) {
adaptedTransformedEvent.recycle()
}
if (adaptedSourceEvent != sourceEvent) {
adaptedSourceEvent.recycle()
}
}
private fun dispatchTouchDownEvent(event: MotionEvent) {
changedTouchesPayload = null
touchEventType = RNGestureHandlerTouchEvent.EVENT_TOUCH_DOWN
val pointerId = event.getPointerId(event.actionIndex)
val offsetX = event.rawX - event.x
val offsetY = event.rawY - event.y
trackedPointers[pointerId] = PointerData(
pointerId,
event.getX(event.actionIndex),
event.getY(event.actionIndex),
event.getX(event.actionIndex) + offsetX - windowOffset[0],
event.getY(event.actionIndex) + offsetY - windowOffset[1],
)
trackedPointersCount++
addChangedPointer(trackedPointers[pointerId]!!)
extractAllPointersData()
dispatchTouchEvent()
}
private fun dispatchTouchUpEvent(event: MotionEvent) {
extractAllPointersData()
changedTouchesPayload = null
touchEventType = RNGestureHandlerTouchEvent.EVENT_TOUCH_UP
val pointerId = event.getPointerId(event.actionIndex)
val offsetX = event.rawX - event.x
val offsetY = event.rawY - event.y
trackedPointers[pointerId] = PointerData(
pointerId,
event.getX(event.actionIndex),
event.getY(event.actionIndex),
event.getX(event.actionIndex) + offsetX - windowOffset[0],
event.getY(event.actionIndex) + offsetY - windowOffset[1],
)
addChangedPointer(trackedPointers[pointerId]!!)
trackedPointers[pointerId] = null
trackedPointersCount--
dispatchTouchEvent()
}
private fun dispatchTouchMoveEvent(event: MotionEvent) {
changedTouchesPayload = null
touchEventType = RNGestureHandlerTouchEvent.EVENT_TOUCH_MOVE
val offsetX = event.rawX - event.x
val offsetY = event.rawY - event.y
var pointersAdded = 0
for (i in 0 until event.pointerCount) {
val pointerId = event.getPointerId(i)
val pointer = trackedPointers[pointerId] ?: continue
if (pointer.x != event.getX(i) || pointer.y != event.getY(i)) {
pointer.x = event.getX(i)
pointer.y = event.getY(i)
pointer.absoluteX = event.getX(i) + offsetX - windowOffset[0]
pointer.absoluteY = event.getY(i) + offsetY - windowOffset[1]
addChangedPointer(pointer)
pointersAdded++
}
}
// only data about pointers that have changed their position is sent, it makes no sense to send
// an empty move event (especially when this method is called during down/up event and there is
// only info about one pointer)
if (pointersAdded > 0) {
extractAllPointersData()
dispatchTouchEvent()
}
}
fun updatePointerData(event: MotionEvent) {
if (event.actionMasked == MotionEvent.ACTION_DOWN || event.actionMasked == MotionEvent.ACTION_POINTER_DOWN) {
dispatchTouchDownEvent(event)
dispatchTouchMoveEvent(event)
} else if (event.actionMasked == MotionEvent.ACTION_UP || event.actionMasked == MotionEvent.ACTION_POINTER_UP) {
dispatchTouchMoveEvent(event)
dispatchTouchUpEvent(event)
} else if (event.actionMasked == MotionEvent.ACTION_MOVE) {
dispatchTouchMoveEvent(event)
}
}
private fun extractAllPointersData() {
allTouchesPayload = null
for (pointerData in trackedPointers) {
if (pointerData != null) {
addPointerToAll(pointerData)
}
}
}
private fun cancelPointers() {
touchEventType = RNGestureHandlerTouchEvent.EVENT_TOUCH_CANCELLED
changedTouchesPayload = null
extractAllPointersData()
for (pointer in trackedPointers) {
pointer?.let {
addChangedPointer(it)
}
}
trackedPointersCount = 0
trackedPointers.fill(null)
dispatchTouchEvent()
}
private fun addChangedPointer(pointerData: PointerData) {
if (changedTouchesPayload == null) {
changedTouchesPayload = Arguments.createArray()
}
changedTouchesPayload!!.pushMap(createPointerData(pointerData))
}
private fun addPointerToAll(pointerData: PointerData) {
if (allTouchesPayload == null) {
allTouchesPayload = Arguments.createArray()
}
allTouchesPayload!!.pushMap(createPointerData(pointerData))
}
private fun createPointerData(pointerData: PointerData) = Arguments.createMap().apply {
putInt("id", pointerData.pointerId)
putDouble("x", PixelUtil.toDIPFromPixel(pointerData.x).toDouble())
putDouble("y", PixelUtil.toDIPFromPixel(pointerData.y).toDouble())
putDouble("absoluteX", PixelUtil.toDIPFromPixel(pointerData.absoluteX).toDouble())
putDouble("absoluteY", PixelUtil.toDIPFromPixel(pointerData.absoluteY).toDouble())
}
fun consumeChangedTouchesPayload(): WritableArray? {
val result = changedTouchesPayload
changedTouchesPayload = null
return result
}
fun consumeAllTouchesPayload(): WritableArray? {
val result = allTouchesPayload
allTouchesPayload = null
return result
}
private fun moveToState(newState: Int) {
UiThreadUtil.assertOnUiThread()
if (state == newState) {
return
}
// if there are tracked pointers and the gesture is about to end, send event cancelling all pointers
if (trackedPointersCount > 0 && (newState == STATE_END || newState == STATE_CANCELLED || newState == STATE_FAILED)) {
cancelPointers()
}
val oldState = state
state = newState
if (state == STATE_ACTIVE) {
// Generate a unique coalescing-key each time the gesture-handler becomes active. All events will have
// the same coalescing-key allowing EventDispatcher to coalesce RNGestureHandlerEvents when events are
// generated faster than they can be treated by JS thread
eventCoalescingKey = nextEventCoalescingKey++
}
orchestrator!!.onHandlerStateChange(this, newState, oldState)
onStateChange(newState, oldState)
}
fun wantEvents(): Boolean {
return isEnabled &&
state != STATE_FAILED &&
state != STATE_CANCELLED &&
state != STATE_END &&
trackedPointersIDsCount > 0
}
open fun shouldRequireToWaitForFailure(handler: GestureHandler<*>): Boolean {
if (handler === this) {
return false
}
return interactionController?.shouldRequireHandlerToWaitForFailure(this, handler) ?: false
}
fun shouldWaitForHandlerFailure(handler: GestureHandler<*>): Boolean {
if (handler === this) {
return false
}
return interactionController?.shouldWaitForHandlerFailure(this, handler) ?: false
}
open fun shouldRecognizeSimultaneously(handler: GestureHandler<*>): Boolean {
if (handler === this) {
return true
}
return interactionController?.shouldRecognizeSimultaneously(this, handler) ?: false
}
open fun shouldBeCancelledBy(handler: GestureHandler<*>): Boolean {
if (handler === this) {
return false
}
return interactionController?.shouldHandlerBeCancelledBy(this, handler) ?: false
}
fun isWithinBounds(view: View?, posX: Float, posY: Float): Boolean {
var left = 0f
var top = 0f
var right = view!!.width.toFloat()
var bottom = view.height.toFloat()
hitSlop?.let { hitSlop ->
val padLeft = hitSlop[HIT_SLOP_LEFT_IDX]
val padTop = hitSlop[HIT_SLOP_TOP_IDX]
val padRight = hitSlop[HIT_SLOP_RIGHT_IDX]
val padBottom = hitSlop[HIT_SLOP_BOTTOM_IDX]
if (hitSlopSet(padLeft)) {
left -= padLeft
}
if (hitSlopSet(padTop)) {
top -= padTop
}
if (hitSlopSet(padRight)) {
right += padRight
}
if (hitSlopSet(padBottom)) {
bottom += padBottom
}
val width = hitSlop[HIT_SLOP_WIDTH_IDX]
val height = hitSlop[HIT_SLOP_HEIGHT_IDX]
if (hitSlopSet(width)) {
if (!hitSlopSet(padLeft)) {
left = right - width
} else if (!hitSlopSet(padRight)) {
right = left + width
}
}
if (hitSlopSet(height)) {
if (!hitSlopSet(padTop)) {
top = bottom - height
} else if (!hitSlopSet(padBottom)) {
bottom = top + height
}
}
}
return posX in left..right && posY in top..bottom
}
fun cancel() {
if (state == STATE_ACTIVE || state == STATE_UNDETERMINED || state == STATE_BEGAN || this.isAwaiting) {
onCancel()
moveToState(STATE_CANCELLED)
}
}
fun fail() {
if (state == STATE_ACTIVE || state == STATE_UNDETERMINED || state == STATE_BEGAN) {
moveToState(STATE_FAILED)
}
}
fun activate() = activate(force = false)
open fun activate(force: Boolean) {
if ((!manualActivation || force) && (state == STATE_UNDETERMINED || state == STATE_BEGAN)) {
moveToState(STATE_ACTIVE)
}
}
fun begin() {
if (state == STATE_UNDETERMINED) {
moveToState(STATE_BEGAN)
}
}
fun end() {
if (state == STATE_BEGAN || state == STATE_ACTIVE) {
moveToState(STATE_END)
}
}
// responsible for resetting the state of handler upon activation (may be called more than once
// if the handler is waiting for failure of other one)
open fun resetProgress() {}
protected open fun onHandle(event: MotionEvent, sourceEvent: MotionEvent) {
moveToState(STATE_FAILED)
}
protected open fun onHandleHover(event: MotionEvent, sourceEvent: MotionEvent) {}
protected open fun onStateChange(newState: Int, previousState: Int) {}
protected open fun onReset() {}
protected open fun onCancel() {}
private fun isButtonInConfig(clickedButton: Int): Boolean {
if (mouseButton == 0) {
return clickedButton == MotionEvent.BUTTON_PRIMARY
}
return clickedButton and mouseButton != 0
}
protected fun shouldActivateWithMouse(sourceEvent: MotionEvent): Boolean {
// While using mouse, we get both sets of events, for example ACTION_DOWN and ACTION_BUTTON_PRESS. That's why we want to take actions to only one of them.
// On API >= 23, we will use events with infix BUTTON, otherwise we use standard action events (like ACTION_DOWN).
with(sourceEvent) {
// To use actionButton, we need API >= 23.
if (getToolType(0) == MotionEvent.TOOL_TYPE_MOUSE && Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
// While using mouse, we want to ignore default events for touch.
if (action == MotionEvent.ACTION_DOWN || action == MotionEvent.ACTION_UP || action == MotionEvent.ACTION_POINTER_UP || action == MotionEvent.ACTION_POINTER_DOWN) {
return@shouldActivateWithMouse false
}
// We don't want to do anything if wrong button was clicked. If we received event for BUTTON, we have to use actionButton to get which one was clicked.
if (action != MotionEvent.ACTION_MOVE && !isButtonInConfig(actionButton)) {
return@shouldActivateWithMouse false
}
// When we receive ACTION_MOVE, we have to check buttonState field.
if (action == MotionEvent.ACTION_MOVE && !isButtonInConfig(buttonState)) {
return@shouldActivateWithMouse false
}
} else if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) {
// We do not fully support mouse below API 23, so we will ignore BUTTON events.
if (action == MotionEvent.ACTION_BUTTON_PRESS || action == MotionEvent.ACTION_BUTTON_RELEASE) {
return@shouldActivateWithMouse false
}
}
}
return true
}
/**
* Transforms a point in the coordinate space of the wrapperView (GestureHandlerRootView) to
* coordinate space of the view the gesture is attached to.
*
* If the gesture handler is not currently attached to a view, it will return (NaN, NaN).
*
* This method modifies and transforms the received point.
*/
protected fun transformPoint(point: PointF): PointF {
return orchestrator?.transformPointToViewCoords(this.view, point) ?: run {
point.x = Float.NaN
point.y = Float.NaN
point
}
}
fun reset() {
view = null
orchestrator = null
Arrays.fill(trackedPointerIDs, -1)
trackedPointersIDsCount = 0
trackedPointersCount = 0
trackedPointers.fill(null)
touchEventType = RNGestureHandlerTouchEvent.EVENT_UNDETERMINED
onReset()
}
fun withMarkedAsInBounds(closure: () -> Unit) {
isWithinBounds = true
closure()
isWithinBounds = false
}
private fun setPointerType(event: MotionEvent) {
val pointerIndex = event.actionIndex
pointerType = when (event.getToolType(pointerIndex)) {
MotionEvent.TOOL_TYPE_FINGER -> POINTER_TYPE_TOUCH
MotionEvent.TOOL_TYPE_STYLUS -> POINTER_TYPE_STYLUS
MotionEvent.TOOL_TYPE_MOUSE -> POINTER_TYPE_MOUSE
else -> POINTER_TYPE_OTHER
}
}
fun setOnTouchEventListener(listener: OnTouchEventListener?): GestureHandler<*> {
onTouchEventListener = listener
return this
}
override fun toString(): String {
val viewString = if (view == null) null else view!!.javaClass.simpleName
return this.javaClass.simpleName + "@[" + tag + "]:" + viewString
}
val lastRelativePositionX: Float
get() = lastAbsolutePositionX
val lastRelativePositionY: Float
get() = lastAbsolutePositionY
val lastPositionInWindowX: Float
get() = lastAbsolutePositionX + lastEventOffsetX - windowOffset[0]
val lastPositionInWindowY: Float
get() = lastAbsolutePositionY + lastEventOffsetY - windowOffset[1]
companion object {
const val STATE_UNDETERMINED = 0
const val STATE_FAILED = 1
const val STATE_BEGAN = 2
const val STATE_CANCELLED = 3
const val STATE_ACTIVE = 4
const val STATE_END = 5
const val HIT_SLOP_NONE = Float.NaN
private const val HIT_SLOP_LEFT_IDX = 0
private const val HIT_SLOP_TOP_IDX = 1
private const val HIT_SLOP_RIGHT_IDX = 2
private const val HIT_SLOP_BOTTOM_IDX = 3
private const val HIT_SLOP_WIDTH_IDX = 4
private const val HIT_SLOP_HEIGHT_IDX = 5
const val DIRECTION_RIGHT = 1
const val DIRECTION_LEFT = 2
const val DIRECTION_UP = 4
const val DIRECTION_DOWN = 8
const val ACTION_TYPE_REANIMATED_WORKLET = 1
const val ACTION_TYPE_NATIVE_ANIMATED_EVENT = 2
const val ACTION_TYPE_JS_FUNCTION_OLD_API = 3
const val ACTION_TYPE_JS_FUNCTION_NEW_API = 4
const val POINTER_TYPE_TOUCH = 0
const val POINTER_TYPE_STYLUS = 1
const val POINTER_TYPE_MOUSE = 2
const val POINTER_TYPE_OTHER = 3
private const val MAX_POINTERS_COUNT = 12
private lateinit var pointerProps: Array<PointerProperties?>
private lateinit var pointerCoords: Array<PointerCoords?>
private fun initPointerProps(size: Int) {
var pointerPropsSize = size
if (!Companion::pointerProps.isInitialized) {
pointerProps = arrayOfNulls(MAX_POINTERS_COUNT)
pointerCoords = arrayOfNulls(MAX_POINTERS_COUNT)
}
while (pointerPropsSize > 0 && pointerProps[pointerPropsSize - 1] == null) {
pointerProps[pointerPropsSize - 1] = PointerProperties()
pointerCoords[pointerPropsSize - 1] = PointerCoords()
pointerPropsSize--
}
}
private var nextEventCoalescingKey: Short = 0
private fun hitSlopSet(value: Float): Boolean {
return !java.lang.Float.isNaN(value)
}
fun stateToString(state: Int): String? {
when (state) {
STATE_UNDETERMINED -> return "UNDETERMINED"
STATE_ACTIVE -> return "ACTIVE"
STATE_FAILED -> return "FAILED"
STATE_BEGAN -> return "BEGIN"
STATE_CANCELLED -> return "CANCELLED"
STATE_END -> return "END"
}
return null
}
}
private data class PointerData(
val pointerId: Int,
var x: Float,
var y: Float,
var absoluteX: Float,
var absoluteY: Float
)
}

View File

@@ -0,0 +1,8 @@
package com.swmansion.gesturehandler.core
interface GestureHandlerInteractionController {
fun shouldWaitForHandlerFailure(handler: GestureHandler<*>, otherHandler: GestureHandler<*>): Boolean
fun shouldRequireHandlerToWaitForFailure(handler: GestureHandler<*>, otherHandler: GestureHandler<*>): Boolean
fun shouldRecognizeSimultaneously(handler: GestureHandler<*>, otherHandler: GestureHandler<*>): Boolean
fun shouldHandlerBeCancelledBy(handler: GestureHandler<*>, otherHandler: GestureHandler<*>): Boolean
}

View File

@@ -0,0 +1,706 @@
package com.swmansion.gesturehandler.core
import android.graphics.Matrix
import android.graphics.PointF
import android.view.MotionEvent
import android.view.View
import android.view.ViewGroup
import android.widget.EditText
import java.util.*
import kotlin.collections.HashSet
class GestureHandlerOrchestrator(
private val wrapperView: ViewGroup,
private val handlerRegistry: GestureHandlerRegistry,
private val viewConfigHelper: ViewConfigurationHelper,
) {
/**
* Minimum alpha (value from 0 to 1) that should be set to a view so that it can be treated as a
* gesture target. E.g. if set to 0.1 then views that less than 10% opaque will be ignored when
* traversing view hierarchy and looking for gesture handlers.
*/
var minimumAlphaForTraversal = DEFAULT_MIN_ALPHA_FOR_TRAVERSAL
private val gestureHandlers = arrayListOf<GestureHandler<*>>()
private val awaitingHandlers = arrayListOf<GestureHandler<*>>()
private val preparedHandlers = arrayListOf<GestureHandler<*>>()
// In `onHandlerStateChange` method we iterate through `awaitingHandlers`, but calling `tryActivate` may modify this list.
// To avoid `ConcurrentModificationException` we iterate through copy. There is one more problem though - if handler was
// removed from `awaitingHandlers`, it was still present in copy of original list. This hashset helps us identify which handlers
// are really inside `awaitingHandlers`.
// `contains` method on HashSet has O(1) complexity, so calling it inside for loop won't result in O(n^2) (contrary to ArrayList)
private val awaitingHandlersTags = HashSet<Int>()
private var isHandlingTouch = false
private var handlingChangeSemaphore = 0
private var finishedHandlersCleanupScheduled = false
private var activationIndex = 0
/**
* Should be called from the view wrapper
*/
fun onTouchEvent(event: MotionEvent): Boolean {
isHandlingTouch = true
val action = event.actionMasked
if (action == MotionEvent.ACTION_DOWN || action == MotionEvent.ACTION_POINTER_DOWN || action == MotionEvent.ACTION_HOVER_MOVE) {
extractGestureHandlers(event)
} else if (action == MotionEvent.ACTION_CANCEL) {
cancelAll()
}
deliverEventToGestureHandlers(event)
isHandlingTouch = false
if (finishedHandlersCleanupScheduled && handlingChangeSemaphore == 0) {
cleanupFinishedHandlers()
}
return true
}
fun getHandlersForView(view: View) = handlerRegistry.getHandlersForView(view)
private fun scheduleFinishedHandlersCleanup() {
if (isHandlingTouch || handlingChangeSemaphore != 0) {
finishedHandlersCleanupScheduled = true
} else {
cleanupFinishedHandlers()
}
}
private fun cleanupFinishedHandlers() {
for (handler in gestureHandlers.asReversed()) {
if (isFinished(handler.state) && !handler.isAwaiting) {
handler.reset()
handler.apply {
isActive = false
isAwaiting = false
activationIndex = Int.MAX_VALUE
}
}
}
gestureHandlers.removeAll { isFinished(it.state) && !it.isAwaiting }
finishedHandlersCleanupScheduled = false
}
private fun hasOtherHandlerToWaitFor(handler: GestureHandler<*>) =
gestureHandlers.any { !isFinished(it.state) && shouldHandlerWaitForOther(handler, it) }
private fun shouldBeCancelledByFinishedHandler(handler: GestureHandler<*>) = gestureHandlers.any { shouldHandlerWaitForOther(handler, it) && it.state == GestureHandler.STATE_END }
private fun tryActivate(handler: GestureHandler<*>) {
// If we are waiting for a gesture that has successfully finished, we should cancel handler
if (shouldBeCancelledByFinishedHandler(handler)) {
handler.cancel()
return
}
// see if there is anyone else who we need to wait for
if (hasOtherHandlerToWaitFor(handler)) {
addAwaitingHandler(handler)
} else {
// we can activate handler right away
makeActive(handler)
handler.isAwaiting = false
}
}
private fun cleanupAwaitingHandlers() {
val awaitingHandlersCopy = awaitingHandlers.toList()
for (handler in awaitingHandlersCopy) {
if (!handler.isAwaiting) {
awaitingHandlers.remove(handler)
awaitingHandlersTags.remove(handler.tag)
}
}
}
/*package*/
fun onHandlerStateChange(handler: GestureHandler<*>, newState: Int, prevState: Int) {
handlingChangeSemaphore += 1
if (isFinished(newState)) {
// We have to loop through copy in order to avoid modifying collection
// while iterating over its elements
val currentlyAwaitingHandlers = awaitingHandlers.toList()
// if there were handlers awaiting completion of this handler, we can trigger active state
for (otherHandler in currentlyAwaitingHandlers) {
if (!shouldHandlerWaitForOther(otherHandler, handler) || !awaitingHandlersTags.contains(otherHandler.tag)) {
continue
}
if (newState == GestureHandler.STATE_END) {
// gesture has ended, we need to kill the awaiting handler
otherHandler.cancel()
if (otherHandler.state == GestureHandler.STATE_END) {
// Handle edge case, where discrete gestures end immediately after activation thus
// their state is set to END and when the gesture they are waiting for activates they
// should be cancelled, however `cancel` was never sent as gestures were already in the END state.
// Send synthetic BEGAN -> CANCELLED to properly handle JS logic
otherHandler.dispatchStateChange(
GestureHandler.STATE_CANCELLED,
GestureHandler.STATE_BEGAN
)
}
otherHandler.isAwaiting = false
} else {
// gesture has failed recognition, we may try activating
tryActivate(otherHandler)
}
}
cleanupAwaitingHandlers()
}
if (newState == GestureHandler.STATE_ACTIVE) {
tryActivate(handler)
} else if (prevState == GestureHandler.STATE_ACTIVE || prevState == GestureHandler.STATE_END) {
if (handler.isActive) {
handler.dispatchStateChange(newState, prevState)
} else if (prevState == GestureHandler.STATE_ACTIVE && (newState == GestureHandler.STATE_CANCELLED || newState == GestureHandler.STATE_FAILED)) {
// Handle edge case where handler awaiting for another one tries to activate but finishes
// before the other would not send state change event upon ending. Note that we only want
// to do this if the newState is either CANCELLED or FAILED, if it is END we still want to
// wait for the other handler to finish as in that case synthetic events will be sent by the
// makeActive method.
handler.dispatchStateChange(newState, GestureHandler.STATE_BEGAN)
}
} else if (prevState != GestureHandler.STATE_UNDETERMINED || newState != GestureHandler.STATE_CANCELLED) {
// If handler is changing state from UNDETERMINED to CANCELLED, the state change event shouldn't
// be sent. Handler hasn't yet began so it may not be initialized which results in crashes.
// If it doesn't crash, there may be some weird behavior on JS side, as `onFinalize` will be
// called without calling `onBegin` first.
handler.dispatchStateChange(newState, prevState)
}
handlingChangeSemaphore -= 1
scheduleFinishedHandlersCleanup()
}
private fun makeActive(handler: GestureHandler<*>) {
val currentState = handler.state
with(handler) {
isAwaiting = false
isActive = true
shouldResetProgress = true
activationIndex = this@GestureHandlerOrchestrator.activationIndex++
}
for (otherHandler in gestureHandlers.asReversed()) {
if (shouldHandlerBeCancelledBy(otherHandler, handler)) {
otherHandler.cancel()
}
}
// Clear all awaiting handlers waiting for the current handler to fail
for (otherHandler in awaitingHandlers.reversed()) {
if (shouldHandlerBeCancelledBy(otherHandler, handler)) {
otherHandler.isAwaiting = false
}
}
cleanupAwaitingHandlers()
// At this point the waiting handler is allowed to activate, so we need to send BEGAN -> ACTIVE event
// as it wasn't sent before. If handler has finished recognizing the gesture before it was allowed to
// activate, we also need to send ACTIVE -> END and END -> UNDETERMINED events, as it was blocked from
// sending events while waiting.
// There is one catch though - if the handler failed or was cancelled while waiting, relevant event has
// already been sent. The following chain would result in artificially activating that handler after the
// failure logic was ran and we don't want to do that.
if (currentState == GestureHandler.STATE_FAILED || currentState == GestureHandler.STATE_CANCELLED) {
return
}
handler.dispatchStateChange(GestureHandler.STATE_ACTIVE, GestureHandler.STATE_BEGAN)
if (currentState != GestureHandler.STATE_ACTIVE) {
handler.dispatchStateChange(GestureHandler.STATE_END, GestureHandler.STATE_ACTIVE)
if (currentState != GestureHandler.STATE_END) {
handler.dispatchStateChange(GestureHandler.STATE_UNDETERMINED, GestureHandler.STATE_END)
}
}
}
private fun deliverEventToGestureHandlers(event: MotionEvent) {
// Copy handlers to "prepared handlers" array, because the list of active handlers can change
// as a result of state updates
preparedHandlers.clear()
preparedHandlers.addAll(gestureHandlers)
// We want to deliver events to active handlers first in order of their activation (handlers
// that activated first will first get event delivered). Otherwise we deliver events in the
// order in which handlers has been added ("most direct" children goes first). Therefore we rely
// on Arrays.sort providing a stable sort (as children are registered in order in which they
// should be tested)
preparedHandlers.sortWith(handlersComparator)
for (handler in preparedHandlers) {
deliverEventToGestureHandler(handler, event)
}
}
private fun cancelAll() {
for (handler in awaitingHandlers.reversed()) {
handler.cancel()
}
// Copy handlers to "prepared handlers" array, because the list of active handlers can change
// as a result of state updates
preparedHandlers.clear()
preparedHandlers.addAll(gestureHandlers)
for (handler in gestureHandlers.reversed()) {
handler.cancel()
}
}
private fun deliverEventToGestureHandler(handler: GestureHandler<*>, sourceEvent: MotionEvent) {
if (!isViewAttachedUnderWrapper(handler.view)) {
handler.cancel()
return
}
if (!handler.wantEvents()) {
return
}
val action = sourceEvent.actionMasked
val event = transformEventToViewCoords(handler.view, MotionEvent.obtain(sourceEvent))
// Touch events are sent before the handler itself has a chance to process them,
// mainly because `onTouchesUp` shoul be send befor gesture finishes. This means that
// the first `onTouchesDown` event is sent before a gesture begins, activation in
// callback for this event causes problems because the handler doesn't have a chance
// to initialize itself with starting values of pointer (in pan this causes translation
// to be equal to the coordinates of the pointer). The simplest solution is to send
// the first `onTouchesDown` event after the handler processes it and changes state
// to `BEGAN`.
if (handler.needsPointerData && handler.state != 0) {
handler.updatePointerData(event)
}
if (!handler.isAwaiting || action != MotionEvent.ACTION_MOVE) {
val isFirstEvent = handler.state == 0
handler.handle(event, sourceEvent)
if (handler.isActive) {
// After handler is done waiting for other one to fail its progress should be
// reset, otherwise there may be a visible jump in values sent by the handler.
// When handler is waiting it's already activated but the `isAwaiting` flag
// prevents it from receiving touch stream. When the flag is changed, the
// difference between this event and the last one may be large enough to be
// visible in interactions based on this gesture. This makes it consistent with
// the behavior on iOS.
if (handler.shouldResetProgress) {
handler.shouldResetProgress = false
handler.resetProgress()
}
handler.dispatchHandlerUpdate(event)
}
if (handler.needsPointerData && isFirstEvent) {
handler.updatePointerData(event)
}
// if event was of type UP or POINTER_UP we request handler to stop tracking now that
// the event has been dispatched
if (action == MotionEvent.ACTION_UP || action == MotionEvent.ACTION_POINTER_UP || action == MotionEvent.ACTION_HOVER_EXIT) {
val pointerId = event.getPointerId(event.actionIndex)
handler.stopTrackingPointer(pointerId)
}
}
event.recycle()
}
/**
* isViewAttachedUnderWrapper checks whether all of parents for view related to handler
* view are attached. Since there might be an issue rarely observed when view
* has been detached and handler's state hasn't been change to canceled, failed or
* ended yet. Probably it's a result of some race condition and stopping delivering
* for this handler and changing its state to failed of end appear to be good enough solution.
*/
private fun isViewAttachedUnderWrapper(view: View?): Boolean {
if (view == null) {
return false
}
if (view === wrapperView) {
return true
}
var parent = view.parent
while (parent != null && parent !== wrapperView) {
parent = parent.parent
}
return parent === wrapperView
}
fun isAnyHandlerActive() = gestureHandlers.any { it.state == GestureHandler.STATE_ACTIVE }
/**
* Transforms an event in the coordinates of wrapperView into the coordinate space of the received view.
*
* This modifies and returns the same event as it receives
*
* @param view - view to which coordinate space the event should be transformed
* @param event - event to transform
*/
fun transformEventToViewCoords(view: View?, event: MotionEvent): MotionEvent {
if (view == null) {
return event
}
val parent = view.parent as? ViewGroup
// Events are passed down to the orchestrator by the wrapperView, so they are already in the
// relevant coordinate space. We want to stop traversing the tree when we reach it.
if (parent != wrapperView) {
transformEventToViewCoords(parent, event)
}
if (parent != null) {
val localX = event.x + parent.scrollX - view.left
val localY = event.y + parent.scrollY - view.top
event.setLocation(localX, localY)
}
if (!view.matrix.isIdentity) {
view.matrix.invert(inverseMatrix)
event.transform(inverseMatrix)
}
return event
}
/**
* Transforms a point in the coordinates of wrapperView into the coordinate space of the received view.
*
* This modifies and returns the same point as it receives
*
* @param view - view to which coordinate space the point should be transformed
* @param point - point to transform
*/
fun transformPointToViewCoords(view: View?, point: PointF): PointF {
if (view == null) {
return point
}
val parent = view.parent as? ViewGroup
// Events are passed down to the orchestrator by the wrapperView, so they are already in the
// relevant coordinate space. We want to stop traversing the tree when we reach it.
if (parent != wrapperView) {
transformPointToViewCoords(parent, point)
}
if (parent != null) {
point.x += parent.scrollX - view.left
point.y += parent.scrollY - view.top
}
if (!view.matrix.isIdentity) {
view.matrix.invert(inverseMatrix)
tempCoords[0] = point.x
tempCoords[1] = point.y
inverseMatrix.mapPoints(tempCoords)
point.x = tempCoords[0]
point.y = tempCoords[1]
}
return point
}
private fun addAwaitingHandler(handler: GestureHandler<*>) {
if (awaitingHandlers.contains(handler)) {
return
}
awaitingHandlers.add(handler)
awaitingHandlersTags.add(handler.tag)
with(handler) {
isAwaiting = true
activationIndex = this@GestureHandlerOrchestrator.activationIndex++
}
}
private fun recordHandlerIfNotPresent(handler: GestureHandler<*>, view: View) {
if (gestureHandlers.contains(handler)) {
return
}
gestureHandlers.add(handler)
handler.isActive = false
handler.isAwaiting = false
handler.activationIndex = Int.MAX_VALUE
handler.prepare(view, this)
}
private fun isViewOverflowingParent(view: View): Boolean {
val parent = view.parent as? ViewGroup ?: return false
val matrix = view.matrix
val localXY = matrixTransformCoords
localXY[0] = 0f
localXY[1] = 0f
matrix.mapPoints(localXY)
val left = localXY[0] + view.left
val top = localXY[1] + view.top
return left < 0f || left + view.width > parent.width || top < 0f || top + view.height > parent.height
}
private fun extractAncestorHandlers(view: View, coords: FloatArray, pointerId: Int): Boolean {
var found = false
var parent = view.parent
while (parent != null) {
if (parent is ViewGroup) {
val parentViewGroup: ViewGroup = parent
handlerRegistry.getHandlersForView(parent)?.let {
synchronized(it) {
for (handler in it) {
if (handler.isEnabled && handler.isWithinBounds(view, coords[0], coords[1])) {
found = true
recordHandlerIfNotPresent(handler, parentViewGroup)
handler.startTrackingPointer(pointerId)
}
}
}
}
}
parent = parent.parent
}
return found
}
private fun recordViewHandlersForPointer(view: View, coords: FloatArray, pointerId: Int, event: MotionEvent): Boolean {
var found = false
handlerRegistry.getHandlersForView(view)?.let {
synchronized(it) {
for (handler in it) {
// skip disabled and out-of-bounds handlers
if (!handler.isEnabled || !handler.isWithinBounds(view, coords[0], coords[1])) {
continue
}
// we don't want to extract gestures other than hover when processing hover events
if (event.action in listOf(MotionEvent.ACTION_HOVER_EXIT, MotionEvent.ACTION_HOVER_ENTER, MotionEvent.ACTION_HOVER_MOVE) && handler !is HoverGestureHandler) {
continue
}
recordHandlerIfNotPresent(handler, view)
handler.startTrackingPointer(pointerId)
found = true
}
}
}
// if the pointer is inside the view but it overflows its parent, handlers attached to the parent
// might not have been extracted (pointer might be in a child, but may be outside parent)
if (coords[0] in 0f..view.width.toFloat() && coords[1] in 0f..view.height.toFloat() &&
isViewOverflowingParent(view) && extractAncestorHandlers(view, coords, pointerId)
) {
found = true
}
return found
}
private fun extractGestureHandlers(event: MotionEvent) {
val actionIndex = event.actionIndex
val pointerId = event.getPointerId(actionIndex)
tempCoords[0] = event.getX(actionIndex)
tempCoords[1] = event.getY(actionIndex)
traverseWithPointerEvents(wrapperView, tempCoords, pointerId, event)
extractGestureHandlers(wrapperView, tempCoords, pointerId, event)
}
private fun extractGestureHandlers(viewGroup: ViewGroup, coords: FloatArray, pointerId: Int, event: MotionEvent): Boolean {
val childrenCount = viewGroup.childCount
for (i in childrenCount - 1 downTo 0) {
val child = viewConfigHelper.getChildInDrawingOrderAtIndex(viewGroup, i)
if (canReceiveEvents(child)) {
val childPoint = tempPoint
transformPointToChildViewCoords(coords[0], coords[1], viewGroup, child, childPoint)
val restoreX = coords[0]
val restoreY = coords[1]
coords[0] = childPoint.x
coords[1] = childPoint.y
var found = false
if (!isClipping(child) || isTransformedTouchPointInView(coords[0], coords[1], child)) {
// we only consider the view if touch is inside the view bounds or if the view's children
// can render outside of the view bounds (overflow visible)
found = traverseWithPointerEvents(child, coords, pointerId, event)
}
coords[0] = restoreX
coords[1] = restoreY
if (found) {
return true
}
}
}
return false
}
private fun traverseWithPointerEvents(view: View, coords: FloatArray, pointerId: Int, event: MotionEvent): Boolean =
when (viewConfigHelper.getPointerEventsConfigForView(view)) {
PointerEventsConfig.NONE -> {
// This view and its children can't be the target
false
}
PointerEventsConfig.BOX_ONLY -> {
// This view is the target, its children don't matter
(
recordViewHandlersForPointer(view, coords, pointerId, event) ||
shouldHandlerlessViewBecomeTouchTarget(view, coords)
)
}
PointerEventsConfig.BOX_NONE -> {
// This view can't be the target, but its children might
when (view) {
is ViewGroup -> {
extractGestureHandlers(view, coords, pointerId, event).also { found ->
// A child view is handling touch, also extract handlers attached to this view
if (found) {
recordViewHandlersForPointer(view, coords, pointerId, event)
}
}
}
// When <TextInput> has editable set to `false` getPointerEventsConfigForView returns
// `BOX_NONE` as it's `isEnabled` property is false. In this case we still want to extract
// handlers attached to the text input, as it makes sense that gestures would work on a
// non-editable TextInput.
is EditText -> {
recordViewHandlersForPointer(view, coords, pointerId, event)
}
else -> false
}
}
PointerEventsConfig.AUTO -> {
// Either this view or one of its children is the target
val found = if (view is ViewGroup) {
extractGestureHandlers(view, coords, pointerId, event)
} else false
(
recordViewHandlersForPointer(view, coords, pointerId, event) ||
found || shouldHandlerlessViewBecomeTouchTarget(view, coords)
)
}
}
private fun canReceiveEvents(view: View) =
view.visibility == View.VISIBLE && view.alpha >= minimumAlphaForTraversal
// if view is not a view group it is clipping, otherwise we check for `getClipChildren` flag to
// be turned on and also confirm with the ViewConfigHelper implementation
private fun isClipping(view: View) =
view !is ViewGroup || viewConfigHelper.isViewClippingChildren(view)
fun activateNativeHandlersForView(view: View) {
handlerRegistry.getHandlersForView(view)?.forEach {
if (it !is NativeViewGestureHandler) {
return@forEach
}
this.recordHandlerIfNotPresent(it, view)
it.withMarkedAsInBounds {
it.begin()
it.activate()
it.end()
}
}
}
companion object {
// The limit doesn't necessarily need to exists, it was just simpler to implement it that way
// it is also more allocation-wise efficient to have a fixed limit
// Be default fully transparent views can receive touch
private const val DEFAULT_MIN_ALPHA_FOR_TRAVERSAL = 0f
private val tempPoint = PointF()
private val matrixTransformCoords = FloatArray(2)
private val inverseMatrix = Matrix()
private val tempCoords = FloatArray(2)
private val handlersComparator = Comparator<GestureHandler<*>?> { a, b ->
return@Comparator if (a.isActive && b.isActive || a.isAwaiting && b.isAwaiting) {
// both A and B are either active or awaiting activation, in which case we prefer one that
// has activated (or turned into "awaiting" state) earlier
Integer.signum(b.activationIndex - a.activationIndex)
} else if (a.isActive) {
-1 // only A is active
} else if (b.isActive) {
1 // only B is active
} else if (a.isAwaiting) {
-1 // only A is awaiting, B is inactive
} else if (b.isAwaiting) {
1 // only B is awaiting, A is inactive
} else {
0 // both A and B are inactive, stable order matters
}
}
private fun shouldHandlerlessViewBecomeTouchTarget(view: View, coords: FloatArray): Boolean {
// The following code is to match the iOS behavior where transparent parts of the views can
// pass touch events through them allowing sibling nodes to handle them.
// TODO: this is not an ideal solution as we only consider ViewGroups that has no background set
// TODO: ideally we should determine the pixel color under the given coordinates and return
// false if the color is transparent
val isLeafOrTransparent = view !is ViewGroup || view.getBackground() != null
return isLeafOrTransparent && isTransformedTouchPointInView(coords[0], coords[1], view)
}
private fun transformPointToChildViewCoords(
x: Float,
y: Float,
parent: ViewGroup,
child: View,
outLocalPoint: PointF,
) {
var localX = x + parent.scrollX - child.left
var localY = y + parent.scrollY - child.top
val matrix = child.matrix
if (!matrix.isIdentity) {
val localXY = matrixTransformCoords
localXY[0] = localX
localXY[1] = localY
matrix.invert(inverseMatrix)
inverseMatrix.mapPoints(localXY)
localX = localXY[0]
localY = localXY[1]
}
outLocalPoint[localX] = localY
}
private fun isTransformedTouchPointInView(x: Float, y: Float, child: View) =
x in 0f..child.width.toFloat() && y in 0f..child.height.toFloat()
private fun shouldHandlerWaitForOther(handler: GestureHandler<*>, other: GestureHandler<*>): Boolean {
return handler !== other && (
handler.shouldWaitForHandlerFailure(other) ||
other.shouldRequireToWaitForFailure(handler)
)
}
private fun canRunSimultaneously(a: GestureHandler<*>, b: GestureHandler<*>) =
a === b || a.shouldRecognizeSimultaneously(b) || b.shouldRecognizeSimultaneously(a)
private fun shouldHandlerBeCancelledBy(handler: GestureHandler<*>, other: GestureHandler<*>): Boolean {
if (!handler.hasCommonPointers(other)) {
// if two handlers share no common pointer one can never trigger cancel for the other
return false
}
if (canRunSimultaneously(handler, other)) {
// if handlers are allowed to run simultaneously, when first activates second can still remain
// in began state
return false
}
return if (handler !== other &&
(handler.isAwaiting || handler.state == GestureHandler.STATE_ACTIVE)
) {
// in every other case as long as the handler is about to be activated or already in active
// state, we delegate the decision to the implementation of GestureHandler#shouldBeCancelledBy
handler.shouldBeCancelledBy(other)
} else true
}
private fun isFinished(state: Int) =
state == GestureHandler.STATE_CANCELLED ||
state == GestureHandler.STATE_FAILED ||
state == GestureHandler.STATE_END
}
}

View File

@@ -0,0 +1,8 @@
package com.swmansion.gesturehandler.core
import android.view.View
import java.util.*
interface GestureHandlerRegistry {
fun getHandlersForView(view: View): ArrayList<GestureHandler<*>>?
}

View File

@@ -0,0 +1,50 @@
package com.swmansion.gesturehandler.core
import android.view.MotionEvent
import kotlin.math.cos
object GestureUtils {
fun getLastPointerX(event: MotionEvent, averageTouches: Boolean): Float {
val excludeIndex = if (event.actionMasked == MotionEvent.ACTION_POINTER_UP) event.actionIndex else -1
return if (averageTouches) {
var sum = 0f
var count = 0
for (i in 0 until event.pointerCount) {
if (i != excludeIndex) {
sum += event.getX(i)
count++
}
}
sum / count
} else {
var lastPointerIdx = event.pointerCount - 1
if (lastPointerIdx == excludeIndex) {
lastPointerIdx--
}
event.getX(lastPointerIdx)
}
}
fun getLastPointerY(event: MotionEvent, averageTouches: Boolean): Float {
val excludeIndex = if (event.actionMasked == MotionEvent.ACTION_POINTER_UP) event.actionIndex else -1
return if (averageTouches) {
var sum = 0f
var count = 0
for (i in 0 until event.pointerCount) {
if (i != excludeIndex) {
sum += event.getY(i)
count++
}
}
sum / count
} else {
var lastPointerIdx = event.pointerCount - 1
if (lastPointerIdx == excludeIndex) {
lastPointerIdx -= 1
}
event.getY(lastPointerIdx)
}
}
fun coneToDeviation(angle: Double): Double =
cos(Math.toRadians(angle / 2.0))
}

View File

@@ -0,0 +1,120 @@
package com.swmansion.gesturehandler.core
import android.os.Handler
import android.os.Looper
import android.view.MotionEvent
import android.view.View
import android.view.ViewGroup
import com.swmansion.gesturehandler.react.RNViewConfigurationHelper
class HoverGestureHandler : GestureHandler<HoverGestureHandler>() {
private var handler: Handler? = null
private var finishRunnable = Runnable { finish() }
private infix fun isAncestorOf(other: GestureHandler<*>): Boolean {
var current: View? = other.view
while (current != null) {
if (current == this.view) {
return true
}
current = current.parent as? View
}
return false
}
private fun isViewDisplayedOverAnother(view: View, other: View, rootView: View = view.rootView): Boolean? {
// traverse the tree starting on the root view, to see which view will be drawn first
if (rootView == other) {
return true
}
if (rootView == view) {
return false
}
if (rootView is ViewGroup) {
for (i in 0 until rootView.childCount) {
val child = viewConfigHelper.getChildInDrawingOrderAtIndex(rootView, i)
return isViewDisplayedOverAnother(view, other, child) ?: continue
}
}
return null
}
override fun shouldBeCancelledBy(handler: GestureHandler<*>): Boolean {
if (handler is HoverGestureHandler && !(handler isAncestorOf this)) {
return isViewDisplayedOverAnother(handler.view!!, this.view!!)!!
}
return super.shouldBeCancelledBy(handler)
}
override fun shouldRequireToWaitForFailure(handler: GestureHandler<*>): Boolean {
if (handler is HoverGestureHandler) {
if (!(this isAncestorOf handler) && !(handler isAncestorOf this)) {
isViewDisplayedOverAnother(this.view!!, handler.view!!)?.let {
return it
}
}
}
return super.shouldRequireToWaitForFailure(handler)
}
override fun shouldRecognizeSimultaneously(handler: GestureHandler<*>): Boolean {
if (handler is HoverGestureHandler && (this isAncestorOf handler || handler isAncestorOf this)) {
return true
}
return super.shouldRecognizeSimultaneously(handler)
}
override fun onHandle(event: MotionEvent, sourceEvent: MotionEvent) {
if (event.action == MotionEvent.ACTION_DOWN) {
handler?.removeCallbacksAndMessages(null)
handler = null
} else if (event.action == MotionEvent.ACTION_UP) {
if (!isWithinBounds) {
finish()
}
}
}
override fun onHandleHover(event: MotionEvent, sourceEvent: MotionEvent) {
when {
event.action == MotionEvent.ACTION_HOVER_EXIT -> {
if (handler == null) {
handler = Handler(Looper.getMainLooper())
}
handler!!.postDelayed(finishRunnable, 4)
}
!isWithinBounds -> {
finish()
}
this.state == STATE_UNDETERMINED &&
(event.action == MotionEvent.ACTION_HOVER_MOVE || event.action == MotionEvent.ACTION_HOVER_ENTER) -> {
begin()
activate()
}
}
}
private fun finish() {
when (this.state) {
STATE_UNDETERMINED -> cancel()
STATE_BEGAN -> fail()
STATE_ACTIVE -> end()
}
}
companion object {
private val viewConfigHelper = RNViewConfigurationHelper()
}
}

View File

@@ -0,0 +1,104 @@
package com.swmansion.gesturehandler.core
import android.content.Context
import android.os.Handler
import android.os.Looper
import android.os.SystemClock
import android.view.MotionEvent
class LongPressGestureHandler(context: Context) : GestureHandler<LongPressGestureHandler>() {
var minDurationMs = DEFAULT_MIN_DURATION_MS
val duration: Int
get() = (previousTime - startTime).toInt()
private val defaultMaxDistSq: Float
private var maxDistSq: Float
private var startX = 0f
private var startY = 0f
private var startTime: Long = 0
private var previousTime: Long = 0
private var handler: Handler? = null
init {
setShouldCancelWhenOutside(true)
val defaultMaxDist = DEFAULT_MAX_DIST_DP * context.resources.displayMetrics.density
defaultMaxDistSq = defaultMaxDist * defaultMaxDist
maxDistSq = defaultMaxDistSq
}
override fun resetConfig() {
super.resetConfig()
minDurationMs = DEFAULT_MIN_DURATION_MS
maxDistSq = defaultMaxDistSq
}
fun setMaxDist(maxDist: Float): LongPressGestureHandler {
maxDistSq = maxDist * maxDist
return this
}
override fun onHandle(event: MotionEvent, sourceEvent: MotionEvent) {
if (!shouldActivateWithMouse(sourceEvent)) {
return
}
if (state == STATE_UNDETERMINED) {
previousTime = SystemClock.uptimeMillis()
startTime = previousTime
begin()
startX = sourceEvent.rawX
startY = sourceEvent.rawY
handler = Handler(Looper.getMainLooper())
if (minDurationMs > 0) {
handler!!.postDelayed({ activate() }, minDurationMs)
} else if (minDurationMs == 0L) {
activate()
}
}
if (sourceEvent.actionMasked == MotionEvent.ACTION_UP || sourceEvent.actionMasked == MotionEvent.ACTION_BUTTON_RELEASE) {
handler?.let {
it.removeCallbacksAndMessages(null)
handler = null
}
if (state == STATE_ACTIVE) {
end()
} else {
fail()
}
} else {
// calculate distance from start
val deltaX = sourceEvent.rawX - startX
val deltaY = sourceEvent.rawY - startY
val distSq = deltaX * deltaX + deltaY * deltaY
if (distSq > maxDistSq) {
if (state == STATE_ACTIVE) {
cancel()
} else {
fail()
}
}
}
}
override fun onStateChange(newState: Int, previousState: Int) {
handler?.let {
it.removeCallbacksAndMessages(null)
handler = null
}
}
override fun dispatchStateChange(newState: Int, prevState: Int) {
previousTime = SystemClock.uptimeMillis()
super.dispatchStateChange(newState, prevState)
}
override fun dispatchHandlerUpdate(event: MotionEvent) {
previousTime = SystemClock.uptimeMillis()
super.dispatchHandlerUpdate(event)
}
companion object {
private const val DEFAULT_MIN_DURATION_MS: Long = 500
private const val DEFAULT_MAX_DIST_DP = 10f
}
}

View File

@@ -0,0 +1,11 @@
package com.swmansion.gesturehandler.core
import android.view.MotionEvent
class ManualGestureHandler : GestureHandler<ManualGestureHandler>() {
override fun onHandle(event: MotionEvent, sourceEvent: MotionEvent) {
if (state == STATE_UNDETERMINED) {
begin()
}
}
}

View File

@@ -0,0 +1,257 @@
package com.swmansion.gesturehandler.core
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.views.scroll.ReactScrollView
import com.facebook.react.views.swiperefresh.ReactSwipeRefreshLayout
import com.facebook.react.views.textinput.ReactEditText
class NativeViewGestureHandler : GestureHandler<NativeViewGestureHandler>() {
private var shouldActivateOnStart = false
var disallowInterruption = false
private set
private var hook: NativeViewGestureHandlerHook = defaultHook
init {
setShouldCancelWhenOutside(true)
}
override fun resetConfig() {
super.resetConfig()
shouldActivateOnStart = false
disallowInterruption = false
}
fun setShouldActivateOnStart(shouldActivateOnStart: Boolean) = apply {
this.shouldActivateOnStart = shouldActivateOnStart
}
/**
* 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.
*/
fun setDisallowInterruption(disallowInterruption: Boolean) = apply {
this.disallowInterruption = disallowInterruption
}
override fun shouldRecognizeSimultaneously(handler: GestureHandler<*>): Boolean {
// if the gesture is marked by user as simultaneous with other or the hook return true
if (super.shouldRecognizeSimultaneously(handler) || hook.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) {
// other handler is active and it disallows interruption, we don't want to get into its way
return false
}
}
val canBeInterrupted = !disallowInterruption
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 {
return !disallowInterruption
}
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()
}
}
override fun onHandle(event: MotionEvent, sourceEvent: MotionEvent) {
val view = view!!
if (event.actionMasked == MotionEvent.ACTION_UP) {
view.onTouchEvent(event)
if ((state == STATE_UNDETERMINED || state == STATE_BEGAN) && view.isPressed) {
activate()
}
end()
hook.afterGestureEnd(event)
} else if (state == STATE_UNDETERMINED || state == STATE_BEGAN) {
when {
shouldActivateOnStart -> {
tryIntercept(view, event)
view.onTouchEvent(event)
activate()
}
tryIntercept(view, event) -> {
view.onTouchEvent(event)
activate()
}
hook.wantsToHandleEventBeforeActivation() -> {
hook.handleEventBeforeActivation(event)
}
state != STATE_BEGAN -> {
if (hook.canBegin()) {
begin()
} else {
cancel()
}
}
}
} else if (state == STATE_ACTIVE) {
view.onTouchEvent(event)
}
}
override fun onCancel() {
val time = SystemClock.uptimeMillis()
val event = MotionEvent.obtain(time, time, MotionEvent.ACTION_CANCEL, 0f, 0f, 0).apply {
action = MotionEvent.ACTION_CANCEL
}
view!!.onTouchEvent(event)
event.recycle()
}
override fun onReset() {
this.hook = defaultHook
}
companion object {
private fun tryIntercept(view: View, event: MotionEvent) =
view is ViewGroup && view.onInterceptTouchEvent(event)
private val defaultHook = object : NativeViewGestureHandlerHook {}
}
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() = true
/**
* 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<*>) = false
/**
* 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
}
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, which is a private inner class
// of RNGestureHandlerRootHelper so no explicit type checks, but its tag is always negative
// also if other handler is NativeViewGestureHandler then don't override the default implementation
override fun shouldRecognizeSimultaneously(handler: GestureHandler<*>) =
handler.tag > 0 && handler !is NativeViewGestureHandler
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 {
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
}
// If handler was found, it's active and the ScrollView is not at the top, fail the RefreshControl
if (scrollHandler != null && scrollHandler.state == STATE_ACTIVE && scroll.scrollY > 0) {
handler.fail()
}
// 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
}
}

View File

@@ -0,0 +1,9 @@
package com.swmansion.gesturehandler.core
import android.view.MotionEvent
interface OnTouchEventListener {
fun <T : GestureHandler<T>> onHandlerUpdate(handler: T, event: MotionEvent)
fun <T : GestureHandler<T>> onStateChange(handler: T, newState: Int, oldState: Int)
fun <T : GestureHandler<T>> onTouchEvent(handler: T)
}

View File

@@ -0,0 +1,326 @@
package com.swmansion.gesturehandler.core
import android.content.Context
import android.os.Handler
import android.os.Looper
import android.view.MotionEvent
import android.view.VelocityTracker
import android.view.ViewConfiguration
import com.swmansion.gesturehandler.core.GestureUtils.getLastPointerX
import com.swmansion.gesturehandler.core.GestureUtils.getLastPointerY
class PanGestureHandler(context: Context?) : GestureHandler<PanGestureHandler>() {
var velocityX = 0f
private set
var velocityY = 0f
private set
val translationX: Float
get() = lastX - startX + offsetX
val translationY: Float
get() = lastY - startY + offsetY
private val defaultMinDistSq: Float
private var minDistSq = MAX_VALUE_IGNORE
private var activeOffsetXStart = MIN_VALUE_IGNORE
private var activeOffsetXEnd = MAX_VALUE_IGNORE
private var failOffsetXStart = MAX_VALUE_IGNORE
private var failOffsetXEnd = MIN_VALUE_IGNORE
private var activeOffsetYStart = MIN_VALUE_IGNORE
private var activeOffsetYEnd = MAX_VALUE_IGNORE
private var failOffsetYStart = MAX_VALUE_IGNORE
private var failOffsetYEnd = MIN_VALUE_IGNORE
private var minVelocityX = MIN_VALUE_IGNORE
private var minVelocityY = MIN_VALUE_IGNORE
private var minVelocitySq = MIN_VALUE_IGNORE
private var minPointers = DEFAULT_MIN_POINTERS
private var maxPointers = DEFAULT_MAX_POINTERS
private var startX = 0f
private var startY = 0f
private var offsetX = 0f
private var offsetY = 0f
private var lastX = 0f
private var lastY = 0f
private var velocityTracker: VelocityTracker? = null
private var averageTouches = false
private var activateAfterLongPress = DEFAULT_ACTIVATE_AFTER_LONG_PRESS
private val activateDelayed = Runnable { activate() }
private var handler: Handler? = null
/**
* On Android when there are multiple pointers on the screen pan gestures most often just consider
* the last placed pointer. The behaviour on iOS is quite different where the x and y component
* of the pan pointer is calculated as an average out of all the pointers placed on the screen.
*
* This behaviour can be customized on android by setting averageTouches property of the handler
* object. This could be useful in particular for the usecases when we attach other handlers that
* recognizes multi-finger gestures such as rotation. In that case when we only rely on the last
* placed finger it is easier for the gesture handler to trigger when we do a rotation gesture
* because each finger when treated separately will travel some distance, whereas the average
* position of all the fingers will remain still while doing a rotation gesture.
*/
init {
val vc = ViewConfiguration.get(context!!)
val touchSlop = vc.scaledTouchSlop
defaultMinDistSq = (touchSlop * touchSlop).toFloat()
minDistSq = defaultMinDistSq
}
override fun resetConfig() {
super.resetConfig()
activeOffsetXStart = MIN_VALUE_IGNORE
activeOffsetXEnd = MAX_VALUE_IGNORE
failOffsetXStart = MAX_VALUE_IGNORE
failOffsetXEnd = MIN_VALUE_IGNORE
activeOffsetYStart = MIN_VALUE_IGNORE
activeOffsetYEnd = MAX_VALUE_IGNORE
failOffsetYStart = MAX_VALUE_IGNORE
failOffsetYEnd = MIN_VALUE_IGNORE
minVelocityX = MIN_VALUE_IGNORE
minVelocityY = MIN_VALUE_IGNORE
minVelocitySq = MIN_VALUE_IGNORE
minDistSq = defaultMinDistSq
minPointers = DEFAULT_MIN_POINTERS
maxPointers = DEFAULT_MAX_POINTERS
activateAfterLongPress = DEFAULT_ACTIVATE_AFTER_LONG_PRESS
averageTouches = false
}
fun setActiveOffsetXStart(activeOffsetXStart: Float) = apply {
this.activeOffsetXStart = activeOffsetXStart
}
fun setActiveOffsetXEnd(activeOffsetXEnd: Float) = apply {
this.activeOffsetXEnd = activeOffsetXEnd
}
fun setFailOffsetXStart(failOffsetXStart: Float) = apply {
this.failOffsetXStart = failOffsetXStart
}
fun setFailOffsetXEnd(failOffsetXEnd: Float) = apply {
this.failOffsetXEnd = failOffsetXEnd
}
fun setActiveOffsetYStart(activeOffsetYStart: Float) = apply {
this.activeOffsetYStart = activeOffsetYStart
}
fun setActiveOffsetYEnd(activeOffsetYEnd: Float) = apply {
this.activeOffsetYEnd = activeOffsetYEnd
}
fun setFailOffsetYStart(failOffsetYStart: Float) = apply {
this.failOffsetYStart = failOffsetYStart
}
fun setFailOffsetYEnd(failOffsetYEnd: Float) = apply {
this.failOffsetYEnd = failOffsetYEnd
}
fun setMinDist(minDist: Float) = apply {
minDistSq = minDist * minDist
}
fun setMinPointers(minPointers: Int) = apply {
this.minPointers = minPointers
}
fun setMaxPointers(maxPointers: Int) = apply {
this.maxPointers = maxPointers
}
fun setAverageTouches(averageTouches: Boolean) = apply {
this.averageTouches = averageTouches
}
fun setActivateAfterLongPress(time: Long) = apply {
this.activateAfterLongPress = time
}
/**
* @param minVelocity in pixels per second
*/
fun setMinVelocity(minVelocity: Float) = apply {
minVelocitySq = minVelocity * minVelocity
}
fun setMinVelocityX(minVelocityX: Float) = apply {
this.minVelocityX = minVelocityX
}
fun setMinVelocityY(minVelocityY: Float) = apply {
this.minVelocityY = minVelocityY
}
private fun shouldActivate(): Boolean {
val dx = lastX - startX + offsetX
if (activeOffsetXStart != MIN_VALUE_IGNORE && dx < activeOffsetXStart) {
return true
}
if (activeOffsetXEnd != MAX_VALUE_IGNORE && dx > activeOffsetXEnd) {
return true
}
val dy = lastY - startY + offsetY
if (activeOffsetYStart != MIN_VALUE_IGNORE && dy < activeOffsetYStart) {
return true
}
if (activeOffsetYEnd != MAX_VALUE_IGNORE && dy > activeOffsetYEnd) {
return true
}
val distSq = dx * dx + dy * dy
if (minDistSq != MIN_VALUE_IGNORE && distSq >= minDistSq) {
return true
}
val vx = velocityX
if (minVelocityX != MIN_VALUE_IGNORE &&
(minVelocityX < 0 && vx <= minVelocityX || minVelocityX in 0.0f..vx)
) {
return true
}
val vy = velocityY
if (minVelocityY != MIN_VALUE_IGNORE &&
(minVelocityY < 0 && vx <= minVelocityY || minVelocityY in 0.0f..vx)
) {
return true
}
val velocitySq = vx * vx + vy * vy
return minVelocitySq != MIN_VALUE_IGNORE && velocitySq >= minVelocitySq
}
private fun shouldFail(): Boolean {
val dx = lastX - startX + offsetX
val dy = lastY - startY + offsetY
if (activateAfterLongPress > 0 && dx * dx + dy * dy > defaultMinDistSq) {
handler?.removeCallbacksAndMessages(null)
return true
}
if (failOffsetXStart != MAX_VALUE_IGNORE && dx < failOffsetXStart) {
return true
}
if (failOffsetXEnd != MIN_VALUE_IGNORE && dx > failOffsetXEnd) {
return true
}
if (failOffsetYStart != MAX_VALUE_IGNORE && dy < failOffsetYStart) {
return true
}
return failOffsetYEnd != MIN_VALUE_IGNORE && dy > failOffsetYEnd
}
override fun onHandle(event: MotionEvent, sourceEvent: MotionEvent) {
if (!shouldActivateWithMouse(sourceEvent)) {
return
}
val state = state
val action = sourceEvent.actionMasked
if (action == MotionEvent.ACTION_POINTER_UP || action == MotionEvent.ACTION_POINTER_DOWN) {
// update offset if new pointer gets added or removed
offsetX += lastX - startX
offsetY += lastY - startY
// reset starting point
lastX = getLastPointerX(sourceEvent, averageTouches)
lastY = getLastPointerY(sourceEvent, averageTouches)
startX = lastX
startY = lastY
} else {
lastX = getLastPointerX(sourceEvent, averageTouches)
lastY = getLastPointerY(sourceEvent, averageTouches)
}
if (state == STATE_UNDETERMINED && sourceEvent.pointerCount >= minPointers) {
resetProgress()
offsetX = 0f
offsetY = 0f
velocityX = 0f
velocityY = 0f
velocityTracker = VelocityTracker.obtain()
addVelocityMovement(velocityTracker, sourceEvent)
begin()
if (activateAfterLongPress > 0) {
if (handler == null) {
handler = Handler(Looper.getMainLooper())
}
handler!!.postDelayed(activateDelayed, activateAfterLongPress)
}
} else if (velocityTracker != null) {
addVelocityMovement(velocityTracker, sourceEvent)
velocityTracker!!.computeCurrentVelocity(1000)
velocityX = velocityTracker!!.xVelocity
velocityY = velocityTracker!!.yVelocity
}
if (action == MotionEvent.ACTION_UP || action == MotionEvent.ACTION_BUTTON_RELEASE) {
if (state == STATE_ACTIVE) {
end()
} else {
fail()
}
} else if (action == MotionEvent.ACTION_POINTER_DOWN && sourceEvent.pointerCount > maxPointers) {
// When new finger is placed down (POINTER_DOWN) we check if MAX_POINTERS is not exceeded
if (state == STATE_ACTIVE) {
cancel()
} else {
fail()
}
} else if (action == MotionEvent.ACTION_POINTER_UP && state == STATE_ACTIVE && sourceEvent.pointerCount < minPointers) {
// When finger is lifted up (POINTER_UP) and the number of pointers falls below MIN_POINTERS
// threshold, we only want to take an action when the handler has already activated. Otherwise
// we can still expect more fingers to be placed on screen and fulfill MIN_POINTERS criteria.
fail()
} else if (state == STATE_BEGAN) {
if (shouldFail()) {
fail()
} else if (shouldActivate()) {
activate()
}
}
}
override fun activate(force: Boolean) {
// reset starting point if the handler has not yet activated
if (state != STATE_ACTIVE) {
resetProgress()
}
super.activate(force)
}
override fun onCancel() {
handler?.removeCallbacksAndMessages(null)
}
override fun onReset() {
handler?.removeCallbacksAndMessages(null)
velocityTracker?.let {
it.recycle()
velocityTracker = null
}
}
override fun resetProgress() {
startX = lastX
startY = lastY
}
companion object {
private const val MIN_VALUE_IGNORE = Float.MAX_VALUE
private const val MAX_VALUE_IGNORE = Float.MIN_VALUE
private const val DEFAULT_MIN_POINTERS = 1
private const val DEFAULT_MAX_POINTERS = 10
private const val DEFAULT_ACTIVATE_AFTER_LONG_PRESS = 0L
/**
* This method adds movement to {@class VelocityTracker} first resetting offset of the event so
* that the velocity is calculated based on the absolute position of touch pointers. This is
* because if the underlying view moves along with the finger using relative x/y coords yields
* incorrect results.
*/
private fun addVelocityMovement(tracker: VelocityTracker?, event: MotionEvent) {
val offsetX = event.rawX - event.x
val offsetY = event.rawY - event.y
event.offsetLocation(offsetX, offsetY)
tracker!!.addMovement(event)
event.offsetLocation(-offsetX, -offsetY)
}
}
}

View File

@@ -0,0 +1,103 @@
package com.swmansion.gesturehandler.core
import android.graphics.PointF
import android.view.MotionEvent
import android.view.ViewConfiguration
import kotlin.math.abs
class PinchGestureHandler : GestureHandler<PinchGestureHandler>() {
var scale = 0.0
private set
var velocity = 0.0
private set
var focalPointX: Float = Float.NaN
private set
var focalPointY: Float = Float.NaN
private set
private var scaleGestureDetector: ScaleGestureDetector? = null
private var startingSpan = 0f
private var spanSlop = 0f
private val gestureListener: ScaleGestureDetector.OnScaleGestureListener = object :
ScaleGestureDetector.OnScaleGestureListener {
override fun onScale(detector: ScaleGestureDetector): Boolean {
val prevScaleFactor: Double = scale
scale *= detector.scaleFactor.toDouble()
val delta = detector.timeDelta
if (delta > 0) {
velocity = (scale - prevScaleFactor) / delta
}
if (abs(startingSpan - detector.currentSpan) >= spanSlop &&
state == STATE_BEGAN
) {
activate()
}
return true
}
init {
setShouldCancelWhenOutside(false)
}
override fun onScaleBegin(detector: ScaleGestureDetector): Boolean {
startingSpan = detector.currentSpan
return true
}
override fun onScaleEnd(detector: ScaleGestureDetector) {
// ScaleGestureDetector thinks that when fingers are 27mm away that's a sufficiently good
// reason to trigger this method giving us no other choice but to ignore it completely.
}
}
override fun onHandle(event: MotionEvent, sourceEvent: MotionEvent) {
if (state == STATE_UNDETERMINED) {
val context = view!!.context
resetProgress()
scaleGestureDetector = ScaleGestureDetector(context, gestureListener)
val configuration = ViewConfiguration.get(context)
spanSlop = configuration.scaledTouchSlop.toFloat()
// set the focal point to the position of the first pointer as NaN causes the event not to arrive
this.focalPointX = event.x
this.focalPointY = event.y
begin()
}
scaleGestureDetector?.onTouchEvent(sourceEvent)
scaleGestureDetector?.let {
val point = transformPoint(PointF(it.focusX, it.focusY))
this.focalPointX = point.x
this.focalPointY = point.y
}
var activePointers = sourceEvent.pointerCount
if (sourceEvent.actionMasked == MotionEvent.ACTION_POINTER_UP) {
activePointers -= 1
}
if (state == STATE_ACTIVE && activePointers < 2) {
end()
} else if (sourceEvent.actionMasked == MotionEvent.ACTION_UP) {
fail()
}
}
override fun activate(force: Boolean) {
// reset scale if the handler has not yet activated
if (state != STATE_ACTIVE) {
resetProgress()
}
super.activate(force)
}
override fun onReset() {
scaleGestureDetector = null
focalPointX = Float.NaN
focalPointY = Float.NaN
resetProgress()
}
override fun resetProgress() {
velocity = 0.0
scale = 1.0
}
}

View File

@@ -0,0 +1,23 @@
package com.swmansion.gesturehandler.core
enum class PointerEventsConfig {
/**
* Neither the container nor its children receive events.
*/
NONE,
/**
* Container doesn't get events but all of its children do.
*/
BOX_NONE,
/**
* Container gets events but none of its children do.
*/
BOX_ONLY,
/**
* Container and all of its children receive touch events (like pointerEvents is unspecified).
*/
AUTO
}

View File

@@ -0,0 +1,125 @@
package com.swmansion.gesturehandler.core
import android.view.MotionEvent
import kotlin.math.atan2
class RotationGestureDetector(private val gestureListener: OnRotationGestureListener?) {
interface OnRotationGestureListener {
fun onRotation(detector: RotationGestureDetector): Boolean
fun onRotationBegin(detector: RotationGestureDetector): Boolean
fun onRotationEnd(detector: RotationGestureDetector)
}
private var currentTime = 0L
private var previousTime = 0L
private var previousAngle = 0.0
/**
* Returns rotation in radians since the previous rotation event.
*
* @return current rotation step in radians.
*/
var rotation = 0.0
private set
/**
* Returns X coordinate of the rotation anchor point relative to the view that the provided motion
* event coordinates (usually relative to the view event was sent to).
*
* @return X coordinate of the rotation anchor point
*/
var anchorX = 0f
private set
/**
* Returns Y coordinate of the rotation anchor point relative to the view that the provided motion
* event coordinates (usually relative to the view event was sent to).
*
* @return Y coordinate of the rotation anchor point
*/
var anchorY = 0f
private set
/**
* Return the time difference in milliseconds between the previous
* accepted rotation event and the current rotation event.
*
* @return Time difference since the last rotation event in milliseconds.
*/
val timeDelta: Long
get() = currentTime - previousTime
private var isInProgress = false
private val pointerIds = IntArray(2)
private fun updateCurrent(event: MotionEvent) {
previousTime = currentTime
currentTime = event.eventTime
val firstPointerIndex = event.findPointerIndex(pointerIds[0])
val secondPointerIndex = event.findPointerIndex(pointerIds[1])
val firstPtX = event.getX(firstPointerIndex)
val firstPtY = event.getY(firstPointerIndex)
val secondPtX = event.getX(secondPointerIndex)
val secondPtY = event.getY(secondPointerIndex)
val vectorX = secondPtX - firstPtX
val vectorY = secondPtY - firstPtY
anchorX = (firstPtX + secondPtX) * 0.5f
anchorY = (firstPtY + secondPtY) * 0.5f
// Angle diff should be positive when rotating in clockwise direction
val angle = -atan2(vectorY.toDouble(), vectorX.toDouble())
rotation = if (previousAngle.isNaN()) {
0.0
} else previousAngle - angle
previousAngle = angle
if (rotation > Math.PI) {
rotation -= Math.PI
} else if (rotation < -Math.PI) {
rotation += Math.PI
}
if (rotation > Math.PI / 2.0) {
rotation -= Math.PI
} else if (rotation < -Math.PI / 2.0) {
rotation += Math.PI
}
}
private fun finish() {
if (isInProgress) {
isInProgress = false
gestureListener?.onRotationEnd(this)
}
}
fun onTouchEvent(event: MotionEvent): Boolean {
when (event.actionMasked) {
MotionEvent.ACTION_DOWN -> {
isInProgress = false
pointerIds[0] = event.getPointerId(event.actionIndex)
pointerIds[1] = MotionEvent.INVALID_POINTER_ID
}
MotionEvent.ACTION_POINTER_DOWN -> if (!isInProgress) {
pointerIds[1] = event.getPointerId(event.actionIndex)
isInProgress = true
previousTime = event.eventTime
previousAngle = Double.NaN
updateCurrent(event)
gestureListener?.onRotationBegin(this)
}
MotionEvent.ACTION_MOVE -> if (isInProgress) {
updateCurrent(event)
gestureListener?.onRotation(this)
}
MotionEvent.ACTION_POINTER_UP -> if (isInProgress) {
val pointerId = event.getPointerId(event.actionIndex)
if (pointerId == pointerIds[0] || pointerId == pointerIds[1]) {
// One of the key pointer has been lifted up, we have to end the gesture
finish()
}
}
MotionEvent.ACTION_UP -> finish()
}
return true
}
}

View File

@@ -0,0 +1,93 @@
package com.swmansion.gesturehandler.core
import android.graphics.PointF
import android.view.MotionEvent
import com.swmansion.gesturehandler.core.RotationGestureDetector.OnRotationGestureListener
import kotlin.math.abs
class RotationGestureHandler : GestureHandler<RotationGestureHandler>() {
private var rotationGestureDetector: RotationGestureDetector? = null
var rotation = 0.0
private set
var velocity = 0.0
private set
var anchorX: Float = Float.NaN
private set
var anchorY: Float = Float.NaN
private set
init {
setShouldCancelWhenOutside(false)
}
private val gestureListener: OnRotationGestureListener = object : OnRotationGestureListener {
override fun onRotation(detector: RotationGestureDetector): Boolean {
val prevRotation: Double = rotation
rotation += detector.rotation
val delta = detector.timeDelta
if (delta > 0) {
velocity = (rotation - prevRotation) / delta
}
if (abs(rotation) >= ROTATION_RECOGNITION_THRESHOLD && state == STATE_BEGAN) {
activate()
}
return true
}
override fun onRotationBegin(detector: RotationGestureDetector) = true
override fun onRotationEnd(detector: RotationGestureDetector) {
end()
}
}
override fun onHandle(event: MotionEvent, sourceEvent: MotionEvent) {
if (state == STATE_UNDETERMINED) {
resetProgress()
rotationGestureDetector = RotationGestureDetector(gestureListener)
// set the anchor to the position of the first pointer as NaN causes the event not to arrive
this.anchorX = event.x
this.anchorY = event.y
begin()
}
rotationGestureDetector?.onTouchEvent(sourceEvent)
rotationGestureDetector?.let {
val point = transformPoint(PointF(it.anchorX, it.anchorY))
anchorX = point.x
anchorY = point.y
}
if (sourceEvent.actionMasked == MotionEvent.ACTION_UP) {
if (state == STATE_ACTIVE) {
end()
} else {
fail()
}
}
}
override fun activate(force: Boolean) {
// reset rotation if the handler has not yet activated
if (state != STATE_ACTIVE) {
resetProgress()
}
super.activate(force)
}
override fun onReset() {
rotationGestureDetector = null
anchorX = Float.NaN
anchorY = Float.NaN
resetProgress()
}
override fun resetProgress() {
velocity = 0.0
rotation = 0.0
}
companion object {
private const val ROTATION_RECOGNITION_THRESHOLD = Math.PI / 36.0 // 5 deg in radians
}
}

View File

@@ -0,0 +1,558 @@
/*
* Copyright (C) 2010 The Android Open Source Project
*
* Copied from https://android.googlesource.com/platform/frameworks/base/+/master/core/java/android/view/ScaleGestureDetector.java
* Modified line 189 to set initial min span to 0 instead of copying it from the system configuration
*/
package com.swmansion.gesturehandler.core;
import android.content.Context;
import android.os.Build;
import android.os.Handler;
import android.view.GestureDetector;
import android.view.MotionEvent;
import android.view.View;
import android.view.ViewConfiguration;
/**
* Detects scaling transformation gestures using the supplied {@link MotionEvent}s.
* The {@link OnScaleGestureListener} callback will notify users when a particular
* gesture event has occurred.
*
* This class should only be used with {@link MotionEvent}s reported via touch.
*
* To use this class:
* <ul>
* <li>Create an instance of the {@code ScaleGestureDetector} for your
* {@link View}
* <li>In the {@link View#onTouchEvent(MotionEvent)} method ensure you call
* {@link #onTouchEvent(MotionEvent)}. The methods defined in your
* callback will be executed when the events occur.
* </ul>
*/
public class ScaleGestureDetector {
private static final String TAG = "ScaleGestureDetector";
/**
* The listener for receiving notifications when gestures occur.
* If you want to listen for all the different gestures then implement
* this interface. If you only want to listen for a subset it might
* be easier to extend {@link SimpleOnScaleGestureListener}.
*
* An application will receive events in the following order:
* <ul>
* <li>One {@link OnScaleGestureListener#onScaleBegin(ScaleGestureDetector)}
* <li>Zero or more {@link OnScaleGestureListener#onScale(ScaleGestureDetector)}
* <li>One {@link OnScaleGestureListener#onScaleEnd(ScaleGestureDetector)}
* </ul>
*/
public interface OnScaleGestureListener {
/**
* Responds to scaling events for a gesture in progress.
* Reported by pointer motion.
*
* @param detector The detector reporting the event - use this to
* retrieve extended info about event state.
* @return Whether or not the detector should consider this event
* as handled. If an event was not handled, the detector
* will continue to accumulate movement until an event is
* handled. This can be useful if an application, for example,
* only wants to update scaling factors if the change is
* greater than 0.01.
*/
public boolean onScale(ScaleGestureDetector detector);
/**
* Responds to the beginning of a scaling gesture. Reported by
* new pointers going down.
*
* @param detector The detector reporting the event - use this to
* retrieve extended info about event state.
* @return Whether or not the detector should continue recognizing
* this gesture. For example, if a gesture is beginning
* with a focal point outside of a region where it makes
* sense, onScaleBegin() may return false to ignore the
* rest of the gesture.
*/
public boolean onScaleBegin(ScaleGestureDetector detector);
/**
* Responds to the end of a scale gesture. Reported by existing
* pointers going up.
*
* Once a scale has ended, {@link ScaleGestureDetector#getFocusX()}
* and {@link ScaleGestureDetector#getFocusY()} will return focal point
* of the pointers remaining on the screen.
*
* @param detector The detector reporting the event - use this to
* retrieve extended info about event state.
*/
public void onScaleEnd(ScaleGestureDetector detector);
}
/**
* A convenience class to extend when you only want to listen for a subset
* of scaling-related events. This implements all methods in
* {@link OnScaleGestureListener} but does nothing.
* {@link OnScaleGestureListener#onScale(ScaleGestureDetector)} returns
* {@code false} so that a subclass can retrieve the accumulated scale
* factor in an overridden onScaleEnd.
* {@link OnScaleGestureListener#onScaleBegin(ScaleGestureDetector)} returns
* {@code true}.
*/
public static class SimpleOnScaleGestureListener implements OnScaleGestureListener {
public boolean onScale(ScaleGestureDetector detector) {
return false;
}
public boolean onScaleBegin(ScaleGestureDetector detector) {
return true;
}
public void onScaleEnd(ScaleGestureDetector detector) {
// Intentionally empty
}
}
private final Context mContext;
private final OnScaleGestureListener mListener;
private float mFocusX;
private float mFocusY;
private boolean mQuickScaleEnabled;
private boolean mStylusScaleEnabled;
private float mCurrSpan;
private float mPrevSpan;
private float mInitialSpan;
private float mCurrSpanX;
private float mCurrSpanY;
private float mPrevSpanX;
private float mPrevSpanY;
private long mCurrTime;
private long mPrevTime;
private boolean mInProgress;
private int mSpanSlop;
private int mMinSpan;
private final Handler mHandler;
private float mAnchoredScaleStartX;
private float mAnchoredScaleStartY;
private int mAnchoredScaleMode = ANCHORED_SCALE_MODE_NONE;
private static final long TOUCH_STABILIZE_TIME = 128; // ms
private static final float SCALE_FACTOR = .5f;
private static final int ANCHORED_SCALE_MODE_NONE = 0;
private static final int ANCHORED_SCALE_MODE_DOUBLE_TAP = 1;
private static final int ANCHORED_SCALE_MODE_STYLUS = 2;
private GestureDetector mGestureDetector;
private boolean mEventBeforeOrAboveStartingGestureEvent;
/**
* Creates a ScaleGestureDetector with the supplied listener.
* You may only use this constructor from a {@link android.os.Looper Looper} thread.
*
* @param context the application's context
* @param listener the listener invoked for all the callbacks, this must
* not be null.
*
* @throws NullPointerException if {@code listener} is null.
*/
public ScaleGestureDetector(Context context, OnScaleGestureListener listener) {
this(context, listener, null);
}
/**
* Creates a ScaleGestureDetector with the supplied listener.
* @see android.os.Handler#Handler()
*
* @param context the application's context
* @param listener the listener invoked for all the callbacks, this must
* not be null.
* @param handler the handler to use for running deferred listener events.
*
* @throws NullPointerException if {@code listener} is null.
*/
public ScaleGestureDetector(Context context, OnScaleGestureListener listener,
Handler handler) {
mContext = context;
mListener = listener;
final ViewConfiguration viewConfiguration = ViewConfiguration.get(context);
mSpanSlop = viewConfiguration.getScaledTouchSlop() * 2;
mMinSpan = 0; // set to zero, to allow for scaling when distance between fingers is small
mHandler = handler;
// Quick scale is enabled by default after JB_MR2
final int targetSdkVersion = context.getApplicationInfo().targetSdkVersion;
if (targetSdkVersion > Build.VERSION_CODES.JELLY_BEAN_MR2) {
setQuickScaleEnabled(true);
}
// Stylus scale is enabled by default after LOLLIPOP_MR1
if (targetSdkVersion > Build.VERSION_CODES.LOLLIPOP_MR1) {
setStylusScaleEnabled(true);
}
}
/**
* Accepts MotionEvents and dispatches events to a {@link OnScaleGestureListener}
* when appropriate.
*
* <p>Applications should pass a complete and consistent event stream to this method.
* A complete and consistent event stream involves all MotionEvents from the initial
* ACTION_DOWN to the final ACTION_UP or ACTION_CANCEL.</p>
*
* @param event The event to process
* @return true if the event was processed and the detector wants to receive the
* rest of the MotionEvents in this event stream.
*/
public boolean onTouchEvent(MotionEvent event) {
mCurrTime = event.getEventTime();
final int action = event.getActionMasked();
// Forward the event to check for double tap gesture
if (mQuickScaleEnabled) {
mGestureDetector.onTouchEvent(event);
}
final int count = event.getPointerCount();
final boolean isStylusButtonDown =
(event.getButtonState() & MotionEvent.BUTTON_STYLUS_PRIMARY) != 0;
final boolean anchoredScaleCancelled =
mAnchoredScaleMode == ANCHORED_SCALE_MODE_STYLUS && !isStylusButtonDown;
final boolean streamComplete = action == MotionEvent.ACTION_UP ||
action == MotionEvent.ACTION_CANCEL || anchoredScaleCancelled;
if (action == MotionEvent.ACTION_DOWN || streamComplete) {
// Reset any scale in progress with the listener.
// If it's an ACTION_DOWN we're beginning a new event stream.
// This means the app probably didn't give us all the events. Shame on it.
if (mInProgress) {
mListener.onScaleEnd(this);
mInProgress = false;
mInitialSpan = 0;
mAnchoredScaleMode = ANCHORED_SCALE_MODE_NONE;
} else if (inAnchoredScaleMode() && streamComplete) {
mInProgress = false;
mInitialSpan = 0;
mAnchoredScaleMode = ANCHORED_SCALE_MODE_NONE;
}
if (streamComplete) {
return true;
}
}
if (!mInProgress && mStylusScaleEnabled && !inAnchoredScaleMode()
&& !streamComplete && isStylusButtonDown) {
// Start of a button scale gesture
mAnchoredScaleStartX = event.getX();
mAnchoredScaleStartY = event.getY();
mAnchoredScaleMode = ANCHORED_SCALE_MODE_STYLUS;
mInitialSpan = 0;
}
final boolean configChanged = action == MotionEvent.ACTION_DOWN ||
action == MotionEvent.ACTION_POINTER_UP ||
action == MotionEvent.ACTION_POINTER_DOWN || anchoredScaleCancelled;
final boolean pointerUp = action == MotionEvent.ACTION_POINTER_UP;
final int skipIndex = pointerUp ? event.getActionIndex() : -1;
// Determine focal point
float sumX = 0, sumY = 0;
final int div = pointerUp ? count - 1 : count;
final float focusX;
final float focusY;
if (inAnchoredScaleMode()) {
// In anchored scale mode, the focal pt is always where the double tap
// or button down gesture started
focusX = mAnchoredScaleStartX;
focusY = mAnchoredScaleStartY;
if (event.getY() < focusY) {
mEventBeforeOrAboveStartingGestureEvent = true;
} else {
mEventBeforeOrAboveStartingGestureEvent = false;
}
} else {
for (int i = 0; i < count; i++) {
if (skipIndex == i) continue;
sumX += event.getX(i);
sumY += event.getY(i);
}
focusX = sumX / div;
focusY = sumY / div;
}
// Determine average deviation from focal point
float devSumX = 0, devSumY = 0;
for (int i = 0; i < count; i++) {
if (skipIndex == i) continue;
// Convert the resulting diameter into a radius.
devSumX += Math.abs(event.getX(i) - focusX);
devSumY += Math.abs(event.getY(i) - focusY);
}
final float devX = devSumX / div;
final float devY = devSumY / div;
// Span is the average distance between touch points through the focal point;
// i.e. the diameter of the circle with a radius of the average deviation from
// the focal point.
final float spanX = devX * 2;
final float spanY = devY * 2;
final float span;
if (inAnchoredScaleMode()) {
span = spanY;
} else {
span = (float) Math.hypot(spanX, spanY);
}
// Dispatch begin/end events as needed.
// If the configuration changes, notify the app to reset its current state by beginning
// a fresh scale event stream.
final boolean wasInProgress = mInProgress;
mFocusX = focusX;
mFocusY = focusY;
if (!inAnchoredScaleMode() && mInProgress && (span < mMinSpan || configChanged)) {
mListener.onScaleEnd(this);
mInProgress = false;
mInitialSpan = span;
}
if (configChanged) {
mPrevSpanX = mCurrSpanX = spanX;
mPrevSpanY = mCurrSpanY = spanY;
mInitialSpan = mPrevSpan = mCurrSpan = span;
}
final int minSpan = inAnchoredScaleMode() ? mSpanSlop : mMinSpan;
if (!mInProgress && span >= minSpan &&
(wasInProgress || Math.abs(span - mInitialSpan) > mSpanSlop)) {
mPrevSpanX = mCurrSpanX = spanX;
mPrevSpanY = mCurrSpanY = spanY;
mPrevSpan = mCurrSpan = span;
mPrevTime = mCurrTime;
mInProgress = mListener.onScaleBegin(this);
}
// Handle motion; focal point and span/scale factor are changing.
if (action == MotionEvent.ACTION_MOVE) {
mCurrSpanX = spanX;
mCurrSpanY = spanY;
mCurrSpan = span;
boolean updatePrev = true;
if (mInProgress) {
updatePrev = mListener.onScale(this);
}
if (updatePrev) {
mPrevSpanX = mCurrSpanX;
mPrevSpanY = mCurrSpanY;
mPrevSpan = mCurrSpan;
mPrevTime = mCurrTime;
}
}
return true;
}
private boolean inAnchoredScaleMode() {
return mAnchoredScaleMode != ANCHORED_SCALE_MODE_NONE;
}
/**
* Set whether the associated {@link OnScaleGestureListener} should receive onScale callbacks
* when the user performs a doubleTap followed by a swipe. Note that this is enabled by default
* if the app targets API 19 and newer.
* @param scales true to enable quick scaling, false to disable
*/
public void setQuickScaleEnabled(boolean scales) {
mQuickScaleEnabled = scales;
if (mQuickScaleEnabled && mGestureDetector == null) {
GestureDetector.SimpleOnGestureListener gestureListener =
new GestureDetector.SimpleOnGestureListener() {
@Override
public boolean onDoubleTap(MotionEvent e) {
// Double tap: start watching for a swipe
mAnchoredScaleStartX = e.getX();
mAnchoredScaleStartY = e.getY();
mAnchoredScaleMode = ANCHORED_SCALE_MODE_DOUBLE_TAP;
return true;
}
};
mGestureDetector = new GestureDetector(mContext, gestureListener, mHandler);
}
}
/**
* Return whether the quick scale gesture, in which the user performs a double tap followed by a
* swipe, should perform scaling. {@see #setQuickScaleEnabled(boolean)}.
*/
public boolean isQuickScaleEnabled() {
return mQuickScaleEnabled;
}
/**
* Sets whether the associates {@link OnScaleGestureListener} should receive
* onScale callbacks when the user uses a stylus and presses the button.
* Note that this is enabled by default if the app targets API 23 and newer.
*
* @param scales true to enable stylus scaling, false to disable.
*/
public void setStylusScaleEnabled(boolean scales) {
mStylusScaleEnabled = scales;
}
/**
* Return whether the stylus scale gesture, in which the user uses a stylus and presses the
* button, should perform scaling. {@see #setStylusScaleEnabled(boolean)}
*/
public boolean isStylusScaleEnabled() {
return mStylusScaleEnabled;
}
/**
* Returns {@code true} if a scale gesture is in progress.
*/
public boolean isInProgress() {
return mInProgress;
}
/**
* Get the X coordinate of the current gesture's focal point.
* If a gesture is in progress, the focal point is between
* each of the pointers forming the gesture.
*
* If {@link #isInProgress()} would return false, the result of this
* function is undefined.
*
* @return X coordinate of the focal point in pixels.
*/
public float getFocusX() {
return mFocusX;
}
/**
* Get the Y coordinate of the current gesture's focal point.
* If a gesture is in progress, the focal point is between
* each of the pointers forming the gesture.
*
* If {@link #isInProgress()} would return false, the result of this
* function is undefined.
*
* @return Y coordinate of the focal point in pixels.
*/
public float getFocusY() {
return mFocusY;
}
/**
* Return the average distance between each of the pointers forming the
* gesture in progress through the focal point.
*
* @return Distance between pointers in pixels.
*/
public float getCurrentSpan() {
return mCurrSpan;
}
/**
* Return the average X distance between each of the pointers forming the
* gesture in progress through the focal point.
*
* @return Distance between pointers in pixels.
*/
public float getCurrentSpanX() {
return mCurrSpanX;
}
/**
* Return the average Y distance between each of the pointers forming the
* gesture in progress through the focal point.
*
* @return Distance between pointers in pixels.
*/
public float getCurrentSpanY() {
return mCurrSpanY;
}
/**
* Return the previous average distance between each of the pointers forming the
* gesture in progress through the focal point.
*
* @return Previous distance between pointers in pixels.
*/
public float getPreviousSpan() {
return mPrevSpan;
}
/**
* Return the previous average X distance between each of the pointers forming the
* gesture in progress through the focal point.
*
* @return Previous distance between pointers in pixels.
*/
public float getPreviousSpanX() {
return mPrevSpanX;
}
/**
* Return the previous average Y distance between each of the pointers forming the
* gesture in progress through the focal point.
*
* @return Previous distance between pointers in pixels.
*/
public float getPreviousSpanY() {
return mPrevSpanY;
}
/**
* Return the scaling factor from the previous scale event to the current
* event. This value is defined as
* ({@link #getCurrentSpan()} / {@link #getPreviousSpan()}).
*
* @return The current scaling factor.
*/
public float getScaleFactor() {
if (inAnchoredScaleMode()) {
// Drag is moving up; the further away from the gesture
// start, the smaller the span should be, the closer,
// the larger the span, and therefore the larger the scale
final boolean scaleUp =
(mEventBeforeOrAboveStartingGestureEvent && (mCurrSpan < mPrevSpan)) ||
(!mEventBeforeOrAboveStartingGestureEvent && (mCurrSpan > mPrevSpan));
final float spanDiff = (Math.abs(1 - (mCurrSpan / mPrevSpan)) * SCALE_FACTOR);
return mPrevSpan <= mSpanSlop ? 1 : scaleUp ? (1 + spanDiff) : (1 - spanDiff);
}
return mPrevSpan > 0 ? mCurrSpan / mPrevSpan : 1;
}
/**
* Return the time difference in milliseconds between the previous
* accepted scaling event and the current scaling event.
*
* @return Time difference since the last scaling event in milliseconds.
*/
public long getTimeDelta() {
return mCurrTime - mPrevTime;
}
/**
* Return the event time of the current event being processed.
*
* @return Current event time in milliseconds.
*/
public long getEventTime() {
return mCurrTime;
}
}

View File

@@ -0,0 +1,172 @@
package com.swmansion.gesturehandler.core
import android.os.Handler
import android.os.Looper
import android.view.MotionEvent
import com.swmansion.gesturehandler.core.GestureUtils.getLastPointerX
import com.swmansion.gesturehandler.core.GestureUtils.getLastPointerY
import kotlin.math.abs
class TapGestureHandler : GestureHandler<TapGestureHandler>() {
private var maxDeltaX = MAX_VALUE_IGNORE
private var maxDeltaY = MAX_VALUE_IGNORE
private var maxDistSq = MAX_VALUE_IGNORE
private var maxDurationMs = DEFAULT_MAX_DURATION_MS
private var maxDelayMs = DEFAULT_MAX_DELAY_MS
private var numberOfTaps = DEFAULT_NUMBER_OF_TAPS
private var minNumberOfPointers = DEFAULT_MIN_NUMBER_OF_POINTERS
private var currentMaxNumberOfPointers = 1
private var startX = 0f
private var startY = 0f
private var offsetX = 0f
private var offsetY = 0f
private var lastX = 0f
private var lastY = 0f
private var handler: Handler? = null
private var tapsSoFar = 0
private val failDelayed = Runnable { fail() }
init {
setShouldCancelWhenOutside(true)
}
override fun resetConfig() {
super.resetConfig()
maxDeltaX = MAX_VALUE_IGNORE
maxDeltaY = MAX_VALUE_IGNORE
maxDistSq = MAX_VALUE_IGNORE
maxDurationMs = DEFAULT_MAX_DURATION_MS
maxDelayMs = DEFAULT_MAX_DELAY_MS
numberOfTaps = DEFAULT_NUMBER_OF_TAPS
minNumberOfPointers = DEFAULT_MIN_NUMBER_OF_POINTERS
}
fun setNumberOfTaps(numberOfTaps: Int) = apply {
this.numberOfTaps = numberOfTaps
}
fun setMaxDelayMs(maxDelayMs: Long) = apply {
this.maxDelayMs = maxDelayMs
}
fun setMaxDurationMs(maxDurationMs: Long) = apply {
this.maxDurationMs = maxDurationMs
}
fun setMaxDx(deltaX: Float) = apply {
maxDeltaX = deltaX
}
fun setMaxDy(deltaY: Float) = apply {
maxDeltaY = deltaY
}
fun setMaxDist(maxDist: Float) = apply {
maxDistSq = maxDist * maxDist
}
fun setMinNumberOfPointers(minNumberOfPointers: Int) = apply {
this.minNumberOfPointers = minNumberOfPointers
}
private fun startTap() {
if (handler == null) {
handler = Handler(Looper.getMainLooper()) // TODO: lazy init (handle else branch correctly)
} else {
handler!!.removeCallbacksAndMessages(null)
}
handler!!.postDelayed(failDelayed, maxDurationMs)
}
private fun endTap() {
if (handler == null) {
handler = Handler(Looper.getMainLooper())
} else {
handler!!.removeCallbacksAndMessages(null)
}
if (++tapsSoFar == numberOfTaps && currentMaxNumberOfPointers >= minNumberOfPointers) {
activate()
} else {
handler!!.postDelayed(failDelayed, maxDelayMs)
}
}
private fun shouldFail(): Boolean {
val dx = lastX - startX + offsetX
if (maxDeltaX != MAX_VALUE_IGNORE && abs(dx) > maxDeltaX) {
return true
}
val dy = lastY - startY + offsetY
if (maxDeltaY != MAX_VALUE_IGNORE && abs(dy) > maxDeltaY) {
return true
}
val dist = dy * dy + dx * dx
return maxDistSq != MAX_VALUE_IGNORE && dist > maxDistSq
}
override fun onHandle(event: MotionEvent, sourceEvent: MotionEvent) {
if (!shouldActivateWithMouse(sourceEvent)) {
return
}
val state = state
val action = sourceEvent.actionMasked
if (state == STATE_UNDETERMINED) {
offsetX = 0f
offsetY = 0f
startX = getLastPointerX(sourceEvent, true)
startY = getLastPointerY(sourceEvent, true)
}
if (action == MotionEvent.ACTION_POINTER_UP || action == MotionEvent.ACTION_POINTER_DOWN) {
offsetX += lastX - startX
offsetY += lastY - startY
lastX = getLastPointerX(sourceEvent, true)
lastY = getLastPointerY(sourceEvent, true)
startX = lastX
startY = lastY
} else {
lastX = getLastPointerX(sourceEvent, true)
lastY = getLastPointerY(sourceEvent, true)
}
if (currentMaxNumberOfPointers < sourceEvent.pointerCount) {
currentMaxNumberOfPointers = sourceEvent.pointerCount
}
if (shouldFail()) {
fail()
} else if (state == STATE_UNDETERMINED) {
if (action == MotionEvent.ACTION_DOWN || action == MotionEvent.ACTION_BUTTON_PRESS) {
begin()
}
startTap()
} else if (state == STATE_BEGAN) {
if (action == MotionEvent.ACTION_UP || action == MotionEvent.ACTION_BUTTON_RELEASE) {
endTap()
} else if (action == MotionEvent.ACTION_DOWN || action == MotionEvent.ACTION_BUTTON_PRESS) {
startTap()
}
}
}
override fun activate(force: Boolean) {
super.activate(force)
end()
}
override fun onCancel() {
handler?.removeCallbacksAndMessages(null)
}
override fun onReset() {
tapsSoFar = 0
currentMaxNumberOfPointers = 0
handler?.removeCallbacksAndMessages(null)
}
companion object {
private const val MAX_VALUE_IGNORE = Float.MIN_VALUE
private const val DEFAULT_MAX_DURATION_MS: Long = 500
private const val DEFAULT_MAX_DELAY_MS: Long = 200
private const val DEFAULT_NUMBER_OF_TAPS = 1
private const val DEFAULT_MIN_NUMBER_OF_POINTERS = 1
}
}

View File

@@ -0,0 +1,66 @@
package com.swmansion.gesturehandler.core
import android.view.VelocityTracker
import com.swmansion.gesturehandler.core.GestureHandler.Companion.DIRECTION_DOWN
import com.swmansion.gesturehandler.core.GestureHandler.Companion.DIRECTION_LEFT
import com.swmansion.gesturehandler.core.GestureHandler.Companion.DIRECTION_RIGHT
import com.swmansion.gesturehandler.core.GestureHandler.Companion.DIRECTION_UP
import kotlin.math.hypot
class Vector(val x: Double, val y: Double) {
private val unitX: Double
private val unitY: Double
val magnitude = hypot(x, y)
init {
val isMagnitudeSufficient = magnitude > MINIMAL_MAGNITUDE
unitX = if (isMagnitudeSufficient) x / magnitude else 0.0
unitY = if (isMagnitudeSufficient) y / magnitude else 0.0
}
private fun computeSimilarity(vector: Vector): Double {
return unitX * vector.unitX + unitY * vector.unitY
}
fun isSimilar(vector: Vector, threshold: Double): Boolean {
return computeSimilarity(vector) > threshold
}
companion object {
private val VECTOR_LEFT: Vector = Vector(-1.0, 0.0)
private val VECTOR_RIGHT: Vector = Vector(1.0, 0.0)
private val VECTOR_UP: Vector = Vector(0.0, -1.0)
private val VECTOR_DOWN: Vector = Vector(0.0, 1.0)
private val VECTOR_RIGHT_UP: Vector = Vector(1.0, -1.0)
private val VECTOR_RIGHT_DOWN: Vector = Vector(1.0, 1.0)
private val VECTOR_LEFT_UP: Vector = Vector(-1.0, -1.0)
private val VECTOR_LEFT_DOWN: Vector = Vector(-1.0, 1.0)
private val VECTOR_ZERO: Vector = Vector(0.0, 0.0)
private const val MINIMAL_MAGNITUDE = 0.1
fun fromDirection(direction: Int): Vector =
when (direction) {
DIRECTION_LEFT -> VECTOR_LEFT
DIRECTION_RIGHT -> VECTOR_RIGHT
DIRECTION_UP -> VECTOR_UP
DIRECTION_DOWN -> VECTOR_DOWN
DiagonalDirections.DIRECTION_RIGHT_UP -> VECTOR_RIGHT_UP
DiagonalDirections.DIRECTION_RIGHT_DOWN -> VECTOR_RIGHT_DOWN
DiagonalDirections.DIRECTION_LEFT_UP -> VECTOR_LEFT_UP
DiagonalDirections.DIRECTION_LEFT_DOWN -> VECTOR_LEFT_DOWN
else -> VECTOR_ZERO
}
fun fromVelocity(tracker: VelocityTracker): Vector {
tracker.computeCurrentVelocity(1000)
val velocityX = tracker.xVelocity.toDouble()
val velocityY = tracker.yVelocity.toDouble()
return Vector(velocityX, velocityY)
}
}
}

View File

@@ -0,0 +1,10 @@
package com.swmansion.gesturehandler.core
import android.view.View
import android.view.ViewGroup
interface ViewConfigurationHelper {
fun getPointerEventsConfigForView(view: View): PointerEventsConfig
fun getChildInDrawingOrderAtIndex(parent: ViewGroup, index: Int): View
fun isViewClippingChildren(view: ViewGroup): Boolean
}

View File

@@ -0,0 +1,16 @@
package com.swmansion.gesturehandler.react
import android.content.Context
import android.view.accessibility.AccessibilityManager
import com.facebook.react.bridge.ReactContext
import com.facebook.react.modules.core.DeviceEventManagerModule
import com.facebook.react.uimanager.UIManagerModule
val ReactContext.deviceEventEmitter: DeviceEventManagerModule.RCTDeviceEventEmitter
get() = this.getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java)
val ReactContext.UIManager: UIManagerModule
get() = this.getNativeModule(UIManagerModule::class.java)!!
fun Context.isScreenReaderOn() =
(getSystemService(Context.ACCESSIBILITY_SERVICE) as AccessibilityManager).isTouchExplorationEnabled

View File

@@ -0,0 +1,523 @@
package com.swmansion.gesturehandler.react
import android.annotation.SuppressLint
import android.annotation.TargetApi
import android.content.Context
import android.content.res.ColorStateList
import android.graphics.Color
import android.graphics.DashPathEffect
import android.graphics.Paint
import android.graphics.PathEffect
import android.graphics.drawable.Drawable
import android.graphics.drawable.LayerDrawable
import android.graphics.drawable.PaintDrawable
import android.graphics.drawable.RippleDrawable
import android.graphics.drawable.ShapeDrawable
import android.graphics.drawable.shapes.RectShape
import android.os.Build
import android.util.TypedValue
import android.view.KeyEvent
import android.view.MotionEvent
import android.view.View
import android.view.View.OnClickListener
import android.view.ViewGroup
import android.view.ViewParent
import androidx.core.view.children
import com.facebook.react.module.annotations.ReactModule
import com.facebook.react.uimanager.PixelUtil
import com.facebook.react.uimanager.ThemedReactContext
import com.facebook.react.uimanager.ViewGroupManager
import com.facebook.react.uimanager.ViewManagerDelegate
import com.facebook.react.uimanager.ViewProps
import com.facebook.react.uimanager.annotations.ReactProp
import com.facebook.react.viewmanagers.RNGestureHandlerButtonManagerDelegate
import com.facebook.react.viewmanagers.RNGestureHandlerButtonManagerInterface
import com.swmansion.gesturehandler.core.NativeViewGestureHandler
import com.swmansion.gesturehandler.react.RNGestureHandlerButtonViewManager.ButtonViewGroup
@ReactModule(name = RNGestureHandlerButtonViewManager.REACT_CLASS)
class RNGestureHandlerButtonViewManager : ViewGroupManager<ButtonViewGroup>(), RNGestureHandlerButtonManagerInterface<ButtonViewGroup> {
private val mDelegate: ViewManagerDelegate<ButtonViewGroup>
init {
mDelegate = RNGestureHandlerButtonManagerDelegate<ButtonViewGroup, RNGestureHandlerButtonViewManager>(this)
}
override fun getName() = REACT_CLASS
public override fun createViewInstance(context: ThemedReactContext) = ButtonViewGroup(context)
@TargetApi(Build.VERSION_CODES.M)
@ReactProp(name = "foreground")
override fun setForeground(view: ButtonViewGroup, useDrawableOnForeground: Boolean) {
view.useDrawableOnForeground = useDrawableOnForeground
}
@ReactProp(name = "borderless")
override fun setBorderless(view: ButtonViewGroup, useBorderlessDrawable: Boolean) {
view.useBorderlessDrawable = useBorderlessDrawable
}
@ReactProp(name = "enabled")
override fun setEnabled(view: ButtonViewGroup, enabled: Boolean) {
view.isEnabled = enabled
}
@ReactProp(name = ViewProps.BORDER_RADIUS)
override fun setBorderRadius(view: ButtonViewGroup, borderRadius: Float) {
view.borderRadius = borderRadius
}
@ReactProp(name = "borderTopLeftRadius")
override fun setBorderTopLeftRadius(view: ButtonViewGroup, borderTopLeftRadius: Float) {
view.borderTopLeftRadius = borderTopLeftRadius
}
@ReactProp(name = "borderTopRightRadius")
override fun setBorderTopRightRadius(view: ButtonViewGroup, borderTopRightRadius: Float) {
view.borderTopRightRadius = borderTopRightRadius
}
@ReactProp(name = "borderBottomLeftRadius")
override fun setBorderBottomLeftRadius(view: ButtonViewGroup, borderBottomLeftRadius: Float) {
view.borderBottomLeftRadius = borderBottomLeftRadius
}
@ReactProp(name = "borderBottomRightRadius")
override fun setBorderBottomRightRadius(view: ButtonViewGroup, borderBottomRightRadius: Float) {
view.borderBottomRightRadius = borderBottomRightRadius
}
@ReactProp(name = "borderWidth")
override fun setBorderWidth(view: ButtonViewGroup, borderWidth: Float) {
view.borderWidth = borderWidth
}
@ReactProp(name = "borderColor")
override fun setBorderColor(view: ButtonViewGroup, borderColor: Int?) {
view.borderColor = borderColor
}
@ReactProp(name = "borderStyle")
override fun setBorderStyle(view: ButtonViewGroup, borderStyle: String?) {
view.borderStyle = borderStyle
}
@ReactProp(name = "rippleColor")
override fun setRippleColor(view: ButtonViewGroup, rippleColor: Int?) {
view.rippleColor = rippleColor
}
@ReactProp(name = "rippleRadius")
override fun setRippleRadius(view: ButtonViewGroup, rippleRadius: Int) {
view.rippleRadius = rippleRadius
}
@ReactProp(name = "exclusive")
override fun setExclusive(view: ButtonViewGroup, exclusive: Boolean) {
view.exclusive = exclusive
}
@ReactProp(name = "touchSoundDisabled")
override fun setTouchSoundDisabled(view: ButtonViewGroup, touchSoundDisabled: Boolean) {
view.isSoundEffectsEnabled = !touchSoundDisabled
}
override fun onAfterUpdateTransaction(view: ButtonViewGroup) {
view.updateBackground()
}
override fun getDelegate(): ViewManagerDelegate<ButtonViewGroup>? {
return mDelegate
}
class ButtonViewGroup(context: Context?) :
ViewGroup(context),
NativeViewGestureHandler.NativeViewGestureHandlerHook {
// Using object because of handling null representing no value set.
var rippleColor: Int? = null
set(color) = withBackgroundUpdate {
field = color
}
var rippleRadius: Int? = null
set(radius) = withBackgroundUpdate {
field = radius
}
var useDrawableOnForeground = false
set(useForeground) = withBackgroundUpdate {
field = useForeground
}
var useBorderlessDrawable = false
var borderRadius = 0f
set(radius) = withBackgroundUpdate {
field = radius * resources.displayMetrics.density
}
var borderTopLeftRadius = 0f
set(radius) = withBackgroundUpdate {
field = radius * resources.displayMetrics.density
}
var borderTopRightRadius = 0f
set(radius) = withBackgroundUpdate {
field = radius * resources.displayMetrics.density
}
var borderBottomLeftRadius = 0f
set(radius) = withBackgroundUpdate {
field = radius * resources.displayMetrics.density
}
var borderBottomRightRadius = 0f
set(radius) = withBackgroundUpdate {
field = radius * resources.displayMetrics.density
}
var borderWidth = 0f
set(width) = withBackgroundUpdate {
field = width * resources.displayMetrics.density
}
var borderColor: Int? = null
set(color) = withBackgroundUpdate {
field = color
}
var borderStyle: String? = "solid"
set(style) = withBackgroundUpdate {
field = style
}
private val hasBorderRadii: Boolean
get() = borderRadius != 0f ||
borderTopLeftRadius != 0f ||
borderTopRightRadius != 0f ||
borderBottomLeftRadius != 0f ||
borderBottomRightRadius != 0f
var exclusive = true
private var _backgroundColor = Color.TRANSPARENT
private var needBackgroundUpdate = false
private var lastEventTime = -1L
private var lastAction = -1
private var receivedKeyEvent = false
var isTouched = false
init {
// we attach empty click listener to trigger tap sounds (see View#performClick())
setOnClickListener(dummyClickListener)
isClickable = true
isFocusable = true
needBackgroundUpdate = true
clipChildren = false
}
private inline fun withBackgroundUpdate(block: () -> Unit) {
block()
needBackgroundUpdate = true
}
private fun buildBorderRadii(): FloatArray {
// duplicate radius for each corner, as setCornerRadii expects X radius and Y radius for each
return floatArrayOf(
borderTopLeftRadius,
borderTopLeftRadius,
borderTopRightRadius,
borderTopRightRadius,
borderBottomRightRadius,
borderBottomRightRadius,
borderBottomLeftRadius,
borderBottomLeftRadius,
)
.map { if (it != 0f) it else borderRadius }
.toFloatArray()
}
private fun buildBorderStyle(): PathEffect? {
return when (borderStyle) {
"dotted" -> DashPathEffect(floatArrayOf(borderWidth, borderWidth, borderWidth, borderWidth), 0f)
"dashed" -> DashPathEffect(floatArrayOf(borderWidth * 3, borderWidth * 3, borderWidth * 3, borderWidth * 3), 0f)
else -> null
}
}
override fun setBackgroundColor(color: Int) = withBackgroundUpdate {
_backgroundColor = color
}
override fun onInterceptTouchEvent(ev: MotionEvent): Boolean {
if (super.onInterceptTouchEvent(ev)) {
return true
}
// We call `onTouchEvent` and wait until button changes state to `pressed`, if it's pressed
// we return true so that the gesture handler can activate.
onTouchEvent(ev)
return isPressed
}
/**
* Buttons in RN are wrapped in NativeViewGestureHandler which manages
* calling onTouchEvent after activation of the handler. Problem is, in order to verify that
* underlying button implementation is interested in receiving touches we have to call onTouchEvent
* and check if button is pressed.
*
* This leads to invoking onTouchEvent twice which isn't idempotent in View - it calls OnClickListener
* and plays sound effect if OnClickListener was set.
*
* To mitigate this behavior we use lastEventTime and lastAction variables to check that we already handled
* the event in [onInterceptTouchEvent]. We assume here that different events
* will have different event times or actions.
* Events with same event time can occur on some devices for different actions.
* (e.g. move and up in one gesture; move and cancel)
*
* Reference:
* [com.swmansion.gesturehandler.NativeViewGestureHandler.onHandle] */
@SuppressLint("ClickableViewAccessibility")
override fun onTouchEvent(event: MotionEvent): Boolean {
if (event.action == MotionEvent.ACTION_CANCEL) {
tryFreeingResponder()
return super.onTouchEvent(event)
}
val eventTime = event.eventTime
val action = event.action
// always true when lastEventTime or lastAction have default value (-1)
if (lastEventTime != eventTime || lastAction != action) {
lastEventTime = eventTime
lastAction = action
return super.onTouchEvent(event)
}
return false
}
private fun updateBackgroundColor(backgroundColor: Int, selectable: Drawable?) {
val colorDrawable = PaintDrawable(backgroundColor)
val borderDrawable = PaintDrawable(Color.TRANSPARENT)
if (hasBorderRadii) {
colorDrawable.setCornerRadii(buildBorderRadii())
borderDrawable.setCornerRadii(buildBorderRadii())
}
if (borderWidth > 0f) {
borderDrawable.paint.apply {
style = Paint.Style.STROKE
strokeWidth = borderWidth
color = borderColor ?: Color.BLACK
pathEffect = buildBorderStyle()
}
}
val layerDrawable = LayerDrawable(if (selectable != null) arrayOf(colorDrawable, selectable, borderDrawable) else arrayOf(colorDrawable, borderDrawable))
background = layerDrawable
}
fun updateBackground() {
if (!needBackgroundUpdate) {
return
}
needBackgroundUpdate = false
if (_backgroundColor == Color.TRANSPARENT) {
// reset background
background = null
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
// reset foreground
foreground = null
}
val selectable = createSelectableDrawable()
if (hasBorderRadii && selectable is RippleDrawable) {
val mask = PaintDrawable(Color.WHITE)
mask.setCornerRadii(buildBorderRadii())
selectable.setDrawableByLayerId(android.R.id.mask, mask)
}
if (useDrawableOnForeground && Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
foreground = selectable
if (_backgroundColor != Color.TRANSPARENT) {
updateBackgroundColor(_backgroundColor, null)
}
} else if (_backgroundColor == Color.TRANSPARENT && rippleColor == null) {
background = selectable
} else {
updateBackgroundColor(_backgroundColor, selectable)
}
}
private fun createSelectableDrawable(): Drawable? {
// don't create ripple drawable at all when it's not supposed to be visible
if (rippleColor == Color.TRANSPARENT) {
return null
}
val states = arrayOf(intArrayOf(android.R.attr.state_enabled))
val rippleRadius = rippleRadius
val colorStateList = if (rippleColor != null) {
val colors = intArrayOf(rippleColor!!)
ColorStateList(states, colors)
} else {
// if rippleColor is null, reapply the default color
context.theme.resolveAttribute(android.R.attr.colorControlHighlight, resolveOutValue, true)
val colors = intArrayOf(resolveOutValue.data)
ColorStateList(states, colors)
}
val drawable = RippleDrawable(
colorStateList,
null,
if (useBorderlessDrawable) null else ShapeDrawable(RectShape())
)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M && rippleRadius != null) {
drawable.radius = PixelUtil.toPixelFromDIP(rippleRadius.toFloat()).toInt()
}
return drawable
}
override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) {
// No-op
}
override fun drawableHotspotChanged(x: Float, y: Float) {
if (touchResponder == null || touchResponder === this) {
super.drawableHotspotChanged(x, y)
}
}
override fun canBegin(): Boolean {
val isResponder = tryGrabbingResponder()
if (isResponder) {
isTouched = true
}
return isResponder
}
override fun afterGestureEnd(event: MotionEvent) {
tryFreeingResponder()
isTouched = false
}
private fun tryFreeingResponder() {
if (touchResponder === this) {
touchResponder = null
soundResponder = this
}
}
private fun tryGrabbingResponder(): Boolean {
if (isChildTouched()) {
return false
}
if (touchResponder == null) {
touchResponder = this
return true
}
return if (exclusive) {
touchResponder === this
} else {
!(touchResponder?.exclusive ?: false)
}
}
private fun isChildTouched(children: Sequence<View> = this.children): Boolean {
for (child in children) {
if (child is ButtonViewGroup && (child.isTouched || child.isPressed)) {
return true
} else if (child is ViewGroup) {
if (isChildTouched(child.children)) {
return true
}
}
}
return false
}
override fun onKeyUp(keyCode: Int, event: KeyEvent?): Boolean {
receivedKeyEvent = true
return super.onKeyUp(keyCode, event)
}
override fun performClick(): Boolean {
// don't preform click when a child button is pressed (mainly to prevent sound effect of
// a parent button from playing)
return if (!isChildTouched()) {
if (context.isScreenReaderOn()) {
findGestureHandlerRootView()?.activateNativeHandlers(this)
} else if (receivedKeyEvent) {
findGestureHandlerRootView()?.activateNativeHandlers(this)
receivedKeyEvent = false
}
if (soundResponder === this) {
tryFreeingResponder()
soundResponder = null
super.performClick()
} else {
false
}
} else {
false
}
}
override fun setPressed(pressed: Boolean) {
// there is a possibility of this method being called before NativeViewGestureHandler has
// opportunity to call canStart, in that case we need to grab responder in case the gesture
// will activate
// when canStart is called eventually, tryGrabbingResponder will return true if the button
// already is a responder
if (pressed) {
if (tryGrabbingResponder()) {
soundResponder = this
}
}
// button can be pressed alongside other button if both are non-exclusive and it doesn't have
// any pressed children (to prevent pressing the parent when children is pressed).
val canBePressedAlongsideOther = !exclusive && touchResponder?.exclusive != true && !isChildTouched()
if (!pressed || touchResponder === this || canBePressedAlongsideOther) {
// we set pressed state only for current responder or any non-exclusive button when responder
// is null or non-exclusive, assuming it doesn't have pressed children
isTouched = pressed
super.setPressed(pressed)
}
if (!pressed && touchResponder === this) {
// if the responder is no longer pressed we release button responder
isTouched = false
}
}
override fun dispatchDrawableHotspotChanged(x: Float, y: Float) {
// No-op
// by default Viewgroup would pass hotspot change events
}
private fun findGestureHandlerRootView(): RNGestureHandlerRootView? {
var parent: ViewParent? = this.parent
var gestureHandlerRootView: RNGestureHandlerRootView? = null
while (parent != null) {
if (parent is RNGestureHandlerRootView) {
gestureHandlerRootView = parent
}
parent = parent.parent
}
return gestureHandlerRootView
}
companion object {
var resolveOutValue = TypedValue()
var touchResponder: ButtonViewGroup? = null
var soundResponder: ButtonViewGroup? = null
var dummyClickListener = OnClickListener { }
}
}
companion object {
const val REACT_CLASS = "RNGestureHandlerButton"
}
}

View File

@@ -0,0 +1,15 @@
package com.swmansion.gesturehandler.react
import android.content.Context
import android.util.AttributeSet
import com.facebook.react.ReactRootView
@Deprecated(message = "Use <GestureHandlerRootView /> component instead. Check gesture handler installation instructions in documentation for more information.")
class RNGestureHandlerEnabledRootView : ReactRootView {
constructor(context: Context?) : super(context)
constructor(context: Context?, attrs: AttributeSet?) : super(context, attrs)
init {
throw UnsupportedOperationException("Your application is configured to use RNGestureHandlerEnabledRootView which is no longer supported. You can see how to migrate to <GestureHandlerRootView /> here: https://docs.swmansion.com/react-native-gesture-handler/docs/guides/migrating-off-rnghenabledroot")
}
}

View File

@@ -0,0 +1,72 @@
// 1. RCTEventEmitter was deprecated in favor of RCTModernEventEmitter interface
// 2. Event#init() with only viewTag was deprecated in favor of two arg c-tor
// 3. Event#receiveEvent() with 3 args was deprecated in favor of 4 args version
// ref: https://github.com/facebook/react-native/commit/2fbbdbb2ce897e8da3f471b08b93f167d566db1d
@file:Suppress("DEPRECATION")
package com.swmansion.gesturehandler.react
import androidx.core.util.Pools
import com.facebook.react.bridge.Arguments
import com.facebook.react.bridge.WritableMap
import com.facebook.react.uimanager.events.Event
import com.swmansion.gesturehandler.core.GestureHandler
import com.swmansion.gesturehandler.react.eventbuilders.GestureHandlerEventDataBuilder
class RNGestureHandlerEvent private constructor() : Event<RNGestureHandlerEvent>() {
private var dataBuilder: GestureHandlerEventDataBuilder<*>? = null
private var coalescingKey: Short = 0
// On the new architecture, native animated expects event names prefixed with `top` instead of `on`,
// since we know when the native animated node is the target of the event we can use the different
// event name where appropriate.
// TODO: This is a workaround not as solution, but doing this properly would require a total overhaul of
// how GH sends events (which needs to be done, but maybe wait until the RN's apis stop changing)
private var useTopPrefixedName: Boolean = false
private fun <T : GestureHandler<T>> init(
handler: T,
dataBuilder: GestureHandlerEventDataBuilder<T>,
useNativeAnimatedName: Boolean
) {
super.init(handler.view!!.id)
this.dataBuilder = dataBuilder
this.useTopPrefixedName = useNativeAnimatedName
coalescingKey = handler.eventCoalescingKey
}
override fun onDispose() {
dataBuilder = null
EVENTS_POOL.release(this)
}
override fun getEventName() = if (useTopPrefixedName) NATIVE_ANIMATED_EVENT_NAME else EVENT_NAME
override fun canCoalesce() = true
override fun getCoalescingKey() = coalescingKey
override fun getEventData(): WritableMap = createEventData(dataBuilder!!)
companion object {
const val EVENT_NAME = "onGestureHandlerEvent"
const val NATIVE_ANIMATED_EVENT_NAME = "topGestureHandlerEvent"
private const val TOUCH_EVENTS_POOL_SIZE = 7 // magic
private val EVENTS_POOL = Pools.SynchronizedPool<RNGestureHandlerEvent>(TOUCH_EVENTS_POOL_SIZE)
fun <T : GestureHandler<T>> obtain(
handler: T,
dataBuilder: GestureHandlerEventDataBuilder<T>,
useTopPrefixedName: Boolean = false
): RNGestureHandlerEvent =
(EVENTS_POOL.acquire() ?: RNGestureHandlerEvent()).apply {
init(handler, dataBuilder, useTopPrefixedName)
}
fun createEventData(
dataBuilder: GestureHandlerEventDataBuilder<*>
): WritableMap = Arguments.createMap().apply {
dataBuilder.buildEventData(this)
}
}
}

View File

@@ -0,0 +1,74 @@
package com.swmansion.gesturehandler.react
import android.util.SparseArray
import com.facebook.react.bridge.ReadableMap
import com.swmansion.gesturehandler.core.GestureHandler
import com.swmansion.gesturehandler.core.GestureHandlerInteractionController
import com.swmansion.gesturehandler.core.NativeViewGestureHandler
class RNGestureHandlerInteractionManager : GestureHandlerInteractionController {
private val waitForRelations = SparseArray<IntArray>()
private val simultaneousRelations = SparseArray<IntArray>()
private val blockingRelations = SparseArray<IntArray>()
fun dropRelationsForHandlerWithTag(handlerTag: Int) {
waitForRelations.remove(handlerTag)
simultaneousRelations.remove(handlerTag)
}
private fun convertHandlerTagsArray(config: ReadableMap, key: String): IntArray {
val array = config.getArray(key)!!
return IntArray(array.size()).also {
for (i in it.indices) {
it[i] = array.getInt(i)
}
}
}
fun configureInteractions(handler: GestureHandler<*>, config: ReadableMap) {
handler.setInteractionController(this)
if (config.hasKey(KEY_WAIT_FOR)) {
val tags = convertHandlerTagsArray(config, KEY_WAIT_FOR)
waitForRelations.put(handler.tag, tags)
}
if (config.hasKey(KEY_SIMULTANEOUS_HANDLERS)) {
val tags = convertHandlerTagsArray(config, KEY_SIMULTANEOUS_HANDLERS)
simultaneousRelations.put(handler.tag, tags)
}
if (config.hasKey(KEY_BLOCKS_HANDLERS)) {
val tags = convertHandlerTagsArray(config, KEY_BLOCKS_HANDLERS)
blockingRelations.put(handler.tag, tags)
}
}
override fun shouldWaitForHandlerFailure(handler: GestureHandler<*>, otherHandler: GestureHandler<*>) =
waitForRelations[handler.tag]?.any { tag -> tag == otherHandler.tag } ?: false
override fun shouldRequireHandlerToWaitForFailure(
handler: GestureHandler<*>,
otherHandler: GestureHandler<*>,
) = blockingRelations[handler.tag]?.any { tag -> tag == otherHandler.tag } ?: false
override fun shouldHandlerBeCancelledBy(handler: GestureHandler<*>, otherHandler: GestureHandler<*>): Boolean {
if (otherHandler is NativeViewGestureHandler) {
return otherHandler.disallowInterruption
}
return false
}
override fun shouldRecognizeSimultaneously(
handler: GestureHandler<*>,
otherHandler: GestureHandler<*>,
) = simultaneousRelations[handler.tag]?.any { tag -> tag == otherHandler.tag } ?: false
fun reset() {
waitForRelations.clear()
simultaneousRelations.clear()
}
companion object {
private const val KEY_WAIT_FOR = "waitFor"
private const val KEY_SIMULTANEOUS_HANDLERS = "simultaneousHandlers"
private const val KEY_BLOCKS_HANDLERS = "blocksHandlers"
}
}

View File

@@ -0,0 +1,739 @@
package com.swmansion.gesturehandler.react
import android.content.Context
import android.util.Log
import android.view.MotionEvent
import com.facebook.react.ReactRootView
import com.facebook.react.bridge.JSApplicationIllegalArgumentException
import com.facebook.react.bridge.ReactApplicationContext
import com.facebook.react.bridge.ReactMethod
import com.facebook.react.bridge.ReadableMap
import com.facebook.react.bridge.ReadableType
import com.facebook.react.bridge.WritableMap
import com.facebook.react.module.annotations.ReactModule
import com.facebook.react.uimanager.PixelUtil
import com.facebook.react.uimanager.events.Event
import com.facebook.soloader.SoLoader
import com.swmansion.common.GestureHandlerStateManager
import com.swmansion.gesturehandler.BuildConfig
import com.swmansion.gesturehandler.NativeRNGestureHandlerModuleSpec
import com.swmansion.gesturehandler.ReanimatedEventDispatcher
import com.swmansion.gesturehandler.core.FlingGestureHandler
import com.swmansion.gesturehandler.core.GestureHandler
import com.swmansion.gesturehandler.core.HoverGestureHandler
import com.swmansion.gesturehandler.core.LongPressGestureHandler
import com.swmansion.gesturehandler.core.ManualGestureHandler
import com.swmansion.gesturehandler.core.NativeViewGestureHandler
import com.swmansion.gesturehandler.core.OnTouchEventListener
import com.swmansion.gesturehandler.core.PanGestureHandler
import com.swmansion.gesturehandler.core.PinchGestureHandler
import com.swmansion.gesturehandler.core.RotationGestureHandler
import com.swmansion.gesturehandler.core.TapGestureHandler
import com.swmansion.gesturehandler.dispatchEvent
import com.swmansion.gesturehandler.react.eventbuilders.FlingGestureHandlerEventDataBuilder
import com.swmansion.gesturehandler.react.eventbuilders.GestureHandlerEventDataBuilder
import com.swmansion.gesturehandler.react.eventbuilders.HoverGestureHandlerEventDataBuilder
import com.swmansion.gesturehandler.react.eventbuilders.LongPressGestureHandlerEventDataBuilder
import com.swmansion.gesturehandler.react.eventbuilders.ManualGestureHandlerEventDataBuilder
import com.swmansion.gesturehandler.react.eventbuilders.NativeGestureHandlerEventDataBuilder
import com.swmansion.gesturehandler.react.eventbuilders.PanGestureHandlerEventDataBuilder
import com.swmansion.gesturehandler.react.eventbuilders.PinchGestureHandlerEventDataBuilder
import com.swmansion.gesturehandler.react.eventbuilders.RotationGestureHandlerEventDataBuilder
import com.swmansion.gesturehandler.react.eventbuilders.TapGestureHandlerEventDataBuilder
// NativeModule.onCatalystInstanceDestroy() was deprecated in favor of NativeModule.invalidate()
// ref: https://github.com/facebook/react-native/commit/18c8417290823e67e211bde241ae9dde27b72f17
// UIManagerModule.resolveRootTagFromReactTag() was deprecated and will be removed in the next RN release
// ref: https://github.com/facebook/react-native/commit/acbf9e18ea666b07c1224a324602a41d0a66985e
@Suppress("DEPRECATION")
@ReactModule(name = RNGestureHandlerModule.NAME)
class RNGestureHandlerModule(reactContext: ReactApplicationContext?) :
NativeRNGestureHandlerModuleSpec(reactContext), GestureHandlerStateManager {
private abstract class HandlerFactory<T : GestureHandler<T>> {
abstract val type: Class<T>
abstract val name: String
abstract fun create(context: Context?): T
open fun configure(handler: T, config: ReadableMap) {
handler.resetConfig()
if (config.hasKey(KEY_SHOULD_CANCEL_WHEN_OUTSIDE)) {
handler.setShouldCancelWhenOutside(config.getBoolean(KEY_SHOULD_CANCEL_WHEN_OUTSIDE))
}
if (config.hasKey(KEY_ENABLED)) {
handler.setEnabled(config.getBoolean(KEY_ENABLED))
}
if (config.hasKey(KEY_HIT_SLOP)) {
handleHitSlopProperty(handler, config)
}
if (config.hasKey(KEY_NEEDS_POINTER_DATA)) {
handler.needsPointerData = config.getBoolean(KEY_NEEDS_POINTER_DATA)
}
if (config.hasKey(KEY_MANUAL_ACTIVATION)) {
handler.setManualActivation(config.getBoolean(KEY_MANUAL_ACTIVATION))
}
if (config.hasKey("mouseButton")) {
handler.setMouseButton(config.getInt("mouseButton"))
}
}
abstract fun createEventBuilder(handler: T): GestureHandlerEventDataBuilder<T>
}
private class NativeViewGestureHandlerFactory : HandlerFactory<NativeViewGestureHandler>() {
override val type = NativeViewGestureHandler::class.java
override val name = "NativeViewGestureHandler"
override fun create(context: Context?): NativeViewGestureHandler {
return NativeViewGestureHandler()
}
override fun configure(handler: NativeViewGestureHandler, config: ReadableMap) {
super.configure(handler, config)
if (config.hasKey(KEY_NATIVE_VIEW_SHOULD_ACTIVATE_ON_START)) {
handler.setShouldActivateOnStart(
config.getBoolean(KEY_NATIVE_VIEW_SHOULD_ACTIVATE_ON_START)
)
}
if (config.hasKey(KEY_NATIVE_VIEW_DISALLOW_INTERRUPTION)) {
handler.setDisallowInterruption(config.getBoolean(KEY_NATIVE_VIEW_DISALLOW_INTERRUPTION))
}
}
override fun createEventBuilder(handler: NativeViewGestureHandler) = NativeGestureHandlerEventDataBuilder(handler)
}
private class TapGestureHandlerFactory : HandlerFactory<TapGestureHandler>() {
override val type = TapGestureHandler::class.java
override val name = "TapGestureHandler"
override fun create(context: Context?): TapGestureHandler {
return TapGestureHandler()
}
override fun configure(handler: TapGestureHandler, config: ReadableMap) {
super.configure(handler, config)
if (config.hasKey(KEY_TAP_NUMBER_OF_TAPS)) {
handler.setNumberOfTaps(config.getInt(KEY_TAP_NUMBER_OF_TAPS))
}
if (config.hasKey(KEY_TAP_MAX_DURATION_MS)) {
handler.setMaxDurationMs(config.getInt(KEY_TAP_MAX_DURATION_MS).toLong())
}
if (config.hasKey(KEY_TAP_MAX_DELAY_MS)) {
handler.setMaxDelayMs(config.getInt(KEY_TAP_MAX_DELAY_MS).toLong())
}
if (config.hasKey(KEY_TAP_MAX_DELTA_X)) {
handler.setMaxDx(PixelUtil.toPixelFromDIP(config.getDouble(KEY_TAP_MAX_DELTA_X)))
}
if (config.hasKey(KEY_TAP_MAX_DELTA_Y)) {
handler.setMaxDy(PixelUtil.toPixelFromDIP(config.getDouble(KEY_TAP_MAX_DELTA_Y)))
}
if (config.hasKey(KEY_TAP_MAX_DIST)) {
handler.setMaxDist(PixelUtil.toPixelFromDIP(config.getDouble(KEY_TAP_MAX_DIST)))
}
if (config.hasKey(KEY_TAP_MIN_POINTERS)) {
handler.setMinNumberOfPointers(config.getInt(KEY_TAP_MIN_POINTERS))
}
}
override fun createEventBuilder(handler: TapGestureHandler) = TapGestureHandlerEventDataBuilder(handler)
}
private class LongPressGestureHandlerFactory : HandlerFactory<LongPressGestureHandler>() {
override val type = LongPressGestureHandler::class.java
override val name = "LongPressGestureHandler"
override fun create(context: Context?): LongPressGestureHandler {
return LongPressGestureHandler((context)!!)
}
override fun configure(handler: LongPressGestureHandler, config: ReadableMap) {
super.configure(handler, config)
if (config.hasKey(KEY_LONG_PRESS_MIN_DURATION_MS)) {
handler.minDurationMs = config.getInt(KEY_LONG_PRESS_MIN_DURATION_MS).toLong()
}
if (config.hasKey(KEY_LONG_PRESS_MAX_DIST)) {
handler.setMaxDist(PixelUtil.toPixelFromDIP(config.getDouble(KEY_LONG_PRESS_MAX_DIST)))
}
}
override fun createEventBuilder(handler: LongPressGestureHandler) = LongPressGestureHandlerEventDataBuilder(handler)
}
private class PanGestureHandlerFactory : HandlerFactory<PanGestureHandler>() {
override val type = PanGestureHandler::class.java
override val name = "PanGestureHandler"
override fun create(context: Context?): PanGestureHandler {
return PanGestureHandler(context)
}
override fun configure(handler: PanGestureHandler, config: ReadableMap) {
super.configure(handler, config)
var hasCustomActivationCriteria = false
if (config.hasKey(KEY_PAN_ACTIVE_OFFSET_X_START)) {
handler.setActiveOffsetXStart(PixelUtil.toPixelFromDIP(config.getDouble(KEY_PAN_ACTIVE_OFFSET_X_START)))
hasCustomActivationCriteria = true
}
if (config.hasKey(KEY_PAN_ACTIVE_OFFSET_X_END)) {
handler.setActiveOffsetXEnd(PixelUtil.toPixelFromDIP(config.getDouble(KEY_PAN_ACTIVE_OFFSET_X_END)))
hasCustomActivationCriteria = true
}
if (config.hasKey(KEY_PAN_FAIL_OFFSET_RANGE_X_START)) {
handler.setFailOffsetXStart(PixelUtil.toPixelFromDIP(config.getDouble(KEY_PAN_FAIL_OFFSET_RANGE_X_START)))
hasCustomActivationCriteria = true
}
if (config.hasKey(KEY_PAN_FAIL_OFFSET_RANGE_X_END)) {
handler.setFailOffsetXEnd(PixelUtil.toPixelFromDIP(config.getDouble(KEY_PAN_FAIL_OFFSET_RANGE_X_END)))
hasCustomActivationCriteria = true
}
if (config.hasKey(KEY_PAN_ACTIVE_OFFSET_Y_START)) {
handler.setActiveOffsetYStart(PixelUtil.toPixelFromDIP(config.getDouble(KEY_PAN_ACTIVE_OFFSET_Y_START)))
hasCustomActivationCriteria = true
}
if (config.hasKey(KEY_PAN_ACTIVE_OFFSET_Y_END)) {
handler.setActiveOffsetYEnd(PixelUtil.toPixelFromDIP(config.getDouble(KEY_PAN_ACTIVE_OFFSET_Y_END)))
hasCustomActivationCriteria = true
}
if (config.hasKey(KEY_PAN_FAIL_OFFSET_RANGE_Y_START)) {
handler.setFailOffsetYStart(PixelUtil.toPixelFromDIP(config.getDouble(KEY_PAN_FAIL_OFFSET_RANGE_Y_START)))
hasCustomActivationCriteria = true
}
if (config.hasKey(KEY_PAN_FAIL_OFFSET_RANGE_Y_END)) {
handler.setFailOffsetYEnd(PixelUtil.toPixelFromDIP(config.getDouble(KEY_PAN_FAIL_OFFSET_RANGE_Y_END)))
hasCustomActivationCriteria = true
}
if (config.hasKey(KEY_PAN_MIN_VELOCITY)) {
// This value is actually in DPs/ms, but we can use the same function as for converting
// from DPs to pixels as the unit we're converting is in the numerator
handler.setMinVelocity(PixelUtil.toPixelFromDIP(config.getDouble(KEY_PAN_MIN_VELOCITY)))
hasCustomActivationCriteria = true
}
if (config.hasKey(KEY_PAN_MIN_VELOCITY_X)) {
handler.setMinVelocityX(PixelUtil.toPixelFromDIP(config.getDouble(KEY_PAN_MIN_VELOCITY_X)))
hasCustomActivationCriteria = true
}
if (config.hasKey(KEY_PAN_MIN_VELOCITY_Y)) {
handler.setMinVelocityY(PixelUtil.toPixelFromDIP(config.getDouble(KEY_PAN_MIN_VELOCITY_Y)))
hasCustomActivationCriteria = true
}
// PanGestureHandler sets minDist by default, if there are custom criteria specified we want
// to reset that setting and use provided criteria instead.
if (config.hasKey(KEY_PAN_MIN_DIST)) {
handler.setMinDist(PixelUtil.toPixelFromDIP(config.getDouble(KEY_PAN_MIN_DIST)))
} else if (hasCustomActivationCriteria) {
handler.setMinDist(Float.MAX_VALUE)
}
if (config.hasKey(KEY_PAN_MIN_POINTERS)) {
handler.setMinPointers(config.getInt(KEY_PAN_MIN_POINTERS))
}
if (config.hasKey(KEY_PAN_MAX_POINTERS)) {
handler.setMaxPointers(config.getInt(KEY_PAN_MAX_POINTERS))
}
if (config.hasKey(KEY_PAN_AVG_TOUCHES)) {
handler.setAverageTouches(config.getBoolean(KEY_PAN_AVG_TOUCHES))
}
if (config.hasKey(KEY_PAN_ACTIVATE_AFTER_LONG_PRESS)) {
handler.setActivateAfterLongPress(config.getInt(KEY_PAN_ACTIVATE_AFTER_LONG_PRESS).toLong())
}
}
override fun createEventBuilder(handler: PanGestureHandler) = PanGestureHandlerEventDataBuilder(handler)
}
private class PinchGestureHandlerFactory : HandlerFactory<PinchGestureHandler>() {
override val type = PinchGestureHandler::class.java
override val name = "PinchGestureHandler"
override fun create(context: Context?): PinchGestureHandler {
return PinchGestureHandler()
}
override fun createEventBuilder(handler: PinchGestureHandler) = PinchGestureHandlerEventDataBuilder(handler)
}
private class FlingGestureHandlerFactory : HandlerFactory<FlingGestureHandler>() {
override val type = FlingGestureHandler::class.java
override val name = "FlingGestureHandler"
override fun create(context: Context?): FlingGestureHandler {
return FlingGestureHandler()
}
override fun configure(handler: FlingGestureHandler, config: ReadableMap) {
super.configure(handler, config)
if (config.hasKey(KEY_NUMBER_OF_POINTERS)) {
handler.numberOfPointersRequired = config.getInt(KEY_NUMBER_OF_POINTERS)
}
if (config.hasKey(KEY_DIRECTION)) {
handler.direction = config.getInt(KEY_DIRECTION)
}
}
override fun createEventBuilder(handler: FlingGestureHandler) = FlingGestureHandlerEventDataBuilder(handler)
}
private class RotationGestureHandlerFactory : HandlerFactory<RotationGestureHandler>() {
override val type = RotationGestureHandler::class.java
override val name = "RotationGestureHandler"
override fun create(context: Context?): RotationGestureHandler {
return RotationGestureHandler()
}
override fun createEventBuilder(handler: RotationGestureHandler) = RotationGestureHandlerEventDataBuilder(handler)
}
private class ManualGestureHandlerFactory : HandlerFactory<ManualGestureHandler>() {
override val type = ManualGestureHandler::class.java
override val name = "ManualGestureHandler"
override fun create(context: Context?): ManualGestureHandler {
return ManualGestureHandler()
}
override fun createEventBuilder(handler: ManualGestureHandler) = ManualGestureHandlerEventDataBuilder(handler)
}
private class HoverGestureHandlerFactory : HandlerFactory<HoverGestureHandler>() {
override val type = HoverGestureHandler::class.java
override val name = "HoverGestureHandler"
override fun create(context: Context?): HoverGestureHandler {
return HoverGestureHandler()
}
override fun createEventBuilder(handler: HoverGestureHandler) = HoverGestureHandlerEventDataBuilder(handler)
}
private val eventListener = object : OnTouchEventListener {
override fun <T : GestureHandler<T>> onHandlerUpdate(handler: T, event: MotionEvent) {
this@RNGestureHandlerModule.onHandlerUpdate(handler)
}
override fun <T : GestureHandler<T>> onStateChange(handler: T, newState: Int, oldState: Int) {
this@RNGestureHandlerModule.onStateChange(handler, newState, oldState)
}
override fun <T : GestureHandler<T>> onTouchEvent(handler: T) {
this@RNGestureHandlerModule.onTouchEvent(handler)
}
}
private val handlerFactories = arrayOf<HandlerFactory<*>>(
NativeViewGestureHandlerFactory(),
TapGestureHandlerFactory(),
LongPressGestureHandlerFactory(),
PanGestureHandlerFactory(),
PinchGestureHandlerFactory(),
RotationGestureHandlerFactory(),
FlingGestureHandlerFactory(),
ManualGestureHandlerFactory(),
HoverGestureHandlerFactory(),
)
val registry: RNGestureHandlerRegistry = RNGestureHandlerRegistry()
private val interactionManager = RNGestureHandlerInteractionManager()
private val roots: MutableList<RNGestureHandlerRootHelper> = ArrayList()
private val reanimatedEventDispatcher = ReanimatedEventDispatcher()
override fun getName() = NAME
@Suppress("UNCHECKED_CAST")
private fun <T : GestureHandler<T>> createGestureHandlerHelper(
handlerName: String,
handlerTag: Int,
config: ReadableMap,
) {
if (registry.getHandler(handlerTag) !== null) {
throw IllegalStateException(
"Handler with tag $handlerTag already exists. Please ensure that no Gesture instance is used across multiple GestureDetectors."
)
}
for (handlerFactory in handlerFactories as Array<HandlerFactory<T>>) {
if (handlerFactory.name == handlerName) {
val handler = handlerFactory.create(reactApplicationContext).apply {
tag = handlerTag
setOnTouchEventListener(eventListener)
}
registry.registerHandler(handler)
interactionManager.configureInteractions(handler, config)
handlerFactory.configure(handler, config)
return
}
}
throw JSApplicationIllegalArgumentException("Invalid handler name $handlerName")
}
@ReactMethod
override fun createGestureHandler(
handlerName: String,
handlerTagDouble: Double,
config: ReadableMap,
) {
val handlerTag = handlerTagDouble.toInt()
createGestureHandlerHelper(handlerName, handlerTag, config)
}
@ReactMethod
override fun attachGestureHandler(handlerTagDouble: Double, viewTagDouble: Double, actionTypeDouble: Double) {
val handlerTag = handlerTagDouble.toInt()
val viewTag = viewTagDouble.toInt()
val actionType = actionTypeDouble.toInt()
// We don't have to handle view flattening in any special way since handlers are stored as
// a map: viewTag -> [handler]. If the view with attached handlers was to be flattened
// then that viewTag simply wouldn't be visited when traversing the view hierarchy in the
// Orchestrator effectively ignoring all handlers attached to flattened views.
if (!registry.attachHandlerToView(handlerTag, viewTag, actionType)) {
throw JSApplicationIllegalArgumentException("Handler with tag $handlerTag does not exists")
}
}
@Suppress("UNCHECKED_CAST")
private fun <T : GestureHandler<T>> updateGestureHandlerHelper(handlerTag: Int, config: ReadableMap) {
val handler = registry.getHandler(handlerTag) as T?
if (handler != null) {
val factory = findFactoryForHandler(handler)
if (factory != null) {
interactionManager.dropRelationsForHandlerWithTag(handlerTag)
interactionManager.configureInteractions(handler, config)
factory.configure(handler, config)
}
}
}
@ReactMethod
override fun updateGestureHandler(handlerTagDouble: Double, config: ReadableMap) {
val handlerTag = handlerTagDouble.toInt()
updateGestureHandlerHelper(handlerTag, config)
}
@ReactMethod
override fun dropGestureHandler(handlerTagDouble: Double) {
val handlerTag = handlerTagDouble.toInt()
interactionManager.dropRelationsForHandlerWithTag(handlerTag)
registry.dropHandler(handlerTag)
}
@ReactMethod
override fun handleSetJSResponder(viewTagDouble: Double, blockNativeResponder: Boolean) {
val viewTag = viewTagDouble.toInt()
val rootView = findRootHelperForViewAncestor(viewTag)
rootView?.handleSetJSResponder(viewTag, blockNativeResponder)
}
@ReactMethod
override fun handleClearJSResponder() {
}
@ReactMethod
override fun flushOperations() {
}
override fun setGestureHandlerState(handlerTag: Int, newState: Int) {
registry.getHandler(handlerTag)?.let { handler ->
when (newState) {
GestureHandler.STATE_ACTIVE -> handler.activate(force = true)
GestureHandler.STATE_BEGAN -> handler.begin()
GestureHandler.STATE_END -> handler.end()
GestureHandler.STATE_FAILED -> handler.fail()
GestureHandler.STATE_CANCELLED -> handler.cancel()
}
}
}
@ReactMethod(isBlockingSynchronousMethod = true)
override fun install(): Boolean {
reactApplicationContext.runOnJSQueueThread {
try {
SoLoader.loadLibrary("gesturehandler")
val jsContext = reactApplicationContext.javaScriptContextHolder!!
decorateRuntime(jsContext.get())
} catch (exception: Exception) {
Log.w("[RNGestureHandler]", "Could not install JSI bindings.")
}
}
return true
}
private external fun decorateRuntime(jsiPtr: Long)
override fun getConstants(): Map<String, Any> {
return mapOf(
"State" to mapOf(
"UNDETERMINED" to GestureHandler.STATE_UNDETERMINED,
"BEGAN" to GestureHandler.STATE_BEGAN,
"ACTIVE" to GestureHandler.STATE_ACTIVE,
"CANCELLED" to GestureHandler.STATE_CANCELLED,
"FAILED" to GestureHandler.STATE_FAILED,
"END" to GestureHandler.STATE_END
),
"Direction" to mapOf(
"RIGHT" to GestureHandler.DIRECTION_RIGHT,
"LEFT" to GestureHandler.DIRECTION_LEFT,
"UP" to GestureHandler.DIRECTION_UP,
"DOWN" to GestureHandler.DIRECTION_DOWN
)
)
}
override fun invalidate() {
registry.dropAllHandlers()
interactionManager.reset()
synchronized(roots) {
while (roots.isNotEmpty()) {
val sizeBefore: Int = roots.size
val root: RNGestureHandlerRootHelper = roots[0]
root.tearDown()
if (roots.size >= sizeBefore) {
throw IllegalStateException("Expected root helper to get unregistered while tearing down")
}
}
}
super.invalidate()
}
fun registerRootHelper(root: RNGestureHandlerRootHelper) {
synchronized(roots) {
if (root in roots) {
throw IllegalStateException("Root helper$root already registered")
}
roots.add(root)
}
}
fun unregisterRootHelper(root: RNGestureHandlerRootHelper) {
synchronized(roots) { roots.remove(root) }
}
private fun findRootHelperForViewAncestor(viewTag: Int): RNGestureHandlerRootHelper? {
// TODO: remove resolveRootTagFromReactTag as it's deprecated and unavailable on FabricUIManager
val uiManager = reactApplicationContext.UIManager
val rootViewTag = uiManager.resolveRootTagFromReactTag(viewTag)
if (rootViewTag < 1) {
return null
}
synchronized(roots) {
return roots.firstOrNull {
it.rootView is ReactRootView && it.rootView.rootViewTag == rootViewTag
}
}
}
@Suppress("UNCHECKED_CAST")
private fun <T : GestureHandler<T>> findFactoryForHandler(handler: GestureHandler<T>): HandlerFactory<T>? =
handlerFactories.firstOrNull { it.type == handler.javaClass } as HandlerFactory<T>?
private fun <T : GestureHandler<T>> onHandlerUpdate(handler: T) {
// triggers onUpdate and onChange callbacks on the JS side
if (handler.tag < 0) {
// root containers use negative tags, we don't need to dispatch events for them to the JS
return
}
if (handler.state == GestureHandler.STATE_ACTIVE) {
val handlerFactory = findFactoryForHandler(handler) ?: return
if (handler.actionType == GestureHandler.ACTION_TYPE_REANIMATED_WORKLET) {
// Reanimated worklet
val event = RNGestureHandlerEvent.obtain(handler, handlerFactory.createEventBuilder(handler))
sendEventForReanimated(event)
} else if (handler.actionType == GestureHandler.ACTION_TYPE_NATIVE_ANIMATED_EVENT) {
// Animated with useNativeDriver: true
val event = RNGestureHandlerEvent.obtain(
handler,
handlerFactory.createEventBuilder(handler),
useTopPrefixedName = BuildConfig.REACT_NATIVE_MINOR_VERSION >= 71
)
sendEventForNativeAnimatedEvent(event)
} else if (handler.actionType == GestureHandler.ACTION_TYPE_JS_FUNCTION_OLD_API) {
// JS function, Animated.event with useNativeDriver: false using old API
if (BuildConfig.IS_NEW_ARCHITECTURE_ENABLED) {
val data = RNGestureHandlerEvent.createEventData(handlerFactory.createEventBuilder(handler))
sendEventForDeviceEvent(RNGestureHandlerEvent.EVENT_NAME, data)
} else {
val event = RNGestureHandlerEvent.obtain(handler, handlerFactory.createEventBuilder(handler))
sendEventForDirectEvent(event)
}
} else if (handler.actionType == GestureHandler.ACTION_TYPE_JS_FUNCTION_NEW_API) {
// JS function, Animated.event with useNativeDriver: false using new API
val data = RNGestureHandlerEvent.createEventData(handlerFactory.createEventBuilder(handler))
sendEventForDeviceEvent(RNGestureHandlerEvent.EVENT_NAME, data)
}
}
}
private fun <T : GestureHandler<T>> onStateChange(handler: T, newState: Int, oldState: Int) {
// triggers onBegin, onStart, onEnd, onFinalize callbacks on the JS side
if (handler.tag < 0) {
// root containers use negative tags, we don't need to dispatch events for them to the JS
return
}
val handlerFactory = findFactoryForHandler(handler) ?: return
if (handler.actionType == GestureHandler.ACTION_TYPE_REANIMATED_WORKLET) {
// Reanimated worklet
val event = RNGestureHandlerStateChangeEvent.obtain(handler, newState, oldState, handlerFactory.createEventBuilder(handler))
sendEventForReanimated(event)
} else if (handler.actionType == GestureHandler.ACTION_TYPE_NATIVE_ANIMATED_EVENT ||
handler.actionType == GestureHandler.ACTION_TYPE_JS_FUNCTION_OLD_API
) {
// JS function or Animated.event with useNativeDriver: false with old API
if (BuildConfig.IS_NEW_ARCHITECTURE_ENABLED) {
val data = RNGestureHandlerStateChangeEvent.createEventData(handlerFactory.createEventBuilder(handler), newState, oldState)
sendEventForDeviceEvent(RNGestureHandlerStateChangeEvent.EVENT_NAME, data)
} else {
val event = RNGestureHandlerStateChangeEvent.obtain(handler, newState, oldState, handlerFactory.createEventBuilder(handler))
sendEventForDirectEvent(event)
}
} else if (handler.actionType == GestureHandler.ACTION_TYPE_JS_FUNCTION_NEW_API) {
// JS function or Animated.event with useNativeDriver: false with new API
val data = RNGestureHandlerStateChangeEvent.createEventData(handlerFactory.createEventBuilder(handler), newState, oldState)
sendEventForDeviceEvent(RNGestureHandlerStateChangeEvent.EVENT_NAME, data)
}
}
private fun <T : GestureHandler<T>> onTouchEvent(handler: T) {
// triggers onTouchesDown, onTouchesMove, onTouchesUp, onTouchesCancelled callbacks on the JS side
if (handler.tag < 0) {
// root containers use negative tags, we don't need to dispatch events for them to the JS
return
}
if (handler.state == GestureHandler.STATE_BEGAN || handler.state == GestureHandler.STATE_ACTIVE ||
handler.state == GestureHandler.STATE_UNDETERMINED || handler.view != null
) {
if (handler.actionType == GestureHandler.ACTION_TYPE_REANIMATED_WORKLET) {
// Reanimated worklet
val event = RNGestureHandlerTouchEvent.obtain(handler)
sendEventForReanimated(event)
} else if (handler.actionType == GestureHandler.ACTION_TYPE_JS_FUNCTION_NEW_API) {
// JS function, Animated.event with useNativeDriver: false with new API
val data = RNGestureHandlerTouchEvent.createEventData(handler)
sendEventForDeviceEvent(RNGestureHandlerEvent.EVENT_NAME, data)
}
}
}
private fun <T : Event<T>>sendEventForReanimated(event: T) {
// Delivers the event to Reanimated.
if (BuildConfig.IS_NEW_ARCHITECTURE_ENABLED) {
// Send event directly to Reanimated
reanimatedEventDispatcher.sendEvent(event, reactApplicationContext)
} else {
// In the old architecture, Reanimated subscribes for specific direct events.
sendEventForDirectEvent(event)
}
}
private fun sendEventForNativeAnimatedEvent(event: RNGestureHandlerEvent) {
// Delivers the event to NativeAnimatedModule.
// TODO: send event directly to NativeAnimated[Turbo]Module
// ReactContext.dispatchEvent is an extension function, depending on the architecture it will
// dispatch event using UIManagerModule or FabricUIManager.
reactApplicationContext.dispatchEvent(event)
}
private fun <T : Event<T>>sendEventForDirectEvent(event: T) {
// Delivers the event to JS as a direct event. This method is called only on Paper.
reactApplicationContext.dispatchEvent(event)
}
private fun sendEventForDeviceEvent(eventName: String, data: WritableMap) {
// Delivers the event to JS as a device event.
reactApplicationContext.deviceEventEmitter.emit(eventName, data)
}
companion object {
const val NAME = "RNGestureHandlerModule"
private const val KEY_SHOULD_CANCEL_WHEN_OUTSIDE = "shouldCancelWhenOutside"
private const val KEY_ENABLED = "enabled"
private const val KEY_NEEDS_POINTER_DATA = "needsPointerData"
private const val KEY_MANUAL_ACTIVATION = "manualActivation"
private const val KEY_HIT_SLOP = "hitSlop"
private const val KEY_HIT_SLOP_LEFT = "left"
private const val KEY_HIT_SLOP_TOP = "top"
private const val KEY_HIT_SLOP_RIGHT = "right"
private const val KEY_HIT_SLOP_BOTTOM = "bottom"
private const val KEY_HIT_SLOP_VERTICAL = "vertical"
private const val KEY_HIT_SLOP_HORIZONTAL = "horizontal"
private const val KEY_HIT_SLOP_WIDTH = "width"
private const val KEY_HIT_SLOP_HEIGHT = "height"
private const val KEY_NATIVE_VIEW_SHOULD_ACTIVATE_ON_START = "shouldActivateOnStart"
private const val KEY_NATIVE_VIEW_DISALLOW_INTERRUPTION = "disallowInterruption"
private const val KEY_TAP_NUMBER_OF_TAPS = "numberOfTaps"
private const val KEY_TAP_MAX_DURATION_MS = "maxDurationMs"
private const val KEY_TAP_MAX_DELAY_MS = "maxDelayMs"
private const val KEY_TAP_MAX_DELTA_X = "maxDeltaX"
private const val KEY_TAP_MAX_DELTA_Y = "maxDeltaY"
private const val KEY_TAP_MAX_DIST = "maxDist"
private const val KEY_TAP_MIN_POINTERS = "minPointers"
private const val KEY_LONG_PRESS_MIN_DURATION_MS = "minDurationMs"
private const val KEY_LONG_PRESS_MAX_DIST = "maxDist"
private const val KEY_PAN_ACTIVE_OFFSET_X_START = "activeOffsetXStart"
private const val KEY_PAN_ACTIVE_OFFSET_X_END = "activeOffsetXEnd"
private const val KEY_PAN_FAIL_OFFSET_RANGE_X_START = "failOffsetXStart"
private const val KEY_PAN_FAIL_OFFSET_RANGE_X_END = "failOffsetXEnd"
private const val KEY_PAN_ACTIVE_OFFSET_Y_START = "activeOffsetYStart"
private const val KEY_PAN_ACTIVE_OFFSET_Y_END = "activeOffsetYEnd"
private const val KEY_PAN_FAIL_OFFSET_RANGE_Y_START = "failOffsetYStart"
private const val KEY_PAN_FAIL_OFFSET_RANGE_Y_END = "failOffsetYEnd"
private const val KEY_PAN_MIN_DIST = "minDist"
private const val KEY_PAN_MIN_VELOCITY = "minVelocity"
private const val KEY_PAN_MIN_VELOCITY_X = "minVelocityX"
private const val KEY_PAN_MIN_VELOCITY_Y = "minVelocityY"
private const val KEY_PAN_MIN_POINTERS = "minPointers"
private const val KEY_PAN_MAX_POINTERS = "maxPointers"
private const val KEY_PAN_AVG_TOUCHES = "avgTouches"
private const val KEY_PAN_ACTIVATE_AFTER_LONG_PRESS = "activateAfterLongPress"
private const val KEY_NUMBER_OF_POINTERS = "numberOfPointers"
private const val KEY_DIRECTION = "direction"
private fun handleHitSlopProperty(handler: GestureHandler<*>, config: ReadableMap) {
if (config.getType(KEY_HIT_SLOP) == ReadableType.Number) {
val hitSlop = PixelUtil.toPixelFromDIP(config.getDouble(KEY_HIT_SLOP))
handler.setHitSlop(hitSlop, hitSlop, hitSlop, hitSlop, GestureHandler.HIT_SLOP_NONE, GestureHandler.HIT_SLOP_NONE)
} else {
val hitSlop = config.getMap(KEY_HIT_SLOP)!!
var left = GestureHandler.HIT_SLOP_NONE
var top = GestureHandler.HIT_SLOP_NONE
var right = GestureHandler.HIT_SLOP_NONE
var bottom = GestureHandler.HIT_SLOP_NONE
var width = GestureHandler.HIT_SLOP_NONE
var height = GestureHandler.HIT_SLOP_NONE
if (hitSlop.hasKey(KEY_HIT_SLOP_HORIZONTAL)) {
val horizontalPad = PixelUtil.toPixelFromDIP(hitSlop.getDouble(KEY_HIT_SLOP_HORIZONTAL))
right = horizontalPad
left = right
}
if (hitSlop.hasKey(KEY_HIT_SLOP_VERTICAL)) {
val verticalPad = PixelUtil.toPixelFromDIP(hitSlop.getDouble(KEY_HIT_SLOP_VERTICAL))
bottom = verticalPad
top = bottom
}
if (hitSlop.hasKey(KEY_HIT_SLOP_LEFT)) {
left = PixelUtil.toPixelFromDIP(hitSlop.getDouble(KEY_HIT_SLOP_LEFT))
}
if (hitSlop.hasKey(KEY_HIT_SLOP_TOP)) {
top = PixelUtil.toPixelFromDIP(hitSlop.getDouble(KEY_HIT_SLOP_TOP))
}
if (hitSlop.hasKey(KEY_HIT_SLOP_RIGHT)) {
right = PixelUtil.toPixelFromDIP(hitSlop.getDouble(KEY_HIT_SLOP_RIGHT))
}
if (hitSlop.hasKey(KEY_HIT_SLOP_BOTTOM)) {
bottom = PixelUtil.toPixelFromDIP(hitSlop.getDouble(KEY_HIT_SLOP_BOTTOM))
}
if (hitSlop.hasKey(KEY_HIT_SLOP_WIDTH)) {
width = PixelUtil.toPixelFromDIP(hitSlop.getDouble(KEY_HIT_SLOP_WIDTH))
}
if (hitSlop.hasKey(KEY_HIT_SLOP_HEIGHT)) {
height = PixelUtil.toPixelFromDIP(hitSlop.getDouble(KEY_HIT_SLOP_HEIGHT))
}
handler.setHitSlop(left, top, right, bottom, width, height)
}
}
}
}

View File

@@ -0,0 +1,100 @@
package com.swmansion.gesturehandler.react
import android.util.SparseArray
import android.view.View
import com.facebook.react.bridge.UiThreadUtil
import com.swmansion.gesturehandler.core.GestureHandler
import com.swmansion.gesturehandler.core.GestureHandlerRegistry
import java.util.*
class RNGestureHandlerRegistry : GestureHandlerRegistry {
private val handlers = SparseArray<GestureHandler<*>>()
private val attachedTo = SparseArray<Int?>()
private val handlersForView = SparseArray<ArrayList<GestureHandler<*>>>()
@Synchronized
fun registerHandler(handler: GestureHandler<*>) {
handlers.put(handler.tag, handler)
}
@Synchronized
fun getHandler(handlerTag: Int): GestureHandler<*>? {
return handlers[handlerTag]
}
@Synchronized
fun attachHandlerToView(handlerTag: Int, viewTag: Int, actionType: Int): Boolean {
val handler = handlers[handlerTag]
return handler?.let {
detachHandler(handler)
handler.actionType = actionType
registerHandlerForViewWithTag(viewTag, handler)
true
} ?: false
}
@Synchronized
private fun registerHandlerForViewWithTag(viewTag: Int, handler: GestureHandler<*>) {
check(attachedTo[handler.tag] == null) { "Handler $handler already attached" }
attachedTo.put(handler.tag, viewTag)
var listToAdd = handlersForView[viewTag]
if (listToAdd == null) {
listToAdd = ArrayList(1)
listToAdd.add(handler)
handlersForView.put(viewTag, listToAdd)
} else {
synchronized(listToAdd) {
listToAdd.add(handler)
}
}
}
@Synchronized
private fun detachHandler(handler: GestureHandler<*>) {
val attachedToView = attachedTo[handler.tag]
if (attachedToView != null) {
attachedTo.remove(handler.tag)
val attachedHandlers = handlersForView[attachedToView]
if (attachedHandlers != null) {
synchronized(attachedHandlers) {
attachedHandlers.remove(handler)
}
if (attachedHandlers.size == 0) {
handlersForView.remove(attachedToView)
}
}
}
if (handler.view != null) {
// Handler is in "prepared" state which means it is registered in the orchestrator and can
// receive touch events. This means that before we remove it from the registry we need to
// "cancel" it so that orchestrator does no longer keep a reference to it.
UiThreadUtil.runOnUiThread { handler.cancel() }
}
}
@Synchronized
fun dropHandler(handlerTag: Int) {
handlers[handlerTag]?.let {
detachHandler(it)
handlers.remove(handlerTag)
}
}
@Synchronized
fun dropAllHandlers() {
handlers.clear()
attachedTo.clear()
handlersForView.clear()
}
@Synchronized
fun getHandlersForViewWithTag(viewTag: Int): ArrayList<GestureHandler<*>>? {
return handlersForView[viewTag]
}
@Synchronized
override fun getHandlersForView(view: View): ArrayList<GestureHandler<*>>? {
return getHandlersForViewWithTag(view.id)
}
}

View File

@@ -0,0 +1,145 @@
package com.swmansion.gesturehandler.react
import android.os.SystemClock
import android.util.Log
import android.view.MotionEvent
import android.view.View
import android.view.ViewGroup
import android.view.ViewParent
import com.facebook.react.bridge.ReactContext
import com.facebook.react.bridge.UiThreadUtil
import com.facebook.react.common.ReactConstants
import com.facebook.react.uimanager.RootView
import com.facebook.react.uimanager.ThemedReactContext
import com.swmansion.gesturehandler.core.GestureHandler
import com.swmansion.gesturehandler.core.GestureHandlerOrchestrator
class RNGestureHandlerRootHelper(private val context: ReactContext, wrappedView: ViewGroup) {
private val orchestrator: GestureHandlerOrchestrator?
private val jsGestureHandler: GestureHandler<*>?
val rootView: ViewGroup
private var shouldIntercept = false
private var passingTouch = false
init {
UiThreadUtil.assertOnUiThread()
val wrappedViewTag = wrappedView.id
check(wrappedViewTag >= 1) { "Expect view tag to be set for $wrappedView" }
val module = (context as ThemedReactContext).reactApplicationContext.getNativeModule(RNGestureHandlerModule::class.java)!!
val registry = module.registry
rootView = findRootViewTag(wrappedView)
Log.i(
ReactConstants.TAG,
"[GESTURE HANDLER] Initialize gesture handler for root view $rootView"
)
orchestrator = GestureHandlerOrchestrator(
wrappedView, registry, RNViewConfigurationHelper()
).apply {
minimumAlphaForTraversal = MIN_ALPHA_FOR_TOUCH
}
jsGestureHandler = RootViewGestureHandler().apply { tag = -wrappedViewTag }
with(registry) {
registerHandler(jsGestureHandler)
attachHandlerToView(jsGestureHandler.tag, wrappedViewTag, GestureHandler.ACTION_TYPE_JS_FUNCTION_OLD_API)
}
module.registerRootHelper(this)
}
fun tearDown() {
Log.i(
ReactConstants.TAG,
"[GESTURE HANDLER] Tearing down gesture handler registered for root view $rootView"
)
val module = (context as ThemedReactContext).reactApplicationContext.getNativeModule(RNGestureHandlerModule::class.java)!!
with(module) {
registry.dropHandler(jsGestureHandler!!.tag)
unregisterRootHelper(this@RNGestureHandlerRootHelper)
}
}
private inner class RootViewGestureHandler : GestureHandler<RootViewGestureHandler>() {
override fun onHandle(event: MotionEvent, sourceEvent: MotionEvent) {
val currentState = state
// we shouldn't stop intercepting events when there is an active handler already, which could happen when
// adding a new pointer to the screen after a handler activates
if (currentState == STATE_UNDETERMINED && (!shouldIntercept || orchestrator?.isAnyHandlerActive() != true)) {
begin()
shouldIntercept = false
}
if (event.actionMasked == MotionEvent.ACTION_UP) {
end()
}
}
override fun onCancel() {
shouldIntercept = true
val time = SystemClock.uptimeMillis()
val event = MotionEvent.obtain(time, time, MotionEvent.ACTION_CANCEL, 0f, 0f, 0).apply {
action = MotionEvent.ACTION_CANCEL
}
if (rootView is RootView) {
rootView.onChildStartedNativeGesture(rootView, event)
}
event.recycle()
}
}
fun requestDisallowInterceptTouchEvent() {
// If this method gets called it means that some native view is attempting to grab lock for
// touch event delivery. In that case we cancel all gesture recognizers
if (orchestrator != null && !passingTouch) {
// if we are in the process of delivering touch events via GH orchestrator, we don't want to
// treat it as a native gesture capturing the lock
tryCancelAllHandlers()
}
}
fun dispatchTouchEvent(ev: MotionEvent): Boolean {
// We mark `mPassingTouch` before we get into `mOrchestrator.onTouchEvent` so that we can tell
// if `requestDisallow` has been called as a result of a normal gesture handling process or
// as a result of one of the gesture handlers activating
passingTouch = true
orchestrator!!.onTouchEvent(ev)
passingTouch = false
return shouldIntercept
}
private fun tryCancelAllHandlers() {
// In order to cancel handlers we activate handler that is hooked to the root view
jsGestureHandler?.apply {
if (state == GestureHandler.STATE_BEGAN) {
// Try activate main JS handler
activate()
end()
}
}
}
/*package*/
@Suppress("UNUSED_PARAMETER", "COMMENT_IN_SUPPRESSION")
// We want to keep order of parameters, so instead of removing viewTag we suppress the warning
fun handleSetJSResponder(viewTag: Int, blockNativeResponder: Boolean) {
if (blockNativeResponder) {
UiThreadUtil.runOnUiThread { tryCancelAllHandlers() }
}
}
fun activateNativeHandlers(view: View) {
orchestrator?.activateNativeHandlersForView(view)
}
companion object {
private const val MIN_ALPHA_FOR_TOUCH = 0.1f
private fun findRootViewTag(viewGroup: ViewGroup): ViewGroup {
UiThreadUtil.assertOnUiThread()
var parent: ViewParent? = viewGroup
while (parent != null && parent !is RootView) {
parent = parent.parent
}
checkNotNull(parent) {
"View $viewGroup has not been mounted under ReactRootView"
}
return parent as ViewGroup
}
}
}

View File

@@ -0,0 +1,5 @@
package com.swmansion.gesturehandler.react
interface RNGestureHandlerRootInterface {
val rootHelper: RNGestureHandlerRootHelper?
}

View File

@@ -0,0 +1,78 @@
package com.swmansion.gesturehandler.react
import android.content.Context
import android.util.Log
import android.view.MotionEvent
import android.view.View
import android.view.ViewGroup
import com.facebook.react.bridge.ReactContext
import com.facebook.react.bridge.UiThreadUtil
import com.facebook.react.common.ReactConstants
import com.facebook.react.uimanager.RootView
import com.facebook.react.views.view.ReactViewGroup
class RNGestureHandlerRootView(context: Context?) : ReactViewGroup(context) {
private var _enabled = false
private var rootHelper: RNGestureHandlerRootHelper? = null // TODO: resettable lateinit
override fun onAttachedToWindow() {
super.onAttachedToWindow()
_enabled = !hasGestureHandlerEnabledRootView(this)
if (!_enabled) {
Log.i(
ReactConstants.TAG,
"[GESTURE HANDLER] Gesture handler is already enabled for a parent view"
)
}
if (_enabled && rootHelper == null) {
rootHelper = RNGestureHandlerRootHelper(context as ReactContext, this)
}
}
fun tearDown() {
rootHelper?.tearDown()
}
override fun dispatchTouchEvent(ev: MotionEvent) =
if (_enabled && rootHelper!!.dispatchTouchEvent(ev)) {
true
} else super.dispatchTouchEvent(ev)
override fun dispatchGenericMotionEvent(event: MotionEvent) =
if (_enabled && rootHelper!!.dispatchTouchEvent(event)) {
true
} else super.dispatchGenericMotionEvent(event)
override fun requestDisallowInterceptTouchEvent(disallowIntercept: Boolean) {
if (_enabled) {
rootHelper!!.requestDisallowInterceptTouchEvent()
}
super.requestDisallowInterceptTouchEvent(disallowIntercept)
}
fun activateNativeHandlers(view: View) {
rootHelper?.activateNativeHandlers(view)
}
companion object {
private fun hasGestureHandlerEnabledRootView(viewGroup: ViewGroup): Boolean {
UiThreadUtil.assertOnUiThread()
var parent = viewGroup.parent
while (parent != null) {
// our own deprecated root view
@Suppress("DEPRECATION")
if (parent is RNGestureHandlerEnabledRootView || parent is RNGestureHandlerRootView) {
return true
}
// Checks other roots views but it's mainly for ReactModalHostView.DialogRootViewGroup
// since modals are outside RN hierachy and we have to initialize GH's root view for it
// Note that RNGestureHandlerEnabledRootView implements RootView - that's why this check has to be below
if (parent is RootView) {
return false
}
parent = parent.parent
}
return false
}
}
}

View File

@@ -0,0 +1,51 @@
package com.swmansion.gesturehandler.react
import com.facebook.react.module.annotations.ReactModule
import com.facebook.react.uimanager.ThemedReactContext
import com.facebook.react.uimanager.ViewGroupManager
import com.facebook.react.uimanager.ViewManagerDelegate
import com.facebook.react.viewmanagers.RNGestureHandlerRootViewManagerDelegate
import com.facebook.react.viewmanagers.RNGestureHandlerRootViewManagerInterface
/**
* React native's view manager used for creating instances of []RNGestureHandlerRootView}. It
* is being used by projects using react-native-navigation where for each screen new root view need
* to be provided.
*/
@ReactModule(name = RNGestureHandlerRootViewManager.REACT_CLASS)
class RNGestureHandlerRootViewManager :
ViewGroupManager<RNGestureHandlerRootView>(),
RNGestureHandlerRootViewManagerInterface<RNGestureHandlerRootView> {
private val mDelegate: ViewManagerDelegate<RNGestureHandlerRootView>
init {
mDelegate = RNGestureHandlerRootViewManagerDelegate<RNGestureHandlerRootView, RNGestureHandlerRootViewManager>(this)
}
override fun getDelegate(): ViewManagerDelegate<RNGestureHandlerRootView> {
return mDelegate
}
override fun getName() = REACT_CLASS
override fun createViewInstance(reactContext: ThemedReactContext) = RNGestureHandlerRootView(reactContext)
override fun onDropViewInstance(view: RNGestureHandlerRootView) {
view.tearDown()
}
/**
* The following event configuration is necessary even if you are not using
* GestureHandlerRootView component directly.
*/
override fun getExportedCustomDirectEventTypeConstants(): Map<String, Map<String, String>> = mutableMapOf(
RNGestureHandlerEvent.EVENT_NAME to
mutableMapOf("registrationName" to RNGestureHandlerEvent.EVENT_NAME),
RNGestureHandlerStateChangeEvent.EVENT_NAME to
mutableMapOf("registrationName" to RNGestureHandlerStateChangeEvent.EVENT_NAME)
)
companion object {
const val REACT_CLASS = "RNGestureHandlerRootView"
}
}

View File

@@ -0,0 +1,75 @@
// 1. RCTEventEmitter was deprecated in favor of RCTModernEventEmitter interface
// 2. Event#init() with only viewTag was deprecated in favor of two arg c-tor
// 3. Event#receiveEvent() with 3 args was deprecated in favor of 4 args version
// ref: https://github.com/facebook/react-native/commit/2fbbdbb2ce897e8da3f471b08b93f167d566db1d
@file:Suppress("DEPRECATION")
package com.swmansion.gesturehandler.react
import androidx.core.util.Pools
import com.facebook.react.bridge.Arguments
import com.facebook.react.bridge.WritableMap
import com.facebook.react.uimanager.events.Event
import com.swmansion.gesturehandler.core.GestureHandler
import com.swmansion.gesturehandler.react.eventbuilders.GestureHandlerEventDataBuilder
class RNGestureHandlerStateChangeEvent private constructor() : Event<RNGestureHandlerStateChangeEvent>() {
private var dataBuilder: GestureHandlerEventDataBuilder<*>? = null
private var newState: Int = GestureHandler.STATE_UNDETERMINED
private var oldState: Int = GestureHandler.STATE_UNDETERMINED
private fun <T : GestureHandler<T>> init(
handler: T,
newState: Int,
oldState: Int,
dataBuilder: GestureHandlerEventDataBuilder<T>,
) {
super.init(handler.view!!.id)
this.dataBuilder = dataBuilder
this.newState = newState
this.oldState = oldState
}
override fun onDispose() {
dataBuilder = null
newState = GestureHandler.STATE_UNDETERMINED
oldState = GestureHandler.STATE_UNDETERMINED
EVENTS_POOL.release(this)
}
override fun getEventName() = EVENT_NAME
// TODO: coalescing
override fun canCoalesce() = false
// TODO: coalescing
override fun getCoalescingKey(): Short = 0
override fun getEventData(): WritableMap = createEventData(dataBuilder!!, newState, oldState)
companion object {
const val EVENT_NAME = "onGestureHandlerStateChange"
private const val TOUCH_EVENTS_POOL_SIZE = 7 // magic
private val EVENTS_POOL = Pools.SynchronizedPool<RNGestureHandlerStateChangeEvent>(TOUCH_EVENTS_POOL_SIZE)
fun <T : GestureHandler<T>> obtain(
handler: T,
newState: Int,
oldState: Int,
dataBuilder: GestureHandlerEventDataBuilder<T>,
): RNGestureHandlerStateChangeEvent =
(EVENTS_POOL.acquire() ?: RNGestureHandlerStateChangeEvent()).apply {
init(handler, newState, oldState, dataBuilder)
}
fun createEventData(
dataBuilder: GestureHandlerEventDataBuilder<*>,
newState: Int,
oldState: Int,
): WritableMap = Arguments.createMap().apply {
dataBuilder.buildEventData(this)
putInt("state", newState)
putInt("oldState", oldState)
}
}
}

View File

@@ -0,0 +1,66 @@
package com.swmansion.gesturehandler.react
import androidx.core.util.Pools
import com.facebook.react.bridge.Arguments
import com.facebook.react.bridge.WritableMap
import com.facebook.react.uimanager.UIManagerHelper
import com.facebook.react.uimanager.events.Event
import com.swmansion.gesturehandler.core.GestureHandler
class RNGestureHandlerTouchEvent private constructor() : Event<RNGestureHandlerTouchEvent>() {
private var extraData: WritableMap? = null
private var coalescingKey: Short = 0
private fun <T : GestureHandler<T>> init(handler: T) {
super.init(UIManagerHelper.getSurfaceId(handler.view), handler.view!!.id)
extraData = createEventData(handler)
coalescingKey = handler.eventCoalescingKey
}
override fun onDispose() {
extraData = null
EVENTS_POOL.release(this)
}
override fun getEventName() = EVENT_NAME
override fun canCoalesce() = true
override fun getCoalescingKey() = coalescingKey
override fun getEventData(): WritableMap? = extraData
companion object {
const val EVENT_UNDETERMINED = 0
const val EVENT_TOUCH_DOWN = 1
const val EVENT_TOUCH_MOVE = 2
const val EVENT_TOUCH_UP = 3
const val EVENT_TOUCH_CANCELLED = 4
const val EVENT_NAME = "onGestureHandlerEvent"
private const val TOUCH_EVENTS_POOL_SIZE = 7 // magic
private val EVENTS_POOL = Pools.SynchronizedPool<RNGestureHandlerTouchEvent>(TOUCH_EVENTS_POOL_SIZE)
fun <T : GestureHandler<T>> obtain(handler: T): RNGestureHandlerTouchEvent =
(EVENTS_POOL.acquire() ?: RNGestureHandlerTouchEvent()).apply {
init(handler)
}
fun <T : GestureHandler<T>> createEventData(handler: T): WritableMap = Arguments.createMap().apply {
putInt("handlerTag", handler.tag)
putInt("state", handler.state)
putInt("numberOfTouches", handler.trackedPointersCount)
putInt("eventType", handler.touchEventType)
handler.consumeChangedTouchesPayload()?.let {
putArray("changedTouches", it)
}
handler.consumeAllTouchesPayload()?.let {
putArray("allTouches", it)
}
if (handler.isAwaiting && handler.state == GestureHandler.STATE_ACTIVE) {
putInt("state", GestureHandler.STATE_BEGAN)
}
}
}
}

View File

@@ -0,0 +1,51 @@
package com.swmansion.gesturehandler.react
import android.view.View
import android.view.ViewGroup
import com.facebook.react.uimanager.PointerEvents
import com.facebook.react.uimanager.ReactPointerEventsView
import com.facebook.react.views.view.ReactViewGroup
import com.swmansion.gesturehandler.core.PointerEventsConfig
import com.swmansion.gesturehandler.core.ViewConfigurationHelper
class RNViewConfigurationHelper : ViewConfigurationHelper {
override fun getPointerEventsConfigForView(view: View): PointerEventsConfig {
val pointerEvents: PointerEvents =
if (view is ReactPointerEventsView) {
(view as ReactPointerEventsView).pointerEvents
} else PointerEvents.AUTO
// Views that are disabled should never be the target of pointer events. However, their children
// can be because some views (SwipeRefreshLayout) use enabled but still have children that can
// be valid targets.
if (!view.isEnabled) {
if (pointerEvents == PointerEvents.AUTO) {
return PointerEventsConfig.BOX_NONE
} else if (pointerEvents == PointerEvents.BOX_ONLY) {
return PointerEventsConfig.NONE
}
}
return when (pointerEvents) {
PointerEvents.BOX_ONLY -> PointerEventsConfig.BOX_ONLY
PointerEvents.BOX_NONE -> PointerEventsConfig.BOX_NONE
PointerEvents.NONE -> PointerEventsConfig.NONE
PointerEvents.AUTO -> PointerEventsConfig.AUTO
}
}
override fun getChildInDrawingOrderAtIndex(parent: ViewGroup, index: Int): View {
return if (parent is ReactViewGroup) {
parent.getChildAt(parent.getZIndexMappedChildIndex(index))
} else parent.getChildAt(index)
}
override fun isViewClippingChildren(view: ViewGroup): Boolean {
if (view.clipChildren) {
return true
}
return if (view is ReactViewGroup) {
"hidden" == view.overflow
} else false
}
}

View File

@@ -0,0 +1,30 @@
package com.swmansion.gesturehandler.react.eventbuilders
import com.facebook.react.bridge.WritableMap
import com.facebook.react.uimanager.PixelUtil
import com.swmansion.gesturehandler.core.FlingGestureHandler
class FlingGestureHandlerEventDataBuilder(handler: FlingGestureHandler) : GestureHandlerEventDataBuilder<FlingGestureHandler>(handler) {
private val x: Float
private val y: Float
private val absoluteX: Float
private val absoluteY: Float
init {
x = handler.lastRelativePositionX
y = handler.lastRelativePositionY
absoluteX = handler.lastPositionInWindowX
absoluteY = handler.lastPositionInWindowY
}
override fun buildEventData(eventData: WritableMap) {
super.buildEventData(eventData)
with(eventData) {
putDouble("x", PixelUtil.toDIPFromPixel(x).toDouble())
putDouble("y", PixelUtil.toDIPFromPixel(y).toDouble())
putDouble("absoluteX", PixelUtil.toDIPFromPixel(absoluteX).toDouble())
putDouble("absoluteY", PixelUtil.toDIPFromPixel(absoluteY).toDouble())
}
}
}

View File

@@ -0,0 +1,25 @@
package com.swmansion.gesturehandler.react.eventbuilders
import com.facebook.react.bridge.WritableMap
import com.swmansion.gesturehandler.core.GestureHandler
abstract class GestureHandlerEventDataBuilder<T : GestureHandler<T>>(handler: T) {
private val numberOfPointers: Int
private val handlerTag: Int
private val state: Int
private val pointerType: Int
init {
numberOfPointers = handler.numberOfPointers
handlerTag = handler.tag
state = handler.state
pointerType = handler.pointerType
}
open fun buildEventData(eventData: WritableMap) {
eventData.putInt("numberOfPointers", numberOfPointers)
eventData.putInt("handlerTag", handlerTag)
eventData.putInt("state", state)
eventData.putInt("pointerType", pointerType)
}
}

View File

@@ -0,0 +1,30 @@
package com.swmansion.gesturehandler.react.eventbuilders
import com.facebook.react.bridge.WritableMap
import com.facebook.react.uimanager.PixelUtil
import com.swmansion.gesturehandler.core.HoverGestureHandler
class HoverGestureHandlerEventDataBuilder(handler: HoverGestureHandler) : GestureHandlerEventDataBuilder<HoverGestureHandler>(handler) {
private val x: Float
private val y: Float
private val absoluteX: Float
private val absoluteY: Float
init {
x = handler.lastRelativePositionX
y = handler.lastRelativePositionY
absoluteX = handler.lastPositionInWindowX
absoluteY = handler.lastPositionInWindowY
}
override fun buildEventData(eventData: WritableMap) {
super.buildEventData(eventData)
with(eventData) {
putDouble("x", PixelUtil.toDIPFromPixel(x).toDouble())
putDouble("y", PixelUtil.toDIPFromPixel(y).toDouble())
putDouble("absoluteX", PixelUtil.toDIPFromPixel(absoluteX).toDouble())
putDouble("absoluteY", PixelUtil.toDIPFromPixel(absoluteY).toDouble())
}
}
}

View File

@@ -0,0 +1,33 @@
package com.swmansion.gesturehandler.react.eventbuilders
import com.facebook.react.bridge.WritableMap
import com.facebook.react.uimanager.PixelUtil
import com.swmansion.gesturehandler.core.LongPressGestureHandler
class LongPressGestureHandlerEventDataBuilder(handler: LongPressGestureHandler) : GestureHandlerEventDataBuilder<LongPressGestureHandler>(handler) {
private val x: Float
private val y: Float
private val absoluteX: Float
private val absoluteY: Float
private val duration: Int
init {
x = handler.lastRelativePositionX
y = handler.lastRelativePositionY
absoluteX = handler.lastPositionInWindowX
absoluteY = handler.lastPositionInWindowY
duration = handler.duration
}
override fun buildEventData(eventData: WritableMap) {
super.buildEventData(eventData)
with(eventData) {
putDouble("x", PixelUtil.toDIPFromPixel(x).toDouble())
putDouble("y", PixelUtil.toDIPFromPixel(y).toDouble())
putDouble("absoluteX", PixelUtil.toDIPFromPixel(absoluteX).toDouble())
putDouble("absoluteY", PixelUtil.toDIPFromPixel(absoluteY).toDouble())
putInt("duration", duration)
}
}
}

View File

@@ -0,0 +1,5 @@
package com.swmansion.gesturehandler.react.eventbuilders
import com.swmansion.gesturehandler.core.ManualGestureHandler
class ManualGestureHandlerEventDataBuilder(handler: ManualGestureHandler) : GestureHandlerEventDataBuilder<ManualGestureHandler>(handler)

View File

@@ -0,0 +1,18 @@
package com.swmansion.gesturehandler.react.eventbuilders
import com.facebook.react.bridge.WritableMap
import com.swmansion.gesturehandler.core.NativeViewGestureHandler
class NativeGestureHandlerEventDataBuilder(handler: NativeViewGestureHandler) : GestureHandlerEventDataBuilder<NativeViewGestureHandler>(handler) {
private val pointerInside: Boolean
init {
pointerInside = handler.isWithinBounds
}
override fun buildEventData(eventData: WritableMap) {
super.buildEventData(eventData)
eventData.putBoolean("pointerInside", pointerInside)
}
}

View File

@@ -0,0 +1,42 @@
package com.swmansion.gesturehandler.react.eventbuilders
import com.facebook.react.bridge.WritableMap
import com.facebook.react.uimanager.PixelUtil
import com.swmansion.gesturehandler.core.PanGestureHandler
class PanGestureHandlerEventDataBuilder(handler: PanGestureHandler) : GestureHandlerEventDataBuilder<PanGestureHandler>(handler) {
private val x: Float
private val y: Float
private val absoluteX: Float
private val absoluteY: Float
private val translationX: Float
private val translationY: Float
private val velocityX: Float
private val velocityY: Float
init {
x = handler.lastRelativePositionX
y = handler.lastRelativePositionY
absoluteX = handler.lastPositionInWindowX
absoluteY = handler.lastPositionInWindowY
translationX = handler.translationX
translationY = handler.translationY
velocityX = handler.velocityX
velocityY = handler.velocityY
}
override fun buildEventData(eventData: WritableMap) {
super.buildEventData(eventData)
with(eventData) {
putDouble("x", PixelUtil.toDIPFromPixel(x).toDouble())
putDouble("y", PixelUtil.toDIPFromPixel(y).toDouble())
putDouble("absoluteX", PixelUtil.toDIPFromPixel(absoluteX).toDouble())
putDouble("absoluteY", PixelUtil.toDIPFromPixel(absoluteY).toDouble())
putDouble("translationX", PixelUtil.toDIPFromPixel(translationX).toDouble())
putDouble("translationY", PixelUtil.toDIPFromPixel(translationY).toDouble())
putDouble("velocityX", PixelUtil.toDIPFromPixel(velocityX).toDouble())
putDouble("velocityY", PixelUtil.toDIPFromPixel(velocityY).toDouble())
}
}
}

View File

@@ -0,0 +1,30 @@
package com.swmansion.gesturehandler.react.eventbuilders
import com.facebook.react.bridge.WritableMap
import com.facebook.react.uimanager.PixelUtil
import com.swmansion.gesturehandler.core.PinchGestureHandler
class PinchGestureHandlerEventDataBuilder(handler: PinchGestureHandler) : GestureHandlerEventDataBuilder<PinchGestureHandler>(handler) {
private val scale: Double
private val focalX: Float
private val focalY: Float
private val velocity: Double
init {
scale = handler.scale
focalX = handler.focalPointX
focalY = handler.focalPointY
velocity = handler.velocity
}
override fun buildEventData(eventData: WritableMap) {
super.buildEventData(eventData)
with(eventData) {
putDouble("scale", scale)
putDouble("focalX", PixelUtil.toDIPFromPixel(focalX).toDouble())
putDouble("focalY", PixelUtil.toDIPFromPixel(focalY).toDouble())
putDouble("velocity", velocity)
}
}
}

View File

@@ -0,0 +1,30 @@
package com.swmansion.gesturehandler.react.eventbuilders
import com.facebook.react.bridge.WritableMap
import com.facebook.react.uimanager.PixelUtil
import com.swmansion.gesturehandler.core.RotationGestureHandler
class RotationGestureHandlerEventDataBuilder(handler: RotationGestureHandler) : GestureHandlerEventDataBuilder<RotationGestureHandler>(handler) {
private val rotation: Double
private val anchorX: Float
private val anchorY: Float
private val velocity: Double
init {
rotation = handler.rotation
anchorX = handler.anchorX
anchorY = handler.anchorY
velocity = handler.velocity
}
override fun buildEventData(eventData: WritableMap) {
super.buildEventData(eventData)
with(eventData) {
putDouble("rotation", rotation)
putDouble("anchorX", PixelUtil.toDIPFromPixel(anchorX).toDouble())
putDouble("anchorY", PixelUtil.toDIPFromPixel(anchorY).toDouble())
putDouble("velocity", velocity)
}
}
}

View File

@@ -0,0 +1,30 @@
package com.swmansion.gesturehandler.react.eventbuilders
import com.facebook.react.bridge.WritableMap
import com.facebook.react.uimanager.PixelUtil
import com.swmansion.gesturehandler.core.TapGestureHandler
class TapGestureHandlerEventDataBuilder(handler: TapGestureHandler) : GestureHandlerEventDataBuilder<TapGestureHandler>(handler) {
private val x: Float
private val y: Float
private val absoluteX: Float
private val absoluteY: Float
init {
x = handler.lastRelativePositionX
y = handler.lastRelativePositionY
absoluteX = handler.lastPositionInWindowX
absoluteY = handler.lastPositionInWindowY
}
override fun buildEventData(eventData: WritableMap) {
super.buildEventData(eventData)
with(eventData) {
putDouble("x", PixelUtil.toDIPFromPixel(x).toDouble())
putDouble("y", PixelUtil.toDIPFromPixel(y).toDouble())
putDouble("absoluteX", PixelUtil.toDIPFromPixel(absoluteX).toDouble())
putDouble("absoluteY", PixelUtil.toDIPFromPixel(absoluteY).toDouble())
}
}
}

View File

@@ -0,0 +1,37 @@
project(GestureHandler)
cmake_minimum_required(VERSION 3.9.0)
set(CMAKE_VERBOSE_MAKEFILE ON)
if(${REACT_NATIVE_MINOR_VERSION} GREATER_EQUAL 73)
set(CMAKE_CXX_STANDARD 20)
else()
set(CMAKE_CXX_STANDARD 17)
endif()
set(PACKAGE_NAME "gesturehandler")
set(REACT_ANDROID_DIR "${REACT_NATIVE_DIR}/ReactAndroid")
include(${REACT_ANDROID_DIR}/cmake-utils/folly-flags.cmake)
add_compile_options(${folly_FLAGS})
add_library(${PACKAGE_NAME}
SHARED
cpp-adapter.cpp
)
target_include_directories(
${PACKAGE_NAME}
PRIVATE
"${REACT_NATIVE_DIR}/ReactCommon"
)
find_package(ReactAndroid REQUIRED CONFIG)
target_link_libraries(
gesturehandler
ReactAndroid::react_render_core
ReactAndroid::react_render_uimanager
ReactAndroid::react_render_graphics
ReactAndroid::jsi
ReactAndroid::react_nativemodule_core
)

View File

@@ -0,0 +1,40 @@
#include <jni.h>
#include <jsi/jsi.h>
#include <react/renderer/uimanager/primitives.h>
using namespace facebook;
using namespace react;
void decorateRuntime(jsi::Runtime &runtime) {
auto isFormsStackingContext = jsi::Function::createFromHostFunction(
runtime,
jsi::PropNameID::forAscii(runtime, "isFormsStackingContext"),
1,
[](jsi::Runtime &runtime,
const jsi::Value &thisValue,
const jsi::Value *arguments,
size_t count) -> jsi::Value {
if (!arguments[0].isObject()) {
return jsi::Value::null();
}
auto shadowNode = arguments[0]
.asObject(runtime).getNativeState<ShadowNode>(runtime);
bool isFormsStackingContext = shadowNode->getTraits().check(ShadowNodeTraits::FormsStackingContext);
return jsi::Value(isFormsStackingContext);
});
runtime.global().setProperty(
runtime, "isFormsStackingContext", std::move(isFormsStackingContext));
}
extern "C" JNIEXPORT void JNICALL
Java_com_swmansion_gesturehandler_react_RNGestureHandlerModule_decorateRuntime(
JNIEnv *env,
jobject clazz,
jlong jsiPtr) {
jsi::Runtime *runtime = reinterpret_cast<jsi::Runtime *>(jsiPtr);
if (runtime) {
decorateRuntime(*runtime);
}
}