Skip to content

Commit 5b29e9d

Browse files
authored
Add react-native-svg interface (#3242)
## Description <!-- Description and motivation for this PR. Include 'Fixes #<number>' if this is fixing some issue. --> This PR adds integration with the `react-native-svg` (`RNSVG`) to improve hitbox detection of the SVG elements they provide. ~~blocked~~ by software-mansion/react-native-svg#2583 ~~blocked~~ by `nested SvgViews with viewBox prop have invalid hit detection` - No issue opened yet ## Test plan <!-- Describe how did you test this change here. --> - use the newly added SVG integration example for testing this feature
1 parent a6304b4 commit 5b29e9d

File tree

9 files changed

+258
-21
lines changed

9 files changed

+258
-21
lines changed

android/build.gradle

Lines changed: 39 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -45,9 +45,9 @@ def resolveReactNativeDirectory() {
4545
}
4646

4747
throw new Exception(
48-
"[react-native-gesture-handler] Unable to resolve react-native location in " +
49-
"node_modules. You should add project extension property (in app/build.gradle) " +
50-
"`REACT_NATIVE_NODE_MODULES_DIR` with path to react-native."
48+
"[react-native-gesture-handler] Unable to resolve react-native location in " +
49+
"node_modules. You should add project extension property (in app/build.gradle) " +
50+
"`REACT_NATIVE_NODE_MODULES_DIR` with path to react-native."
5151
)
5252
}
5353

@@ -75,6 +75,22 @@ def shouldUseCommonInterfaceFromReanimated() {
7575
}
7676
}
7777

78+
def shouldUseCommonInterfaceFromRNSVG() {
79+
// common interface compatible with react-native-svg >= 15.11.2
80+
def rnsvg = rootProject.subprojects.find { it.name == 'react-native-svg' }
81+
if (rnsvg == null) {
82+
return false
83+
}
84+
85+
def inputFile = new File(rnsvg.projectDir, '../package.json')
86+
def json = new JsonSlurper().parseText(inputFile.text)
87+
def rnsvgVersion = json.version as String
88+
def (major, minor, patch) = rnsvgVersion.tokenize('.')
89+
return (Integer.parseInt(major) == 15 && Integer.parseInt(minor) == 11 && Integer.parseInt(patch) >= 2) ||
90+
(Integer.parseInt(major) == 15 && Integer.parseInt(minor) > 11) ||
91+
Integer.parseInt(major) > 15
92+
}
93+
7894
def reactNativeArchitectures() {
7995
def value = project.getProperties().get("reactNativeArchitectures")
8096
return value ? value.split(",") : ["armeabi-v7a", "x86", "x86_64", "arm64-v8a"]
@@ -119,15 +135,15 @@ android {
119135
buildConfigField "int", "REACT_NATIVE_MINOR_VERSION", REACT_NATIVE_MINOR_VERSION.toString()
120136

121137
if (isNewArchitectureEnabled()) {
122-
var appProject = rootProject.allprojects.find {it.plugins.hasPlugin('com.android.application')}
138+
var appProject = rootProject.allprojects.find { it.plugins.hasPlugin('com.android.application') }
123139
externalNativeBuild {
124140
cmake {
125141
cppFlags "-O2", "-frtti", "-fexceptions", "-Wall", "-Werror", "-std=c++20", "-DANDROID"
126142
arguments "-DREACT_NATIVE_DIR=${REACT_NATIVE_DIR}",
127-
"-DREACT_NATIVE_MINOR_VERSION=${REACT_NATIVE_MINOR_VERSION}",
128-
"-DANDROID_STL=c++_shared",
129-
"-DANDROID_SUPPORT_FLEXIBLE_PAGE_SIZES=ON"
130-
abiFilters (*reactNativeArchitectures())
143+
"-DREACT_NATIVE_MINOR_VERSION=${REACT_NATIVE_MINOR_VERSION}",
144+
"-DANDROID_STL=c++_shared",
145+
"-DANDROID_SUPPORT_FLEXIBLE_PAGE_SIZES=ON"
146+
abiFilters(*reactNativeArchitectures())
131147
}
132148
}
133149
}
@@ -168,13 +184,19 @@ android {
168184
srcDirs += 'noreanimated/src/main/java'
169185
}
170186

187+
if (shouldUseCommonInterfaceFromRNSVG()) {
188+
srcDirs += 'svg/src/main/java'
189+
} else {
190+
srcDirs += 'nosvg/src/main/java'
191+
}
192+
171193
if (isNewArchitectureEnabled()) {
172194
srcDirs += 'fabric/src/main/java'
173195
} else {
174196
// 'paper/src/main/java' includes files from codegen so the library can compile with
175197
// codegen turned off
176198

177-
if (REACT_NATIVE_MINOR_VERSION > 77){
199+
if (REACT_NATIVE_MINOR_VERSION > 77) {
178200
srcDirs += 'paper/src/main/java'
179201
} else {
180202
srcDirs += 'paper77/src/main/java'
@@ -211,15 +233,20 @@ def kotlin_version = safeExtGet('kotlinVersion', project.properties['RNGH_kotlin
211233

212234
dependencies {
213235
implementation 'com.facebook.react:react-native:+' // from node_modules
214-
236+
215237

216238
if (shouldUseCommonInterfaceFromReanimated()) {
217239
// Include Reanimated as dependency to load the common interface
218-
implementation (rootProject.subprojects.find { it.name == 'react-native-reanimated' }) {
219-
exclude group:'com.facebook.fbjni' // resolves "Duplicate class com.facebook.jni.CppException"
240+
implementation(rootProject.subprojects.find { it.name == 'react-native-reanimated' }) {
241+
// resolves "Duplicate class com.facebook.jni.CppException"
242+
exclude group: 'com.facebook.fbjni'
220243
}
221244
}
222245

246+
if (shouldUseCommonInterfaceFromRNSVG()) {
247+
implementation rootProject.subprojects.find { it.name == 'react-native-svg' }
248+
}
249+
223250
implementation 'androidx.appcompat:appcompat:1.2.0'
224251
implementation "androidx.core:core-ktx:1.6.0"
225252
implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version"
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
package com.swmansion.gesturehandler
2+
3+
import android.view.View
4+
5+
class RNSVGHitTester {
6+
companion object {
7+
@Suppress("UNUSED_PARAMETER")
8+
fun isSvgElement(view: Any) = false
9+
10+
@Suppress("UNUSED_PARAMETER")
11+
fun hitTest(view: View, posX: Float, posY: Float) = false
12+
}
13+
}

android/spotless.gradle

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ apply plugin: "com.diffplug.spotless"
22

33
spotless {
44
kotlin {
5-
target "src/**/*.kt", "reanimated/**/*.kt", "noreanimated/**/*.kt", "common/**/*.kt"
5+
target "src/**/*.kt", "reanimated/**/*.kt", "noreanimated/**/*.kt", "svg/**/*.kt", "nosvg/**/*.kt", "common/**/*.kt"
66
ktlint().editorConfigOverride([indent_size: 2])
77
trimTrailingWhitespace()
88
indentWithSpaces()

android/src/main/java/com/swmansion/gesturehandler/core/GestureHandler.kt

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import com.facebook.react.bridge.UiThreadUtil
1515
import com.facebook.react.bridge.WritableArray
1616
import com.facebook.react.uimanager.PixelUtil
1717
import com.swmansion.gesturehandler.BuildConfig
18+
import com.swmansion.gesturehandler.RNSVGHitTester
1819
import com.swmansion.gesturehandler.react.RNGestureHandlerTouchEvent
1920
import java.lang.IllegalStateException
2021
import java.util.*
@@ -610,9 +611,13 @@ open class GestureHandler<ConcreteGestureHandlerT : GestureHandler<ConcreteGestu
610611
}
611612

612613
fun isWithinBounds(view: View?, posX: Float, posY: Float): Boolean {
614+
if (RNSVGHitTester.isSvgElement(view!!)) {
615+
return RNSVGHitTester.hitTest(view, posX, posY)
616+
}
617+
613618
var left = 0f
614619
var top = 0f
615-
var right = view!!.width.toFloat()
620+
var right = view.width.toFloat()
616621
var bottom = view.height.toFloat()
617622
hitSlop?.let { hitSlop ->
618623
val padLeft = hitSlop[HIT_SLOP_LEFT_IDX]
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
package com.swmansion.gesturehandler
2+
3+
import android.view.View
4+
import androidx.core.view.children
5+
import com.horcrux.svg.SvgView
6+
import com.horcrux.svg.VirtualView
7+
8+
class RNSVGHitTester {
9+
companion object {
10+
private fun getRootSvgView(view: View): SvgView {
11+
var rootSvgView: SvgView
12+
13+
rootSvgView = if (view is VirtualView) {
14+
view.svgView!!
15+
} else {
16+
view as SvgView
17+
}
18+
19+
while (isSvgElement(rootSvgView.parent)) {
20+
rootSvgView = if (rootSvgView.parent is VirtualView) {
21+
(rootSvgView.parent as VirtualView).svgView!!
22+
} else {
23+
rootSvgView.parent as SvgView
24+
}
25+
}
26+
27+
return rootSvgView
28+
}
29+
30+
fun isSvgElement(view: Any): Boolean {
31+
return (view is VirtualView || view is SvgView)
32+
}
33+
34+
fun hitTest(view: View, posX: Float, posY: Float): Boolean {
35+
val rootSvgView = getRootSvgView(view)
36+
val viewLocation = intArrayOf(0, 0)
37+
val rootLocation = intArrayOf(0, 0)
38+
39+
view.getLocationOnScreen(viewLocation)
40+
rootSvgView.getLocationOnScreen(rootLocation)
41+
42+
// convert View-relative coordinates into SvgView-relative coordinates
43+
val rootX = posX + viewLocation[0] - rootLocation[0]
44+
val rootY = posY + viewLocation[1] - rootLocation[1]
45+
46+
val pressedId = rootSvgView.reactTagForTouch(rootX, rootY)
47+
val hasBeenPressed = view.id == pressedId
48+
49+
// hitTest(view, ...) should only be called after isSvgElement(view) returns true
50+
// Consequently, `view` will always be either SvgView or VirtualView
51+
52+
val pressIsInBounds =
53+
posX in 0.0..view.width.toDouble() &&
54+
posY in 0.0..view.height.toDouble()
55+
56+
if (view is SvgView) {
57+
val childrenIds = view.children.map { it.id }
58+
59+
val hasChildBeenPressed = pressedId in childrenIds
60+
61+
return (hasBeenPressed || hasChildBeenPressed) && pressIsInBounds
62+
}
63+
64+
return hasBeenPressed && pressIsInBounds
65+
}
66+
}
67+
}

example/App.tsx

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,12 +40,16 @@ import PointerType from './src/release_tests/pointerType';
4040
import SwipeableReanimation from './src/release_tests/swipeableReanimation';
4141
import NestedGestureHandlerRootViewWithModal from './src/release_tests/nestedGHRootViewWithModal';
4242
import TwoFingerPan from './src/release_tests/twoFingerPan';
43+
import SvgCompatibility from './src/release_tests/svg';
4344
import NestedText from './src/release_tests/nestedText';
45+
4446
import { PinchableBox } from './src/recipes/scaleAndRotate';
4547
import PanAndScroll from './src/recipes/panAndScroll';
48+
4649
import { BottomSheet } from './src/showcase/bottomSheet';
4750
import Swipeables from './src/showcase/swipeable';
4851
import ChatHeads from './src/showcase/chatHeads';
52+
4953
import Draggable from './src/basic/draggable';
5054
import MultiTap from './src/basic/multitap';
5155
import BouncingBox from './src/basic/bouncing';
@@ -186,6 +190,10 @@ const EXAMPLES: ExamplesSection[] = [
186190
component: NestedButtons,
187191
unsupportedPlatforms: new Set(['web', 'ios', 'macos']),
188192
},
193+
{
194+
name: 'Svg integration with Gesture Handler',
195+
component: SvgCompatibility,
196+
},
189197
{ name: 'Double pinch & rotate', component: DoublePinchRotate },
190198
{ name: 'Double draggable', component: DoubleDraggable },
191199
{ name: 'Rows', component: Rows },

example/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@
3838
"react-native-reanimated": "~3.16.1",
3939
"react-native-safe-area-context": "4.12.0",
4040
"react-native-screens": "~4.4.0",
41-
"react-native-svg": "15.8.0",
41+
"react-native-svg": "15.11.2",
4242
"react-native-web": "~0.19.10"
4343
},
4444
"devDependencies": {
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
import React from 'react';
2+
import { Text, View, StyleSheet } from 'react-native';
3+
import { Gesture, GestureDetector } from 'react-native-gesture-handler';
4+
5+
import Svg, { Circle, Rect } from 'react-native-svg';
6+
7+
export default function SvgExample() {
8+
const circleElementTap = Gesture.Tap().onStart(() =>
9+
console.log('RNGH: clicked circle')
10+
);
11+
const rectElementTap = Gesture.Tap().onStart(() =>
12+
console.log('RNGH: clicked parallelogram')
13+
);
14+
const containerTap = Gesture.Tap().onStart(() =>
15+
console.log('RNGH: clicked container')
16+
);
17+
const vbContainerTap = Gesture.Tap().onStart(() =>
18+
console.log('RNGH: clicked viewbox container')
19+
);
20+
const vbInnerContainerTap = Gesture.Tap().onStart(() =>
21+
console.log('RNGH: clicked inner viewbox container')
22+
);
23+
const vbCircleTap = Gesture.Tap().onStart(() =>
24+
console.log('RNGH: clicked viewbox circle')
25+
);
26+
27+
return (
28+
<View>
29+
<View style={styles.container}>
30+
<Text style={styles.header}>
31+
Overlapping SVGs with gesture detectors
32+
</Text>
33+
<View style={{ backgroundColor: 'tomato' }}>
34+
<GestureDetector gesture={containerTap}>
35+
<Svg
36+
height="250"
37+
width="250"
38+
onPress={() => console.log('SVG: clicked container')}>
39+
<GestureDetector gesture={circleElementTap}>
40+
<Circle
41+
cx="125"
42+
cy="125"
43+
r="125"
44+
fill="green"
45+
onPress={() => console.log('SVG: clicked circle')}
46+
/>
47+
</GestureDetector>
48+
<GestureDetector gesture={rectElementTap}>
49+
<Rect
50+
skewX="45"
51+
width="125"
52+
height="250"
53+
fill="yellow"
54+
onPress={() => console.log('SVG: clicked parallelogram')}
55+
/>
56+
</GestureDetector>
57+
</Svg>
58+
</GestureDetector>
59+
</View>
60+
<Text>
61+
Tapping each color should read to a different console.log output
62+
</Text>
63+
</View>
64+
<View style={styles.container}>
65+
<Text style={styles.header}>SvgView with SvgView with ViewBox</Text>
66+
<View style={{ backgroundColor: 'tomato' }}>
67+
<GestureDetector gesture={vbContainerTap}>
68+
<Svg
69+
height="250"
70+
width="250"
71+
viewBox="-50 -50 150 150"
72+
onPress={() => console.log('SVG: clicked viewbox container')}>
73+
<GestureDetector gesture={vbInnerContainerTap}>
74+
<Svg
75+
height="250"
76+
width="250"
77+
viewBox="-300 -300 600 600"
78+
onPress={() =>
79+
console.log('SVG: clicked inner viewbox container')
80+
}>
81+
<Rect
82+
x="-300"
83+
y="-300"
84+
width="600"
85+
height="600"
86+
fill="yellow"
87+
/>
88+
<GestureDetector gesture={vbCircleTap}>
89+
<Circle
90+
r="300"
91+
fill="green"
92+
onPress={() => console.log('SVG: clicked viewbox circle')}
93+
/>
94+
</GestureDetector>
95+
</Svg>
96+
</GestureDetector>
97+
</Svg>
98+
</GestureDetector>
99+
</View>
100+
<Text>The viewBox property remaps SVG's coordinate space</Text>
101+
</View>
102+
</View>
103+
);
104+
}
105+
106+
const styles = StyleSheet.create({
107+
container: {
108+
alignItems: 'center',
109+
justifyContent: 'center',
110+
marginBottom: 48,
111+
},
112+
header: {
113+
fontSize: 18,
114+
fontWeight: 'bold',
115+
margin: 10,
116+
},
117+
});

0 commit comments

Comments
 (0)