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.
- package/.eslintrc.js +5 -0
- package/CHANGELOG.md +11 -0
- package/README.md +182 -0
- package/android/build.gradle +51 -0
- package/android/src/main/AndroidManifest.xml +2 -0
- package/android/src/main/java/expo/modules/scrollforwarder/ExpoScrollForwarderModule.kt +16 -0
- package/android/src/main/java/expo/modules/scrollforwarder/ExpoScrollForwarderView.kt +229 -0
- package/build/ExpoScrollForwarder.types.d.ts +5 -0
- package/build/ExpoScrollForwarder.types.d.ts.map +1 -0
- package/build/ExpoScrollForwarder.types.js +2 -0
- package/build/ExpoScrollForwarder.types.js.map +1 -0
- package/build/ExpoScrollForwarderView.android.d.ts +3 -0
- package/build/ExpoScrollForwarderView.android.d.ts.map +1 -0
- package/build/ExpoScrollForwarderView.android.js +6 -0
- package/build/ExpoScrollForwarderView.android.js.map +1 -0
- package/build/ExpoScrollForwarderView.d.ts +3 -0
- package/build/ExpoScrollForwarderView.d.ts.map +1 -0
- package/build/ExpoScrollForwarderView.ios.d.ts +3 -0
- package/build/ExpoScrollForwarderView.ios.d.ts.map +1 -0
- package/build/ExpoScrollForwarderView.ios.js +6 -0
- package/build/ExpoScrollForwarderView.ios.js.map +1 -0
- package/build/ExpoScrollForwarderView.js +4 -0
- package/build/ExpoScrollForwarderView.js.map +1 -0
- package/build/index.d.ts +3 -0
- package/build/index.d.ts.map +1 -0
- package/build/index.js +2 -0
- package/build/index.js.map +1 -0
- package/expo-module.config.json +9 -0
- package/ios/ExpoScrollForwarder.podspec +29 -0
- package/ios/ExpoScrollForwarderModule.swift +48 -0
- package/ios/ExpoScrollForwarderView.swift +38 -0
- package/package.json +44 -0
- package/src/ExpoScrollForwarder.types.ts +4 -0
- package/src/ExpoScrollForwarderView.android.tsx +13 -0
- package/src/ExpoScrollForwarderView.ios.tsx +13 -0
- package/src/ExpoScrollForwarderView.tsx +7 -0
- package/src/index.ts +2 -0
- package/tsconfig.json +9 -0
package/.eslintrc.js
ADDED
package/CHANGELOG.md
ADDED
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,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 @@
|
|
|
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 @@
|
|
|
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 @@
|
|
|
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 @@
|
|
|
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"]}
|
package/build/index.d.ts
ADDED
|
@@ -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 @@
|
|
|
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,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,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
|
+
}
|
package/src/index.ts
ADDED
package/tsconfig.json
ADDED