expo-scroll-forwarder 0.1.6 → 1.0.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.
@@ -0,0 +1,17 @@
1
+ Pod::Spec.new do |s|
2
+ s.name = 'ExpoScrollForwarder'
3
+ s.version = '1.0.0'
4
+ s.summary = 'Forward scroll gesture from UIView to UIScrollView'
5
+ s.description = 'Forward scroll gesture from UIView to UIScrollView'
6
+ s.author = 'Sharif Rayhan Nafi'
7
+ s.homepage = 'https://github.com/sharifrayhan/expo-scroll-forwarder'
8
+ s.platforms = { :ios => '13.4' }
9
+ s.source = { git: '', tag: s.version }
10
+ s.static_framework = true
11
+ s.dependency 'ExpoModulesCore'
12
+ s.pod_target_xcconfig = {
13
+ 'DEFINES_MODULE' => 'YES',
14
+ 'SWIFT_COMPILATION_MODE' => 'wholemodule'
15
+ }
16
+ s.source_files = "src/ios/*.{h,m,mm,swift,hpp,cpp}"
17
+ end
package/LICENSE ADDED
File without changes
package/README.md CHANGED
@@ -1,182 +1,30 @@
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/) 🚀
1
+ # expo-scroll-forwarder
2
+
3
+ [![npm version](https://img.shields.io/npm/v/expo-scroll-forwarder.svg)](https://www.npmjs.com/package/expo-scroll-forwarder)
4
+ [![license](https://img.shields.io/npm/l/expo-scroll-forwarder.svg)](https://github.com/sharifrayhan/expo-scroll-forwarder/blob/main/LICENSE)
5
+
6
+ `expo-scroll-forwarder` is an **iOS-only Expo module** that allows you to forward scroll gestures from a native view to a `ScrollView`. This is useful for creating **custom headers, pull-to-refresh areas, or gesture-forwarding components** in React Native and Expo.
7
+
8
+ > ⚠️ Android support will be added in a future release.
9
+
10
+ ---
11
+
12
+ ## Features
13
+
14
+ - Forward vertical scroll gestures from a native view to a `ScrollView`.
15
+ - Compatible with Expo modules architecture.
16
+ - Pure iOS implementation using Swift and `ExpoModulesCore`.
17
+ - Works with `ScrollView` and `RefreshControl`.
18
+ - Fully typed with TypeScript for React Native.
19
+
20
+ ---
21
+
22
+ ## Installation
23
+
24
+ Install the package in your Expo or React Native project:
25
+
26
+ ```bash
27
+ npm install expo-scroll-forwarder
28
+ # or
29
+ yarn add expo-scroll-forwarder
30
+ ```
@@ -1,9 +1,6 @@
1
- {
2
- "platforms": ["apple", "android"],
3
- "apple": {
4
- "modules": ["ExpoScrollForwarderModule"]
5
- },
6
- "android": {
7
- "modules": ["expo.modules.scrollforwarder.ExpoScrollForwarderModule"]
8
- }
9
- }
1
+ {
2
+ "platforms": ["ios"],
3
+ "ios": {
4
+ "modules": ["ExpoScrollForwarderModule"]
5
+ }
6
+ }
package/index.ts ADDED
@@ -0,0 +1 @@
1
+ export { ExpoScrollForwarderView } from "./src/ExpoScrollForwarderView";
package/package.json CHANGED
@@ -1,43 +1,33 @@
1
- {
2
- "name": "expo-scroll-forwarder",
3
- "version": "0.1.6",
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 <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
- }
1
+ {
2
+ "name": "expo-scroll-forwarder",
3
+ "version": "1.0.0",
4
+ "main": "index.ts",
5
+ "types": "index.ts",
6
+ "files": [
7
+ "src",
8
+ "index.ts",
9
+ "ExpoScrollForwarder.podspec",
10
+ "expo-module.config.json"
11
+ ],
12
+ "peerDependencies": {
13
+ "react": "*",
14
+ "react-native": "*",
15
+ "expo-modules-core": "*"
16
+ },
17
+ "devDependencies": {
18
+ "typescript": "^5.2.0",
19
+ "@types/react": "^18.2.0",
20
+ "@types/react-native": "^0.72.0",
21
+ "expo-modules-core": "*"
22
+ },
23
+ "scripts": {
24
+ "build": "tsc --build"
25
+ },
26
+ "keywords": [
27
+ "expo",
28
+ "react-native",
29
+ "scroll",
30
+ "forwarder",
31
+ "module"
32
+ ]
33
+ }
@@ -1,4 +1,4 @@
1
- export interface ExpoScrollForwarderViewProps {
2
- scrollViewTag: number | null
3
- children: React.ReactNode
4
- }
1
+ export interface ExpoScrollForwarderViewProps {
2
+ scrollViewTag: number | null;
3
+ children: React.ReactNode;
4
+ }
@@ -0,0 +1,8 @@
1
+ import * as React from "react";
2
+ import { ExpoScrollForwarderViewProps } from "./ExpoScrollForwarder.types";
3
+
4
+ export function ExpoScrollForwarderView({
5
+ children,
6
+ }: ExpoScrollForwarderViewProps) {
7
+ return children; // placeholder for Android
8
+ }
@@ -0,0 +1,13 @@
1
+ import * as React from "react";
2
+ import { requireNativeViewManager } from "expo-modules-core";
3
+ import { 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
+ }
@@ -1,13 +1,8 @@
1
- import { requireNativeViewManager } from "expo-modules-core";
2
- import * as React from "react";
3
- import { 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
- }
1
+ import * as React from "react";
2
+ import { ExpoScrollForwarderViewProps } from "./ExpoScrollForwarder.types";
3
+
4
+ export function ExpoScrollForwarderView({
5
+ children,
6
+ }: ExpoScrollForwarderViewProps) {
7
+ return children; // cross-platform fallback
8
+ }
@@ -1,13 +1,13 @@
1
- import ExpoModulesCore
2
-
3
- public class ExpoScrollForwarderModule: Module {
4
- public func definition() -> ModuleDefinition {
5
- Name("ExpoScrollForwarder")
6
-
7
- View(ExpoScrollForwarderView.self) {
8
- Prop("scrollViewTag") { (view: ExpoScrollForwarderView, prop: Int) in
9
- view.scrollViewTag = prop
10
- }
11
- }
12
- }
13
- }
1
+ import ExpoModulesCore
2
+
3
+ public class ExpoScrollForwarderModule: Module {
4
+ public func definition() -> ModuleDefinition {
5
+ Name("ExpoScrollForwarder")
6
+
7
+ View(ExpoScrollForwarderView.self) {
8
+ Prop("scrollViewTag") { (view: ExpoScrollForwarderView, prop: Int) in
9
+ view.scrollViewTag = prop
10
+ }
11
+ }
12
+ }
13
+ }
@@ -1,221 +1,138 @@
1
- import ExpoModulesCore
2
-
3
- // This view will be used as a native component. Make sure to inherit from `ExpoView`
4
- // to apply the proper styling (e.g. border radius and shadows).
5
- class ExpoScrollForwarderView: ExpoView, UIGestureRecognizerDelegate {
6
- var scrollViewTag: Int? {
7
- didSet {
8
- self.tryFindScrollView()
9
- }
10
- }
11
-
12
- private var rctScrollView: RCTScrollView?
13
- private var rctRefreshCtrl: RCTRefreshControl?
14
- private var cancelGestureRecognizers: [UIGestureRecognizer]?
15
- private var animTimer: Timer?
16
- private var initialOffset: CGFloat = 0.0
17
- private var didImpact: Bool = false
18
-
19
- required init(appContext: AppContext? = nil) {
20
- super.init(appContext: appContext)
21
-
22
- let pg = UIPanGestureRecognizer(target: self, action: #selector(callOnPan(_:)))
23
- pg.delegate = self
24
- self.addGestureRecognizer(pg)
25
-
26
- let tg = UITapGestureRecognizer(target: self, action: #selector(callOnPress(_:)))
27
- tg.isEnabled = false
28
- tg.delegate = self
29
-
30
- let lpg = UILongPressGestureRecognizer(target: self, action: #selector(callOnPress(_:)))
31
- lpg.minimumPressDuration = 0.01
32
- lpg.isEnabled = false
33
- lpg.delegate = self
34
-
35
- self.cancelGestureRecognizers = [lpg, tg]
36
- }
37
-
38
- // We don't want to recognize the scroll pan gesture and the swipe back gesture together
39
- func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool {
40
- if gestureRecognizer is UIPanGestureRecognizer, otherGestureRecognizer is UIPanGestureRecognizer {
41
- return false
42
- }
43
-
44
- return true
45
- }
46
-
47
- // We only want the "scroll" gesture to happen whenever the pan is vertical, otherwise it will
48
- // interfere with the native swipe back gesture.
49
- override func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
50
- guard let gestureRecognizer = gestureRecognizer as? UIPanGestureRecognizer else {
51
- return true
52
- }
53
-
54
- let velocity = gestureRecognizer.velocity(in: self)
55
- return abs(velocity.y) > abs(velocity.x)
56
- }
57
-
58
- // This will be used to cancel the scroll animation whenever we tap inside of the header. We don't need another
59
- // recognizer for this one.
60
- override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
61
- self.stopTimer()
62
- }
63
-
64
- // This will be used to cancel the animation whenever we press inside of the scroll view. We don't want to change
65
- // the scroll view gesture's delegate, so we add an additional recognizer to detect this.
66
- @IBAction func callOnPress(_ sender: UITapGestureRecognizer) {
67
- self.stopTimer()
68
- }
69
-
70
- @IBAction func callOnPan(_ sender: UIPanGestureRecognizer) {
71
- guard let rctsv = self.rctScrollView, let sv = rctsv.scrollView else {
72
- return
73
- }
74
-
75
- let translation = sender.translation(in: self).y
76
-
77
- if sender.state == .began {
78
- if sv.contentOffset.y < 0 {
79
- sv.contentOffset.y = 0
80
- }
81
-
82
- self.initialOffset = sv.contentOffset.y
83
- }
84
-
85
- if sender.state == .changed {
86
- sv.contentOffset.y = self.dampenOffset(-translation + self.initialOffset)
87
-
88
- if sv.contentOffset.y <= -130, !didImpact {
89
- let generator = UIImpactFeedbackGenerator(style: .light)
90
- generator.impactOccurred()
91
-
92
- self.didImpact = true
93
- }
94
- }
95
-
96
- if sender.state == .ended {
97
- let velocity = sender.velocity(in: self).y
98
- self.didImpact = false
99
-
100
- if sv.contentOffset.y <= -130 {
101
- if let ctrl = self.rctRefreshCtrl {
102
- if ctrl.responds(to: Selector(("forwarderBeginRefreshing"))) {
103
- ctrl.perform(Selector(("forwarderBeginRefreshing")))
104
- } else {
105
- ctrl.beginRefreshing()
106
- ctrl.sendActions(for: .valueChanged)
107
- }
108
- }
109
-
110
- return
111
- }
112
-
113
- // A check for a velocity under 250 prevents animations from occurring when they wouldn't in a normal
114
- // scroll view
115
- if abs(velocity) < 250, sv.contentOffset.y >= 0 {
116
- return
117
- }
118
-
119
- self.startDecayAnimation(translation, velocity)
120
- }
121
- }
122
-
123
- func startDecayAnimation(_ translation: CGFloat, _ velocity: CGFloat) {
124
- guard let sv = self.rctScrollView?.scrollView else {
125
- return
126
- }
127
-
128
- var velocity = velocity
129
-
130
- self.enableCancelGestureRecognizers()
131
-
132
- if velocity > 0 {
133
- velocity = min(velocity, 5000)
134
- } else {
135
- velocity = max(velocity, -5000)
136
- }
137
-
138
- var animTranslation = -translation
139
- self.animTimer = Timer.scheduledTimer(withTimeInterval: 1.0 / 120, repeats: true) { _ in
140
- velocity *= 0.9875
141
- animTranslation = (-velocity / 120) + animTranslation
142
-
143
- let nextOffset = self.dampenOffset(animTranslation + self.initialOffset)
144
-
145
- if nextOffset <= 0 {
146
- if self.initialOffset <= 1 {
147
- self.scrollToOffset(0)
148
- } else {
149
- sv.contentOffset.y = 0
150
- }
151
-
152
- self.stopTimer()
153
- return
154
- } else {
155
- sv.contentOffset.y = nextOffset
156
- }
157
-
158
- if abs(velocity) < 5 {
159
- self.stopTimer()
160
- }
161
- }
162
- }
163
-
164
- func dampenOffset(_ offset: CGFloat) -> CGFloat {
165
- if offset < 0 {
166
- return offset - (offset * 0.55)
167
- }
168
-
169
- return offset
170
- }
171
-
172
- func tryFindScrollView() {
173
- guard let scrollViewTag = scrollViewTag else {
174
- return
175
- }
176
-
177
- // Before we switch to a different scrollview, we always want to remove the cancel gesture recognizer.
178
- // Otherwise we might end up with duplicates when we switch back to that scrollview.
179
- self.removeCancelGestureRecognizers()
180
-
181
- self.rctScrollView = self.appContext?
182
- .findView(withTag: scrollViewTag, ofType: RCTScrollView.self)
183
- self.rctRefreshCtrl = self.rctScrollView?.scrollView.refreshControl as? RCTRefreshControl
184
-
185
- self.addCancelGestureRecognizers()
186
- }
187
-
188
- func addCancelGestureRecognizers() {
189
- self.cancelGestureRecognizers?.forEach { r in
190
- self.rctScrollView?.scrollView?.addGestureRecognizer(r)
191
- }
192
- }
193
-
194
- func removeCancelGestureRecognizers() {
195
- self.cancelGestureRecognizers?.forEach { r in
196
- self.rctScrollView?.scrollView?.removeGestureRecognizer(r)
197
- }
198
- }
199
-
200
- func enableCancelGestureRecognizers() {
201
- self.cancelGestureRecognizers?.forEach { r in
202
- r.isEnabled = true
203
- }
204
- }
205
-
206
- func disableCancelGestureRecognizers() {
207
- self.cancelGestureRecognizers?.forEach { r in
208
- r.isEnabled = false
209
- }
210
- }
211
-
212
- func scrollToOffset(_ offset: Int, animated: Bool = true) {
213
- self.rctScrollView?.scroll(toOffset: CGPoint(x: 0, y: offset), animated: animated)
214
- }
215
-
216
- func stopTimer() {
217
- self.disableCancelGestureRecognizers()
218
- self.animTimer?.invalidate()
219
- self.animTimer = nil
220
- }
221
- }
1
+ import ExpoModulesCore
2
+
3
+ class ExpoScrollForwarderView: ExpoView, UIGestureRecognizerDelegate {
4
+ var scrollViewTag: Int? {
5
+ didSet {
6
+ self.tryFindScrollView()
7
+ }
8
+ }
9
+
10
+ private var rctScrollView: RCTScrollView?
11
+ private var rctRefreshCtrl: RCTRefreshControl?
12
+ private var cancelGestureRecognizers: [UIGestureRecognizer]?
13
+ private var animTimer: Timer?
14
+ private var initialOffset: CGFloat = 0.0
15
+ private var didImpact: Bool = false
16
+
17
+ required init(appContext: AppContext? = nil) {
18
+ super.init(appContext: appContext)
19
+
20
+ let pg = UIPanGestureRecognizer(target: self, action: #selector(callOnPan(_:)))
21
+ pg.delegate = self
22
+ self.addGestureRecognizer(pg)
23
+
24
+ let tg = UITapGestureRecognizer(target: self, action: #selector(callOnPress(_:)))
25
+ tg.isEnabled = false
26
+ tg.delegate = self
27
+
28
+ let lpg = UILongPressGestureRecognizer(target: self, action: #selector(callOnPress(_:)))
29
+ lpg.minimumPressDuration = 0.01
30
+ lpg.isEnabled = false
31
+ lpg.delegate = self
32
+
33
+ self.cancelGestureRecognizers = [lpg, tg]
34
+ }
35
+
36
+ func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool {
37
+ if gestureRecognizer is UIPanGestureRecognizer, otherGestureRecognizer is UIPanGestureRecognizer {
38
+ return false
39
+ }
40
+ return true
41
+ }
42
+
43
+ override func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
44
+ guard let gestureRecognizer = gestureRecognizer as? UIPanGestureRecognizer else {
45
+ return true
46
+ }
47
+ let velocity = gestureRecognizer.velocity(in: self)
48
+ return abs(velocity.y) > abs(velocity.x)
49
+ }
50
+
51
+ override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
52
+ self.stopTimer()
53
+ }
54
+
55
+ @IBAction func callOnPress(_ sender: UITapGestureRecognizer) {
56
+ self.stopTimer()
57
+ }
58
+
59
+ @IBAction func callOnPan(_ sender: UIPanGestureRecognizer) {
60
+ guard let rctsv = self.rctScrollView, let sv = rctsv.scrollView else { return }
61
+
62
+ let translation = sender.translation(in: self).y
63
+
64
+ if sender.state == .began {
65
+ if sv.contentOffset.y < 0 { sv.contentOffset.y = 0 }
66
+ self.initialOffset = sv.contentOffset.y
67
+ }
68
+
69
+ if sender.state == .changed {
70
+ sv.contentOffset.y = self.dampenOffset(-translation + self.initialOffset)
71
+ if sv.contentOffset.y <= -130, !didImpact {
72
+ let generator = UIImpactFeedbackGenerator(style: .light)
73
+ generator.impactOccurred()
74
+ self.didImpact = true
75
+ }
76
+ }
77
+
78
+ if sender.state == .ended {
79
+ let velocity = sender.velocity(in: self).y
80
+ self.didImpact = false
81
+
82
+ if sv.contentOffset.y <= -130 {
83
+ self.rctRefreshCtrl?.forwarderBeginRefreshing()
84
+ return
85
+ }
86
+
87
+ if abs(velocity) < 250, sv.contentOffset.y >= 0 { return }
88
+ self.startDecayAnimation(translation, velocity)
89
+ }
90
+ }
91
+
92
+ func startDecayAnimation(_ translation: CGFloat, _ velocity: CGFloat) {
93
+ guard let sv = self.rctScrollView?.scrollView else { return }
94
+
95
+ var velocity = velocity
96
+ self.enableCancelGestureRecognizers()
97
+
98
+ if velocity > 0 { velocity = min(velocity, 5000) }
99
+ else { velocity = max(velocity, -5000) }
100
+
101
+ var animTranslation = -translation
102
+ self.animTimer = Timer.scheduledTimer(withTimeInterval: 1.0/120, repeats: true) { _ in
103
+ velocity *= 0.9875
104
+ animTranslation = (-velocity/120) + animTranslation
105
+ let nextOffset = self.dampenOffset(animTranslation + self.initialOffset)
106
+
107
+ if nextOffset <= 0 {
108
+ if self.initialOffset <= 1 { self.scrollToOffset(0) }
109
+ else { sv.contentOffset.y = 0 }
110
+ self.stopTimer()
111
+ return
112
+ } else {
113
+ sv.contentOffset.y = nextOffset
114
+ }
115
+
116
+ if abs(velocity) < 5 { self.stopTimer() }
117
+ }
118
+ }
119
+
120
+ func dampenOffset(_ offset: CGFloat) -> CGFloat {
121
+ return offset < 0 ? offset - (offset*0.55) : offset
122
+ }
123
+
124
+ func tryFindScrollView() {
125
+ guard let scrollViewTag = scrollViewTag else { return }
126
+ self.removeCancelGestureRecognizers()
127
+ self.rctScrollView = self.appContext?.findView(withTag: scrollViewTag, ofType: RCTScrollView.self)
128
+ self.rctRefreshCtrl = self.rctScrollView?.scrollView.refreshControl as? RCTRefreshControl
129
+ self.addCancelGestureRecognizers()
130
+ }
131
+
132
+ func addCancelGestureRecognizers() { self.cancelGestureRecognizers?.forEach { r in self.rctScrollView?.scrollView?.addGestureRecognizer(r) } }
133
+ func removeCancelGestureRecognizers() { self.cancelGestureRecognizers?.forEach { r in self.rctScrollView?.scrollView?.removeGestureRecognizer(r) } }
134
+ func enableCancelGestureRecognizers() { self.cancelGestureRecognizers?.forEach { r in r.isEnabled = true } }
135
+ func disableCancelGestureRecognizers() { self.cancelGestureRecognizers?.forEach { r in r.isEnabled = false } }
136
+ func scrollToOffset(_ offset: Int, animated: Bool = true) { self.rctScrollView?.scroll(toOffset: CGPoint(x:0,y:offset), animated:animated) }
137
+ func stopTimer() { self.disableCancelGestureRecognizers(); self.animTimer?.invalidate(); self.animTimer=nil }
138
+ }
package/.eslintrc.js DELETED
@@ -1,5 +0,0 @@
1
- module.exports = {
2
- root: true,
3
- extends: ['universe/native', 'universe/web'],
4
- ignorePatterns: ['build'],
5
- };
@@ -1,48 +0,0 @@
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
- // Add the dependencies block here
46
- dependencies {
47
- implementation 'com.facebook.react:react-android'
48
- }
@@ -1,2 +0,0 @@
1
- <manifest>
2
- </manifest>
@@ -1,17 +0,0 @@
1
- package expo.modules.scrollforwarder
2
-
3
- import expo.modules.kotlin.modules.Module
4
- import expo.modules.kotlin.modules.ModuleDefinition
5
- import expo.modules.kotlin.views.ExpoView
6
-
7
- class ExpoScrollForwarderModule : Module() {
8
- override fun definition() = ModuleDefinition {
9
- Name("ExpoScrollForwarder")
10
-
11
- View(ExpoScrollForwarderView::class) {
12
- Prop("scrollViewTag") { view: ExpoScrollForwarderView, prop: Int ->
13
- view.scrollViewTag = prop
14
- }
15
- }
16
- }
17
- }
@@ -1,223 +0,0 @@
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.animation.DecelerateInterpolator
8
- import androidx.core.view.GestureDetectorCompat
9
- import com.facebook.react.views.scroll.ReactScrollView
10
- import expo.modules.kotlin.AppContext
11
- import expo.modules.kotlin.views.ExpoView
12
- import kotlin.math.abs
13
-
14
- class ExpoScrollForwarderView(context: Context, appContext: AppContext) : ExpoView(context, appContext) {
15
-
16
- var scrollViewTag: Int? = null
17
- set(value) {
18
- field = value
19
- tryFindScrollView()
20
- }
21
-
22
- private var reactScrollView: ReactScrollView? = null
23
- private var gestureDetector: GestureDetectorCompat
24
- private var isScrolling = false
25
- private var initialScrollY = 0
26
- private var lastY = 0f
27
- private var decayAnimator: ValueAnimator? = null
28
- private var didImpact = false
29
-
30
- init {
31
- gestureDetector = GestureDetectorCompat(context, GestureListener())
32
- }
33
-
34
- override fun onInterceptTouchEvent(ev: MotionEvent): Boolean {
35
- val scrollView = reactScrollView ?: return super.onInterceptTouchEvent(ev)
36
-
37
- when (ev.action) {
38
- MotionEvent.ACTION_DOWN -> {
39
- stopDecayAnimation()
40
- lastY = ev.rawY
41
- initialScrollY = scrollView.scrollY
42
- isScrolling = false
43
- }
44
- MotionEvent.ACTION_MOVE -> {
45
- val deltaY = lastY - ev.rawY
46
-
47
- // Check if this is a vertical scroll gesture
48
- if (!isScrolling && abs(deltaY) > 10) {
49
- // Calculate if the gesture is more vertical than horizontal
50
- val initialX = if (ev.historySize > 0) ev.getHistoricalX(0) else ev.x
51
- val initialY = if (ev.historySize > 0) ev.getHistoricalY(0) else ev.y
52
- val deltaX = ev.x - initialX
53
- val deltaYCheck = ev.y - initialY
54
-
55
- if (abs(deltaYCheck) > abs(deltaX)) {
56
- isScrolling = true
57
- return true
58
- }
59
- }
60
- }
61
- }
62
-
63
- return isScrolling || super.onInterceptTouchEvent(ev)
64
- }
65
-
66
- override fun onTouchEvent(event: MotionEvent): Boolean {
67
- val scrollView = reactScrollView ?: return super.onTouchEvent(event)
68
-
69
- gestureDetector.onTouchEvent(event)
70
-
71
- when (event.action) {
72
- MotionEvent.ACTION_DOWN -> {
73
- stopDecayAnimation()
74
- lastY = event.rawY
75
- initialScrollY = scrollView.scrollY
76
- if (scrollView.scrollY < 0) {
77
- scrollView.scrollTo(0, 0)
78
- }
79
- return true
80
- }
81
-
82
- MotionEvent.ACTION_MOVE -> {
83
- val deltaY = (lastY - event.rawY).toInt()
84
- val newOffset = dampenOffset(initialScrollY + deltaY)
85
-
86
- scrollView.scrollTo(0, newOffset)
87
-
88
- // Haptic feedback at refresh threshold
89
- if (newOffset <= -130 && !didImpact) {
90
- performHapticFeedback(android.view.HapticFeedbackConstants.LONG_PRESS)
91
- didImpact = true
92
- }
93
-
94
- return true
95
- }
96
-
97
- MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> {
98
- val velocityY = calculateVelocity(event)
99
- didImpact = false
100
-
101
- // Check for pull-to-refresh threshold
102
- if (scrollView.scrollY <= -130) {
103
- triggerRefresh(scrollView)
104
- return true
105
- }
106
-
107
- // Don't animate if velocity is too low and we're at a valid position
108
- if (abs(velocityY) < 250 && scrollView.scrollY >= 0) {
109
- isScrolling = false
110
- return true
111
- }
112
-
113
- startDecayAnimation(scrollView, velocityY.toFloat())
114
- isScrolling = false
115
- return true
116
- }
117
- }
118
-
119
- return super.onTouchEvent(event)
120
- }
121
-
122
- private fun calculateVelocity(event: MotionEvent): Int {
123
- if (event.historySize == 0) return 0
124
-
125
- val lastHistoricalY = event.getHistoricalY(event.historySize - 1)
126
- val lastHistoricalTime = event.getHistoricalEventTime(event.historySize - 1)
127
- val deltaY = event.y - lastHistoricalY
128
- val deltaTime = (event.eventTime - lastHistoricalTime) / 1000f
129
-
130
- return if (deltaTime > 0) {
131
- (-deltaY / deltaTime).toInt()
132
- } else {
133
- 0
134
- }
135
- }
136
-
137
- private fun startDecayAnimation(scrollView: ReactScrollView, velocity: Float) {
138
- var currentVelocity = velocity.coerceIn(-5000f, 5000f)
139
- val startOffset = scrollView.scrollY
140
- var currentOffset = startOffset.toFloat()
141
-
142
- decayAnimator = ValueAnimator.ofFloat(0f, 1f).apply {
143
- duration = 3000
144
- interpolator = DecelerateInterpolator()
145
-
146
- addUpdateListener { animator ->
147
- currentVelocity *= 0.9875f
148
- currentOffset += (-currentVelocity / 120f)
149
-
150
- val newOffset = dampenOffset(currentOffset.toInt())
151
-
152
- when {
153
- newOffset <= 0 -> {
154
- scrollView.smoothScrollTo(0, 0)
155
- cancel()
156
- }
157
- else -> {
158
- scrollView.scrollTo(0, newOffset)
159
- }
160
- }
161
-
162
- if (abs(currentVelocity) < 5) {
163
- cancel()
164
- }
165
- }
166
- }
167
- decayAnimator?.start()
168
- }
169
-
170
- private fun dampenOffset(offset: Int): Int {
171
- return if (offset < 0) {
172
- (offset - (offset * 0.55)).toInt()
173
- } else {
174
- offset
175
- }
176
- }
177
-
178
- private fun triggerRefresh(scrollView: ReactScrollView) {
179
- // Trigger refresh by finding and activating the refresh control
180
- try {
181
- for (i in 0 until scrollView.childCount) {
182
- val child = scrollView.getChildAt(i)
183
- if (child.javaClass.simpleName.contains("Refresh")) {
184
- // Try to invoke setRefreshing via reflection
185
- val method = child.javaClass.getMethod("setRefreshing", Boolean::class.javaPrimitiveType)
186
- method.invoke(child, true)
187
- break
188
- }
189
- }
190
- } catch (e: Exception) {
191
- // Fallback: just scroll to trigger position
192
- scrollView.scrollTo(0, -140)
193
- }
194
- }
195
-
196
- private fun stopDecayAnimation() {
197
- decayAnimator?.cancel()
198
- decayAnimator = null
199
- }
200
-
201
- private fun tryFindScrollView() {
202
- val tag = scrollViewTag ?: return
203
-
204
- reactScrollView = appContext?.findView(tag) as? ReactScrollView
205
- }
206
-
207
- inner class GestureListener : GestureDetector.SimpleOnGestureListener() {
208
- override fun onDown(e: MotionEvent): Boolean {
209
- stopDecayAnimation()
210
- return true
211
- }
212
-
213
- override fun onSingleTapUp(e: MotionEvent): Boolean {
214
- stopDecayAnimation()
215
- return true
216
- }
217
- }
218
-
219
- override fun onDetachedFromWindow() {
220
- super.onDetachedFromWindow()
221
- stopDecayAnimation()
222
- }
223
- }
@@ -1,5 +0,0 @@
1
- export interface ExpoScrollForwarderViewProps {
2
- scrollViewTag: number | null;
3
- children: React.ReactNode;
4
- }
5
- //# sourceMappingURL=ExpoScrollForwarder.types.d.ts.map
@@ -1 +0,0 @@
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"}
@@ -1,2 +0,0 @@
1
- export {};
2
- //# sourceMappingURL=ExpoScrollForwarder.types.js.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"ExpoScrollForwarder.types.js","sourceRoot":"","sources":["../src/ExpoScrollForwarder.types.ts"],"names":[],"mappings":"","sourcesContent":["export interface ExpoScrollForwarderViewProps {\n scrollViewTag: number | null\n children: React.ReactNode\n}\n"]}
@@ -1,4 +0,0 @@
1
- import * as React from "react";
2
- import { ExpoScrollForwarderViewProps } from "./ExpoScrollForwarder.types";
3
- export declare function ExpoScrollForwarderView({ children, ...rest }: ExpoScrollForwarderViewProps): React.JSX.Element;
4
- //# sourceMappingURL=ExpoScrollForwarderView.d.ts.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"ExpoScrollForwarderView.d.ts","sourceRoot":"","sources":["../src/ExpoScrollForwarderView.tsx"],"names":[],"mappings":"AACA,OAAO,KAAK,KAAK,MAAM,OAAO,CAAC;AAC/B,OAAO,EAAE,4BAA4B,EAAE,MAAM,6BAA6B,CAAC;AAK3E,wBAAgB,uBAAuB,CAAC,EACtC,QAAQ,EACR,GAAG,IAAI,EACR,EAAE,4BAA4B,qBAE9B"}
@@ -1,7 +0,0 @@
1
- import { requireNativeViewManager } from "expo-modules-core";
2
- import * as React from "react";
3
- const NativeView = requireNativeViewManager("ExpoScrollForwarder");
4
- export function ExpoScrollForwarderView({ children, ...rest }) {
5
- return <NativeView {...rest}>{children}</NativeView>;
6
- }
7
- //# sourceMappingURL=ExpoScrollForwarderView.js.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"ExpoScrollForwarderView.js","sourceRoot":"","sources":["../src/ExpoScrollForwarderView.tsx"],"names":[],"mappings":"AAAA,OAAO,EAAE,wBAAwB,EAAE,MAAM,mBAAmB,CAAC;AAC7D,OAAO,KAAK,KAAK,MAAM,OAAO,CAAC;AAG/B,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\";\nimport * as React from \"react\";\nimport { ExpoScrollForwarderViewProps } from \"./ExpoScrollForwarder.types\";\n\nconst NativeView: React.ComponentType<ExpoScrollForwarderViewProps> =\n requireNativeViewManager(\"ExpoScrollForwarder\");\n\nexport function ExpoScrollForwarderView({\n children,\n ...rest\n}: ExpoScrollForwarderViewProps) {\n return <NativeView {...rest}>{children}</NativeView>;\n}\n"]}
package/build/index.d.ts DELETED
@@ -1,2 +0,0 @@
1
- export { ExpoScrollForwarderView } from "./ExpoScrollForwarderView";
2
- //# sourceMappingURL=index.d.ts.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,uBAAuB,EAAE,MAAM,2BAA2B,CAAC"}
package/build/index.js DELETED
@@ -1,2 +0,0 @@
1
- export { ExpoScrollForwarderView } from "./ExpoScrollForwarderView";
2
- //# sourceMappingURL=index.js.map
@@ -1 +0,0 @@
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\";\n"]}
@@ -1,29 +0,0 @@
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
package/src/index.ts DELETED
@@ -1 +0,0 @@
1
- export { ExpoScrollForwarderView } from "./ExpoScrollForwarderView";
package/tsconfig.json DELETED
@@ -1,9 +0,0 @@
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
- }