expo-scroll-forwarder 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/.eslintrc.js +5 -0
  2. package/CHANGELOG.md +11 -0
  3. package/README.md +182 -0
  4. package/android/build.gradle +51 -0
  5. package/android/src/main/AndroidManifest.xml +2 -0
  6. package/android/src/main/java/expo/modules/scrollforwarder/ExpoScrollForwarderModule.kt +16 -0
  7. package/android/src/main/java/expo/modules/scrollforwarder/ExpoScrollForwarderView.kt +229 -0
  8. package/build/ExpoScrollForwarder.types.d.ts +5 -0
  9. package/build/ExpoScrollForwarder.types.d.ts.map +1 -0
  10. package/build/ExpoScrollForwarder.types.js +2 -0
  11. package/build/ExpoScrollForwarder.types.js.map +1 -0
  12. package/build/ExpoScrollForwarderView.android.d.ts +3 -0
  13. package/build/ExpoScrollForwarderView.android.d.ts.map +1 -0
  14. package/build/ExpoScrollForwarderView.android.js +6 -0
  15. package/build/ExpoScrollForwarderView.android.js.map +1 -0
  16. package/build/ExpoScrollForwarderView.d.ts +3 -0
  17. package/build/ExpoScrollForwarderView.d.ts.map +1 -0
  18. package/build/ExpoScrollForwarderView.ios.d.ts +3 -0
  19. package/build/ExpoScrollForwarderView.ios.d.ts.map +1 -0
  20. package/build/ExpoScrollForwarderView.ios.js +6 -0
  21. package/build/ExpoScrollForwarderView.ios.js.map +1 -0
  22. package/build/ExpoScrollForwarderView.js +4 -0
  23. package/build/ExpoScrollForwarderView.js.map +1 -0
  24. package/build/index.d.ts +3 -0
  25. package/build/index.d.ts.map +1 -0
  26. package/build/index.js +2 -0
  27. package/build/index.js.map +1 -0
  28. package/expo-module.config.json +9 -0
  29. package/ios/ExpoScrollForwarder.podspec +29 -0
  30. package/ios/ExpoScrollForwarderModule.swift +48 -0
  31. package/ios/ExpoScrollForwarderView.swift +38 -0
  32. package/package.json +44 -0
  33. package/src/ExpoScrollForwarder.types.ts +4 -0
  34. package/src/ExpoScrollForwarderView.android.tsx +13 -0
  35. package/src/ExpoScrollForwarderView.ios.tsx +13 -0
  36. package/src/ExpoScrollForwarderView.tsx +7 -0
  37. package/src/index.ts +2 -0
  38. package/tsconfig.json +9 -0
package/.eslintrc.js ADDED
@@ -0,0 +1,5 @@
1
+ module.exports = {
2
+ root: true,
3
+ extends: ['universe/native', 'universe/web'],
4
+ ignorePatterns: ['build'],
5
+ };
package/CHANGELOG.md ADDED
@@ -0,0 +1,11 @@
1
+ # Changelog
2
+
3
+ ## Unpublished
4
+
5
+ ### 🛠 Breaking changes
6
+
7
+ ### 🎉 New features
8
+
9
+ ### 🐛 Bug fixes
10
+
11
+ ### 💡 Others
package/README.md ADDED
@@ -0,0 +1,182 @@
1
+ # expo-scroll-forwarder
2
+
3
+ A native module for React Native and Expo that forwards scroll gestures from a non-scrollable view to a ScrollView. Perfect for creating sticky headers that can initiate scrolling, or any UI where you want touch gestures on one view to control scrolling in another.
4
+
5
+ ## Features
6
+
7
+ - ✨ Forward scroll gestures from any view to a ScrollView
8
+ - 📱 Works on both iOS and Android
9
+ - 🎯 Supports pull-to-refresh
10
+ - 🚀 Smooth momentum scrolling with decay animation
11
+ - 💫 Haptic feedback at refresh threshold
12
+ - 🎨 Natural damping for overscroll
13
+ - 👆 Tap to cancel scroll momentum
14
+
15
+ ## Installation
16
+
17
+ ```bash
18
+ npx expo install expo-scroll-forwarder
19
+ ```
20
+
21
+ ## Usage
22
+
23
+ ```typescript
24
+ import React, { useRef, useState, useEffect } from "react";
25
+ import { ExpoScrollForwarderView } from "expo-scroll-forwarder";
26
+ import {
27
+ ScrollView,
28
+ View,
29
+ Text,
30
+ RefreshControl,
31
+ findNodeHandle,
32
+ } from "react-native";
33
+
34
+ export default function App() {
35
+ const scrollViewRef = useRef<ScrollView>(null);
36
+ const [scrollViewTag, setScrollViewTag] = useState<number | null>(null);
37
+ const [refreshing, setRefreshing] = useState(false);
38
+
39
+ // Get the native tag of the ScrollView
40
+ useEffect(() => {
41
+ if (scrollViewRef.current) {
42
+ const tag = findNodeHandle(scrollViewRef.current);
43
+ setScrollViewTag(tag);
44
+ }
45
+ }, []);
46
+
47
+ const onRefresh = () => {
48
+ setRefreshing(true);
49
+ setTimeout(() => setRefreshing(false), 2000);
50
+ };
51
+
52
+ return (
53
+ <>
54
+ {/* Header that forwards scroll gestures */}
55
+ <ExpoScrollForwarderView scrollViewTag={scrollViewTag}>
56
+ <View style={{ padding: 20, backgroundColor: "#6366f1" }}>
57
+ <Text style={{ color: "white", fontSize: 20 }}>
58
+ Swipe down here to scroll
59
+ </Text>
60
+ </View>
61
+ </ExpoScrollForwarderView>
62
+
63
+ {/* Main ScrollView */}
64
+ <ScrollView
65
+ ref={scrollViewRef}
66
+ refreshControl={
67
+ <RefreshControl refreshing={refreshing} onRefresh={onRefresh} />
68
+ }
69
+ >
70
+ <View style={{ padding: 20 }}>
71
+ <Text>Your scrollable content here</Text>
72
+ {/* Add more content... */}
73
+ </View>
74
+ </ScrollView>
75
+ </>
76
+ );
77
+ }
78
+ ```
79
+
80
+ ## API Reference
81
+
82
+ ### ExpoScrollForwarderView
83
+
84
+ A view component that forwards scroll gestures to a target ScrollView.
85
+
86
+ #### Props
87
+
88
+ | Prop | Type | Required | Description |
89
+ | --------------- | ----------------- | -------- | ------------------------------------------------------------------------------------------------------------------ |
90
+ | `scrollViewTag` | `number \| null` | Yes | The native tag of the ScrollView to forward gestures to. Obtain this using `findNodeHandle(scrollViewRef.current)` |
91
+ | `children` | `React.ReactNode` | Yes | The content that will capture and forward scroll gestures |
92
+
93
+ ## How It Works
94
+
95
+ 1. **Get ScrollView Tag**: Use `findNodeHandle()` to get the native tag of your ScrollView
96
+ 2. **Wrap Your Header**: Wrap any view in `ExpoScrollForwarderView` and pass the tag
97
+ 3. **Gesture Forwarding**: Touch gestures on the wrapped view are forwarded to the ScrollView
98
+ 4. **Natural Scrolling**: Includes momentum, damping, and all native scroll behaviors
99
+
100
+ ## Use Cases
101
+
102
+ - **Collapsible Headers**: Create headers that users can swipe to scroll the content below
103
+ - **Custom Navigation Bars**: Make navigation bars scrollable
104
+ - **Dashboard Cards**: Allow cards or panels to initiate scrolling
105
+ - **Video Player Controls**: Overlay controls that don't block scroll gestures
106
+ - **Chat Input Areas**: Input fields that can still control chat scroll
107
+
108
+ ## Platform Support
109
+
110
+ - ✅ iOS 13.4+
111
+ - ✅ Android API 21+
112
+ - ✅ Expo SDK 50+
113
+
114
+ ## Example
115
+
116
+ Check out the [example app](./example) in the repository for a full working demo.
117
+
118
+ ## Technical Details
119
+
120
+ ### iOS Implementation
121
+
122
+ - Uses `UIPanGestureRecognizer` for gesture detection
123
+ - Custom decay animation with configurable friction
124
+ - Integrates with native `UIScrollView` and `RCTRefreshControl`
125
+ - Prevents conflicts with swipe-back gestures
126
+
127
+ ### Android Implementation
128
+
129
+ - Uses `GestureDetector` and custom touch event handling
130
+ - `ValueAnimator` for smooth decay animations
131
+ - Integrates with `ReactScrollView` and RefreshControl
132
+ - Proper velocity tracking and momentum calculation
133
+
134
+ ## Troubleshooting
135
+
136
+ ### ScrollView not responding to gestures
137
+
138
+ Make sure you're correctly getting and setting the `scrollViewTag`:
139
+
140
+ ```typescript
141
+ const tag = findNodeHandle(scrollViewRef.current);
142
+ setScrollViewTag(tag);
143
+ ```
144
+
145
+ ### Gestures interfering with other touch handlers
146
+
147
+ The module automatically handles gesture conflicts, but ensure your view hierarchy is correct - the `ExpoScrollForwarderView` should be a sibling or parent of the ScrollView, not a child.
148
+
149
+ ### Pull-to-refresh not working
150
+
151
+ Ensure you've added a `RefreshControl` to your ScrollView:
152
+
153
+ ```typescript
154
+ <ScrollView
155
+ refreshControl={
156
+ <RefreshControl refreshing={refreshing} onRefresh={onRefresh} />
157
+ }
158
+ >
159
+ ```
160
+
161
+ ## Contributing
162
+
163
+ Contributions are welcome! Please feel free to submit a Pull Request.
164
+
165
+ ## License
166
+
167
+ MIT © [Sharif Rayhan Nafi](https://github.com/sharifrayhan)
168
+
169
+ ## Author
170
+
171
+ **Sharif Rayhan Nafi**
172
+
173
+ - GitHub: [@sharifrayhan](https://github.com/sharifrayhan)
174
+ - Email: sharifrayhan.official@gmail.com
175
+
176
+ ## Acknowledgments
177
+
178
+ This module was inspired by the scroll forwarding behavior in the Bluesky app
179
+ .
180
+ Special thanks to the Bluesky engineering team — their implementation and user experience provided valuable insight into creating a natural, responsive gesture-forwarding system.
181
+
182
+ Built with [Expo Modules](https://docs.expo.dev/modules/) 🚀
@@ -0,0 +1,51 @@
1
+ apply plugin: 'com.android.library'
2
+
3
+ group = 'expo.modules.scrollforwarder'
4
+ version = '0.1.0'
5
+
6
+ def expoModulesCorePlugin = new File(project(":expo-modules-core").projectDir.absolutePath, "ExpoModulesCorePlugin.gradle")
7
+ apply from: expoModulesCorePlugin
8
+ applyKotlinExpoModulesCorePlugin()
9
+ useCoreDependencies()
10
+ useExpoPublishing()
11
+
12
+ // If you want to use the managed Android SDK versions from expo-modules-core, set this to true.
13
+ // The Android SDK versions will be bumped from time to time in SDK releases and may introduce breaking changes in your module code.
14
+ // Most of the time, you may like to manage the Android SDK versions yourself.
15
+ def useManagedAndroidSdkVersions = false
16
+ if (useManagedAndroidSdkVersions) {
17
+ useDefaultAndroidSdkVersions()
18
+ } else {
19
+ buildscript {
20
+ // Simple helper that allows the root project to override versions declared by this library.
21
+ ext.safeExtGet = { prop, fallback ->
22
+ rootProject.ext.has(prop) ? rootProject.ext.get(prop) : fallback
23
+ }
24
+ }
25
+ project.android {
26
+ compileSdkVersion safeExtGet("compileSdkVersion", 36)
27
+ defaultConfig {
28
+ minSdkVersion safeExtGet("minSdkVersion", 24)
29
+ targetSdkVersion safeExtGet("targetSdkVersion", 36)
30
+ }
31
+ }
32
+ }
33
+
34
+ android {
35
+ namespace "expo.modules.scrollforwarder"
36
+ defaultConfig {
37
+ versionCode 1
38
+ versionName "0.1.0"
39
+ }
40
+ lintOptions {
41
+ abortOnError false
42
+ }
43
+ }
44
+
45
+ repositories {
46
+ mavenCentral()
47
+ }
48
+
49
+ dependencies {
50
+ implementation "androidx.core:core-ktx:1.10.1"
51
+ }
@@ -0,0 +1,2 @@
1
+ <manifest>
2
+ </manifest>
@@ -0,0 +1,16 @@
1
+ package expo.modules.scrollforwarder
2
+
3
+ import expo.modules.kotlin.modules.Module
4
+ import expo.modules.kotlin.modules.ModuleDefinition
5
+
6
+ class ExpoScrollForwarderModule : Module() {
7
+ override fun definition() = ModuleDefinition {
8
+ Name("ExpoScrollForwarder")
9
+
10
+ View(ExpoScrollForwarderView::class) {
11
+ Prop("scrollViewTag") { view: ExpoScrollForwarderView, prop: Int ->
12
+ view.scrollViewTag = prop
13
+ }
14
+ }
15
+ }
16
+ }
@@ -0,0 +1,229 @@
1
+ package expo.modules.scrollforwarder
2
+
3
+ import android.animation.ValueAnimator
4
+ import android.content.Context
5
+ import android.view.GestureDetector
6
+ import android.view.MotionEvent
7
+ import android.view.View
8
+ import android.view.ViewGroup
9
+ import android.view.animation.DecelerateInterpolator
10
+ import androidx.core.view.GestureDetectorCompat
11
+ import com.facebook.react.views.scroll.ReactScrollView
12
+ import expo.modules.kotlin.AppContext
13
+ import expo.modules.kotlin.viewevent.EventDispatcher
14
+ import expo.modules.kotlin.views.ExpoView
15
+ import kotlin.math.abs
16
+
17
+ class ExpoScrollForwarderView(context: Context, appContext: AppContext) : ExpoView(context, appContext) {
18
+
19
+ var scrollViewTag: Int? = null
20
+ set(value) {
21
+ field = value
22
+ tryFindScrollView()
23
+ }
24
+
25
+ private var reactScrollView: ReactScrollView? = null
26
+ private var gestureDetector: GestureDetectorCompat
27
+ private var isScrolling = false
28
+ private var initialScrollY = 0
29
+ private var lastY = 0f
30
+ private var decayAnimator: ValueAnimator? = null
31
+ private var didImpact = false
32
+
33
+ init {
34
+ gestureDetector = GestureDetectorCompat(context, GestureListener())
35
+ }
36
+
37
+ override fun onInterceptTouchEvent(ev: MotionEvent): Boolean {
38
+ val scrollView = reactScrollView ?: return super.onInterceptTouchEvent(ev)
39
+
40
+ when (ev.action) {
41
+ MotionEvent.ACTION_DOWN -> {
42
+ stopDecayAnimation()
43
+ lastY = ev.rawY
44
+ initialScrollY = scrollView.scrollY
45
+ isScrolling = false
46
+ }
47
+ MotionEvent.ACTION_MOVE -> {
48
+ val deltaY = lastY - ev.rawY
49
+
50
+ // Check if this is a vertical scroll gesture
51
+ if (!isScrolling && abs(deltaY) > 10) {
52
+ // Calculate if the gesture is more vertical than horizontal
53
+ val initialX = ev.getHistoricalX(0)
54
+ val initialY = ev.getHistoricalY(0)
55
+ val deltaX = ev.x - initialX
56
+ val deltaYCheck = ev.y - initialY
57
+
58
+ if (abs(deltaYCheck) > abs(deltaX)) {
59
+ isScrolling = true
60
+ return true
61
+ }
62
+ }
63
+ }
64
+ }
65
+
66
+ return isScrolling || super.onInterceptTouchEvent(ev)
67
+ }
68
+
69
+ override fun onTouchEvent(event: MotionEvent): Boolean {
70
+ val scrollView = reactScrollView ?: return super.onTouchEvent(event)
71
+
72
+ gestureDetector.onTouchEvent(event)
73
+
74
+ when (event.action) {
75
+ MotionEvent.ACTION_DOWN -> {
76
+ stopDecayAnimation()
77
+ lastY = event.rawY
78
+ initialScrollY = scrollView.scrollY
79
+ if (scrollView.scrollY < 0) {
80
+ scrollView.scrollY = 0
81
+ }
82
+ return true
83
+ }
84
+
85
+ MotionEvent.ACTION_MOVE -> {
86
+ val deltaY = (lastY - event.rawY).toInt()
87
+ val newOffset = dampenOffset(initialScrollY + deltaY)
88
+
89
+ scrollView.scrollY = newOffset
90
+
91
+ // Haptic feedback at refresh threshold
92
+ if (newOffset <= -130 && !didImpact) {
93
+ performHapticFeedback(android.view.HapticFeedbackConstants.LONG_PRESS)
94
+ didImpact = true
95
+ }
96
+
97
+ return true
98
+ }
99
+
100
+ MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> {
101
+ val velocityY = calculateVelocity(event)
102
+ didImpact = false
103
+
104
+ // Check for pull-to-refresh threshold
105
+ if (scrollView.scrollY <= -130) {
106
+ triggerRefresh(scrollView)
107
+ return true
108
+ }
109
+
110
+ // Don't animate if velocity is too low and we're at a valid position
111
+ if (abs(velocityY) < 250 && scrollView.scrollY >= 0) {
112
+ isScrolling = false
113
+ return true
114
+ }
115
+
116
+ startDecayAnimation(scrollView, velocityY.toFloat())
117
+ isScrolling = false
118
+ return true
119
+ }
120
+ }
121
+
122
+ return super.onTouchEvent(event)
123
+ }
124
+
125
+ private fun calculateVelocity(event: MotionEvent): Int {
126
+ if (event.historySize == 0) return 0
127
+
128
+ val lastHistoricalY = event.getHistoricalY(event.historySize - 1)
129
+ val lastHistoricalTime = event.getHistoricalEventTime(event.historySize - 1)
130
+ val deltaY = event.y - lastHistoricalY
131
+ val deltaTime = (event.eventTime - lastHistoricalTime) / 1000f
132
+
133
+ return if (deltaTime > 0) {
134
+ (-deltaY / deltaTime).toInt()
135
+ } else {
136
+ 0
137
+ }
138
+ }
139
+
140
+ private fun startDecayAnimation(scrollView: ReactScrollView, velocity: Float) {
141
+ var currentVelocity = velocity.coerceIn(-5000f, 5000f)
142
+ val startOffset = scrollView.scrollY
143
+ var currentOffset = startOffset.toFloat()
144
+
145
+ decayAnimator = ValueAnimator.ofFloat(0f, 1f).apply {
146
+ duration = 3000
147
+ interpolator = DecelerateInterpolator()
148
+
149
+ addUpdateListener { animator ->
150
+ currentVelocity *= 0.9875f
151
+ currentOffset += (-currentVelocity / 120f)
152
+
153
+ val newOffset = dampenOffset(currentOffset.toInt())
154
+
155
+ when {
156
+ newOffset <= 0 -> {
157
+ if (startOffset <= 1) {
158
+ scrollView.smoothScrollTo(0, 0)
159
+ } else {
160
+ scrollView.scrollY = 0
161
+ }
162
+ cancel()
163
+ }
164
+ else -> {
165
+ scrollView.scrollY = newOffset
166
+ }
167
+ }
168
+
169
+ if (abs(currentVelocity) < 5) {
170
+ cancel()
171
+ }
172
+ }
173
+ }
174
+ decayAnimator?.start()
175
+ }
176
+
177
+ private fun dampenOffset(offset: Int): Int {
178
+ return if (offset < 0) {
179
+ (offset - (offset * 0.55)).toInt()
180
+ } else {
181
+ offset
182
+ }
183
+ }
184
+
185
+ private fun triggerRefresh(scrollView: ReactScrollView) {
186
+ // Find RefreshControl if it exists
187
+ for (i in 0 until scrollView.childCount) {
188
+ val child = scrollView.getChildAt(i)
189
+ // React Native's RefreshControl detection
190
+ if (child.javaClass.simpleName.contains("RefreshControl")) {
191
+ try {
192
+ val method = child.javaClass.getMethod("setRefreshing", Boolean::class.java)
193
+ method.invoke(child, true)
194
+ } catch (e: Exception) {
195
+ // RefreshControl not available or different implementation
196
+ }
197
+ break
198
+ }
199
+ }
200
+ }
201
+
202
+ private fun stopDecayAnimation() {
203
+ decayAnimator?.cancel()
204
+ decayAnimator = null
205
+ }
206
+
207
+ private fun tryFindScrollView() {
208
+ val tag = scrollViewTag ?: return
209
+
210
+ reactScrollView = appContext?.findView(tag) as? ReactScrollView
211
+ }
212
+
213
+ inner class GestureListener : GestureDetector.SimpleOnGestureListener() {
214
+ override fun onDown(e: MotionEvent): Boolean {
215
+ stopDecayAnimation()
216
+ return true
217
+ }
218
+
219
+ override fun onSingleTapUp(e: MotionEvent): Boolean {
220
+ stopDecayAnimation()
221
+ return true
222
+ }
223
+ }
224
+
225
+ override fun onDetachedFromWindow() {
226
+ super.onDetachedFromWindow()
227
+ stopDecayAnimation()
228
+ }
229
+ }
@@ -0,0 +1,5 @@
1
+ export interface ExpoScrollForwarderViewProps {
2
+ scrollViewTag: number | null;
3
+ children: React.ReactNode;
4
+ }
5
+ //# sourceMappingURL=ExpoScrollForwarder.types.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"ExpoScrollForwarder.types.d.ts","sourceRoot":"","sources":["../src/ExpoScrollForwarder.types.ts"],"names":[],"mappings":"AAAA,MAAM,WAAW,4BAA4B;IAC3C,aAAa,EAAE,MAAM,GAAG,IAAI,CAAA;IAC5B,QAAQ,EAAE,KAAK,CAAC,SAAS,CAAA;CAC1B"}
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=ExpoScrollForwarder.types.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"ExpoScrollForwarder.types.js","sourceRoot":"","sources":["../src/ExpoScrollForwarder.types.ts"],"names":[],"mappings":"","sourcesContent":["export interface ExpoScrollForwarderViewProps {\r\n scrollViewTag: number | null\r\n children: React.ReactNode\r\n}\r\n"]}
@@ -0,0 +1,3 @@
1
+ import { type ExpoScrollForwarderViewProps } from "./ExpoScrollForwarder.types";
2
+ export declare function ExpoScrollForwarderView({ children, ...rest }: ExpoScrollForwarderViewProps): import("react").JSX.Element;
3
+ //# sourceMappingURL=ExpoScrollForwarderView.android.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"ExpoScrollForwarderView.android.d.ts","sourceRoot":"","sources":["../src/ExpoScrollForwarderView.android.tsx"],"names":[],"mappings":"AAEA,OAAO,EAAE,KAAK,4BAA4B,EAAE,MAAM,6BAA6B,CAAC;AAKhF,wBAAgB,uBAAuB,CAAC,EACtC,QAAQ,EACR,GAAG,IAAI,EACR,EAAE,4BAA4B,+BAE9B"}
@@ -0,0 +1,6 @@
1
+ import { requireNativeViewManager } from "expo-modules-core";
2
+ const NativeView = requireNativeViewManager("ExpoScrollForwarder");
3
+ export function ExpoScrollForwarderView({ children, ...rest }) {
4
+ return <NativeView {...rest}>{children}</NativeView>;
5
+ }
6
+ //# sourceMappingURL=ExpoScrollForwarderView.android.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"ExpoScrollForwarderView.android.js","sourceRoot":"","sources":["../src/ExpoScrollForwarderView.android.tsx"],"names":[],"mappings":"AAAA,OAAO,EAAE,wBAAwB,EAAE,MAAM,mBAAmB,CAAC;AAI7D,MAAM,UAAU,GACd,wBAAwB,CAAC,qBAAqB,CAAC,CAAC;AAElD,MAAM,UAAU,uBAAuB,CAAC,EACtC,QAAQ,EACR,GAAG,IAAI,EACsB;IAC7B,OAAO,CAAC,UAAU,CAAC,IAAI,IAAI,CAAC,CAAC,CAAC,QAAQ,CAAC,EAAE,UAAU,CAAC,CAAC;AACvD,CAAC","sourcesContent":["import { requireNativeViewManager } from \"expo-modules-core\";\r\n\r\nimport { type ExpoScrollForwarderViewProps } from \"./ExpoScrollForwarder.types\";\r\n\r\nconst NativeView: React.ComponentType<ExpoScrollForwarderViewProps> =\r\n requireNativeViewManager(\"ExpoScrollForwarder\");\r\n\r\nexport function ExpoScrollForwarderView({\r\n children,\r\n ...rest\r\n}: ExpoScrollForwarderViewProps) {\r\n return <NativeView {...rest}>{children}</NativeView>;\r\n}\r\n"]}
@@ -0,0 +1,3 @@
1
+ import { type ExpoScrollForwarderViewProps } from './ExpoScrollForwarder.types';
2
+ export declare function ExpoScrollForwarderView({ children, }: React.PropsWithChildren<ExpoScrollForwarderViewProps>): import("react").ReactNode;
3
+ //# sourceMappingURL=ExpoScrollForwarderView.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"ExpoScrollForwarderView.d.ts","sourceRoot":"","sources":["../src/ExpoScrollForwarderView.tsx"],"names":[],"mappings":"AAAA,OAAO,EAAC,KAAK,4BAA4B,EAAC,MAAM,6BAA6B,CAAA;AAE7E,wBAAgB,uBAAuB,CAAC,EACtC,QAAQ,GACT,EAAE,KAAK,CAAC,iBAAiB,CAAC,4BAA4B,CAAC,6BAEvD"}
@@ -0,0 +1,3 @@
1
+ import { type ExpoScrollForwarderViewProps } from './ExpoScrollForwarder.types';
2
+ export declare function ExpoScrollForwarderView({ children, ...rest }: ExpoScrollForwarderViewProps): import("react").JSX.Element;
3
+ //# sourceMappingURL=ExpoScrollForwarderView.ios.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"ExpoScrollForwarderView.ios.d.ts","sourceRoot":"","sources":["../src/ExpoScrollForwarderView.ios.tsx"],"names":[],"mappings":"AAEA,OAAO,EAAC,KAAK,4BAA4B,EAAC,MAAM,6BAA6B,CAAA;AAK7E,wBAAgB,uBAAuB,CAAC,EACtC,QAAQ,EACR,GAAG,IAAI,EACR,EAAE,4BAA4B,+BAE9B"}
@@ -0,0 +1,6 @@
1
+ import { requireNativeViewManager } from 'expo-modules-core';
2
+ const NativeView = requireNativeViewManager('ExpoScrollForwarder');
3
+ export function ExpoScrollForwarderView({ children, ...rest }) {
4
+ return <NativeView {...rest}>{children}</NativeView>;
5
+ }
6
+ //# sourceMappingURL=ExpoScrollForwarderView.ios.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"ExpoScrollForwarderView.ios.js","sourceRoot":"","sources":["../src/ExpoScrollForwarderView.ios.tsx"],"names":[],"mappings":"AAAA,OAAO,EAAC,wBAAwB,EAAC,MAAM,mBAAmB,CAAA;AAI1D,MAAM,UAAU,GACd,wBAAwB,CAAC,qBAAqB,CAAC,CAAA;AAEjD,MAAM,UAAU,uBAAuB,CAAC,EACtC,QAAQ,EACR,GAAG,IAAI,EACsB;IAC7B,OAAO,CAAC,UAAU,CAAC,IAAI,IAAI,CAAC,CAAC,CAAC,QAAQ,CAAC,EAAE,UAAU,CAAC,CAAA;AACtD,CAAC","sourcesContent":["import {requireNativeViewManager} from 'expo-modules-core'\r\n\r\nimport {type ExpoScrollForwarderViewProps} from './ExpoScrollForwarder.types'\r\n\r\nconst NativeView: React.ComponentType<ExpoScrollForwarderViewProps> =\r\n requireNativeViewManager('ExpoScrollForwarder')\r\n\r\nexport function ExpoScrollForwarderView({\r\n children,\r\n ...rest\r\n}: ExpoScrollForwarderViewProps) {\r\n return <NativeView {...rest}>{children}</NativeView>\r\n}\r\n"]}
@@ -0,0 +1,4 @@
1
+ export function ExpoScrollForwarderView({ children, }) {
2
+ return children;
3
+ }
4
+ //# sourceMappingURL=ExpoScrollForwarderView.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"ExpoScrollForwarderView.js","sourceRoot":"","sources":["../src/ExpoScrollForwarderView.tsx"],"names":[],"mappings":"AAEA,MAAM,UAAU,uBAAuB,CAAC,EACtC,QAAQ,GAC8C;IACtD,OAAO,QAAQ,CAAA;AACjB,CAAC","sourcesContent":["import {type ExpoScrollForwarderViewProps} from './ExpoScrollForwarder.types'\r\n\r\nexport function ExpoScrollForwarderView({\r\n children,\r\n}: React.PropsWithChildren<ExpoScrollForwarderViewProps>) {\r\n return children\r\n}\r\n"]}
@@ -0,0 +1,3 @@
1
+ export { ExpoScrollForwarderView } from "./ExpoScrollForwarderView";
2
+ export type { ExpoScrollForwarderViewProps } from "./ExpoScrollForwarder.types";
3
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,uBAAuB,EAAE,MAAM,2BAA2B,CAAC;AACpE,YAAY,EAAE,4BAA4B,EAAE,MAAM,6BAA6B,CAAC"}
package/build/index.js ADDED
@@ -0,0 +1,2 @@
1
+ export { ExpoScrollForwarderView } from "./ExpoScrollForwarderView";
2
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,uBAAuB,EAAE,MAAM,2BAA2B,CAAC","sourcesContent":["export { ExpoScrollForwarderView } from \"./ExpoScrollForwarderView\";\nexport type { ExpoScrollForwarderViewProps } from \"./ExpoScrollForwarder.types\";\n"]}
@@ -0,0 +1,9 @@
1
+ {
2
+ "platforms": ["ios", "android"],
3
+ "ios": {
4
+ "modules": ["ExpoScrollForwarderModule"]
5
+ },
6
+ "android": {
7
+ "modules": ["expo.modules.scrollforwarder.ExpoScrollForwarderModule"]
8
+ }
9
+ }
@@ -0,0 +1,29 @@
1
+ require 'json'
2
+
3
+ package = JSON.parse(File.read(File.join(__dir__, '..', 'package.json')))
4
+
5
+ Pod::Spec.new do |s|
6
+ s.name = 'ExpoScrollForwarder'
7
+ s.version = package['version']
8
+ s.summary = package['description']
9
+ s.description = package['description']
10
+ s.license = package['license']
11
+ s.author = package['author']
12
+ s.homepage = package['homepage']
13
+ s.platforms = {
14
+ :ios => '15.1',
15
+ :tvos => '15.1'
16
+ }
17
+ s.swift_version = '5.9'
18
+ s.source = { git: 'https://github.com/sharifrayhan/expo-scroll-forwarder' }
19
+ s.static_framework = true
20
+
21
+ s.dependency 'ExpoModulesCore'
22
+
23
+ # Swift/Objective-C compatibility
24
+ s.pod_target_xcconfig = {
25
+ 'DEFINES_MODULE' => 'YES',
26
+ }
27
+
28
+ s.source_files = "**/*.{h,m,mm,swift,hpp,cpp}"
29
+ end
@@ -0,0 +1,48 @@
1
+ import ExpoModulesCore
2
+
3
+ public class ExpoScrollForwarderModule: Module {
4
+ // Each module class must implement the definition function. The definition consists of components
5
+ // that describes the module's functionality and behavior.
6
+ // See https://docs.expo.dev/modules/module-api for more details about available components.
7
+ public func definition() -> ModuleDefinition {
8
+ // Sets the name of the module that JavaScript code will use to refer to the module. Takes a string as an argument.
9
+ // Can be inferred from module's class name, but it's recommended to set it explicitly for clarity.
10
+ // The module will be accessible from `requireNativeModule('ExpoScrollForwarder')` in JavaScript.
11
+ Name("ExpoScrollForwarder")
12
+
13
+ // Defines constant property on the module.
14
+ Constant("PI") {
15
+ Double.pi
16
+ }
17
+
18
+ // Defines event names that the module can send to JavaScript.
19
+ Events("onChange")
20
+
21
+ // Defines a JavaScript synchronous function that runs the native code on the JavaScript thread.
22
+ Function("hello") {
23
+ return "Hello world! 👋"
24
+ }
25
+
26
+ // Defines a JavaScript function that always returns a Promise and whose native code
27
+ // is by default dispatched on the different thread than the JavaScript runtime runs on.
28
+ AsyncFunction("setValueAsync") { (value: String) in
29
+ // Send an event to JavaScript.
30
+ self.sendEvent("onChange", [
31
+ "value": value
32
+ ])
33
+ }
34
+
35
+ // Enables the module to be used as a native view. Definition components that are accepted as part of the
36
+ // view definition: Prop, Events.
37
+ View(ExpoScrollForwarderView.self) {
38
+ // Defines a setter for the `url` prop.
39
+ Prop("url") { (view: ExpoScrollForwarderView, url: URL) in
40
+ if view.webView.url != url {
41
+ view.webView.load(URLRequest(url: url))
42
+ }
43
+ }
44
+
45
+ Events("onLoad")
46
+ }
47
+ }
48
+ }
@@ -0,0 +1,38 @@
1
+ import ExpoModulesCore
2
+ import WebKit
3
+
4
+ // This view will be used as a native component. Make sure to inherit from `ExpoView`
5
+ // to apply the proper styling (e.g. border radius and shadows).
6
+ class ExpoScrollForwarderView: ExpoView {
7
+ let webView = WKWebView()
8
+ let onLoad = EventDispatcher()
9
+ var delegate: WebViewDelegate?
10
+
11
+ required init(appContext: AppContext? = nil) {
12
+ super.init(appContext: appContext)
13
+ clipsToBounds = true
14
+ delegate = WebViewDelegate { url in
15
+ self.onLoad(["url": url])
16
+ }
17
+ webView.navigationDelegate = delegate
18
+ addSubview(webView)
19
+ }
20
+
21
+ override func layoutSubviews() {
22
+ webView.frame = bounds
23
+ }
24
+ }
25
+
26
+ class WebViewDelegate: NSObject, WKNavigationDelegate {
27
+ let onUrlChange: (String) -> Void
28
+
29
+ init(onUrlChange: @escaping (String) -> Void) {
30
+ self.onUrlChange = onUrlChange
31
+ }
32
+
33
+ func webView(_ webView: WKWebView, didFinish navigation: WKNavigation) {
34
+ if let url = webView.url {
35
+ onUrlChange(url.absoluteString)
36
+ }
37
+ }
38
+ }
package/package.json ADDED
@@ -0,0 +1,44 @@
1
+ {
2
+ "name": "expo-scroll-forwarder",
3
+ "version": "0.1.0",
4
+ "description": "Forward scroll gestures from any view to a ScrollView in React Native and Expo.",
5
+ "main": "build/index.js",
6
+ "types": "build/index.d.ts",
7
+ "scripts": {
8
+ "build": "expo-module build",
9
+ "clean": "expo-module clean",
10
+ "lint": "expo-module lint",
11
+ "test": "expo-module test",
12
+ "prepare": "expo-module prepare",
13
+ "prepublishOnly": "expo-module prepublishOnly",
14
+ "expo-module": "expo-module",
15
+ "open:ios": "xed example/ios",
16
+ "open:android": "open -a \"Android Studio\" example/android"
17
+ },
18
+ "keywords": [
19
+ "react-native",
20
+ "expo",
21
+ "expo-scroll-forwarder",
22
+ "ExpoScrollForwarder"
23
+ ],
24
+ "repository": "https://github.com/sharifrayhan/expo-scroll-forwarder",
25
+ "bugs": {
26
+ "url": "https://github.com/sharifrayhan/expo-scroll-forwarder/issues"
27
+ },
28
+ "author": "Sharif Rayhan Nafi <sharifrayhan.official@gmail.com> (https://github.com/sharifrayhan)",
29
+ "license": "MIT",
30
+ "homepage": "https://github.com/sharifrayhan/expo-scroll-forwarder#readme",
31
+ "dependencies": {},
32
+ "devDependencies": {
33
+ "@types/react": "~19.1.0",
34
+ "expo-module-scripts": "^5.0.7",
35
+ "expo": "^54.0.18",
36
+ "react-native": "0.81.5"
37
+ },
38
+ "peerDependencies": {
39
+ "expo": "*",
40
+ "react": "*",
41
+ "react-native": "*"
42
+ },
43
+ "packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e"
44
+ }
@@ -0,0 +1,4 @@
1
+ export interface ExpoScrollForwarderViewProps {
2
+ scrollViewTag: number | null
3
+ children: React.ReactNode
4
+ }
@@ -0,0 +1,13 @@
1
+ import { requireNativeViewManager } from "expo-modules-core";
2
+
3
+ import { type ExpoScrollForwarderViewProps } from "./ExpoScrollForwarder.types";
4
+
5
+ const NativeView: React.ComponentType<ExpoScrollForwarderViewProps> =
6
+ requireNativeViewManager("ExpoScrollForwarder");
7
+
8
+ export function ExpoScrollForwarderView({
9
+ children,
10
+ ...rest
11
+ }: ExpoScrollForwarderViewProps) {
12
+ return <NativeView {...rest}>{children}</NativeView>;
13
+ }
@@ -0,0 +1,13 @@
1
+ import {requireNativeViewManager} from 'expo-modules-core'
2
+
3
+ import {type ExpoScrollForwarderViewProps} from './ExpoScrollForwarder.types'
4
+
5
+ const NativeView: React.ComponentType<ExpoScrollForwarderViewProps> =
6
+ requireNativeViewManager('ExpoScrollForwarder')
7
+
8
+ export function ExpoScrollForwarderView({
9
+ children,
10
+ ...rest
11
+ }: ExpoScrollForwarderViewProps) {
12
+ return <NativeView {...rest}>{children}</NativeView>
13
+ }
@@ -0,0 +1,7 @@
1
+ import {type ExpoScrollForwarderViewProps} from './ExpoScrollForwarder.types'
2
+
3
+ export function ExpoScrollForwarderView({
4
+ children,
5
+ }: React.PropsWithChildren<ExpoScrollForwarderViewProps>) {
6
+ return children
7
+ }
package/src/index.ts ADDED
@@ -0,0 +1,2 @@
1
+ export { ExpoScrollForwarderView } from "./ExpoScrollForwarderView";
2
+ export type { ExpoScrollForwarderViewProps } from "./ExpoScrollForwarder.types";
package/tsconfig.json ADDED
@@ -0,0 +1,9 @@
1
+ // @generated by expo-module-scripts
2
+ {
3
+ "extends": "expo-module-scripts/tsconfig.base",
4
+ "compilerOptions": {
5
+ "outDir": "./build"
6
+ },
7
+ "include": ["./src"],
8
+ "exclude": ["**/__mocks__/*", "**/__tests__/*", "**/__rsc_tests__/*"]
9
+ }