react-native-capped-scrollview 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.
@@ -0,0 +1,20 @@
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 = "CappedScrollView"
7
+ s.version = package["version"]
8
+ s.summary = package["description"]
9
+ s.homepage = package["homepage"]
10
+ s.license = package["license"]
11
+ s.authors = package["author"]
12
+
13
+ s.platforms = { :ios => min_ios_version_supported }
14
+ s.source = { :git => "https://github.com/leonsilicon/react-native-capped-scrollview.git", :tag => "#{s.version}" }
15
+
16
+ s.source_files = "ios/**/*.{h,m,mm,swift,cpp}"
17
+ s.private_header_files = "ios/**/*.h"
18
+
19
+ install_modules_dependencies(s)
20
+ end
package/LICENSE ADDED
@@ -0,0 +1,20 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Leon Si
4
+ Permission is hereby granted, free of charge, to any person obtaining a copy
5
+ of this software and associated documentation files (the "Software"), to deal
6
+ in the Software without restriction, including without limitation the rights
7
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
8
+ copies of the Software, and to permit persons to whom the Software is
9
+ furnished to do so, subject to the following conditions:
10
+
11
+ The above copyright notice and this permission notice shall be included in all
12
+ copies or substantial portions of the Software.
13
+
14
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
15
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
16
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
17
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
18
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
19
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
20
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,125 @@
1
+ # react-native-capped-scrollview
2
+
3
+ A drop-in replacement for React Native's `ScrollView` that lets you cap how
4
+ fast the user can fling the list. Useful for surfaces where you want to keep
5
+ the visual scroll speed inside a comfortable, readable range — long-form
6
+ content, kiosk-style UIs, accessibility modes — without disabling momentum
7
+ entirely.
8
+
9
+ <p align="center">
10
+ <img src="https://raw.githubusercontent.com/leonsilicon/react-native-capped-scrollview/main/scrollview-capped.gif" width="320" alt="Side-by-side demo: capped column on the left flings at a controlled speed while the plain ScrollView on the right coasts naturally." />
11
+ </p>
12
+
13
+ ## Installation
14
+
15
+ ```sh
16
+ npm install react-native-capped-scrollview
17
+ # or
18
+ yarn add react-native-capped-scrollview
19
+ # or
20
+ pnpm add react-native-capped-scrollview
21
+ ```
22
+
23
+ Then on iOS run `pod install` inside the `ios/` directory (or `npx expo prebuild` if you're on Expo). Android picks the library up automatically via autolinking.
24
+
25
+ Requires the New Architecture (Fabric + TurboModules). Tested against React Native 0.83; should work on any RN ≥ 0.80.
26
+
27
+ ## Usage
28
+
29
+ ```tsx
30
+ import { CappedScrollView } from 'react-native-capped-scrollview';
31
+
32
+ export function MyList() {
33
+ return (
34
+ <CappedScrollView maxVelocity={0.25} style={{ flex: 1 }}>
35
+ {items.map((item) => (
36
+ <Row key={item.id} item={item} />
37
+ ))}
38
+ </CappedScrollView>
39
+ );
40
+ }
41
+ ```
42
+
43
+ `CappedScrollView` accepts every prop the built-in `ScrollView` accepts (it renders one under the hood) and adds a single new prop, `maxVelocity`.
44
+
45
+ The GIF above is the example app from `example/` — a `CappedScrollView` on the left and a plain `ScrollView` on the right being flung with the same gesture. Run it with `yarn example ios` or `yarn example android`.
46
+
47
+ ## API
48
+
49
+ ### `maxVelocity?: number | null`
50
+
51
+ Normalized fling-velocity cap.
52
+
53
+ | Value | Behaviour |
54
+ | -------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- |
55
+ | `null`/omitted | No capper installed. Behaves exactly like a plain `ScrollView`. **Default.** |
56
+ | `0` | List cannot fling. Drag still works; releasing the finger stops the scroll dead. |
57
+ | `1` | Capper installed but set to the reference maximum. Human-paced flings pass through untouched; only faster-than-human programmatic flings get clamped. |
58
+ | `0 < x < 1` | Peak fling velocity is clamped to `x × 8000 dp/s` on Android (`x × 8000 pt/s` on iOS). The two units are identical, so the cap on each platform corresponds to the same logical scroll speed. |
59
+
60
+ The reference max is **8000 dp/s** on Android (scaled to physical pixels via `displayMetrics.density`) and **8000 pt/s** on iOS. Because a `dp` and a `pt` are the same logical unit, the same `maxVelocity` value applies the same peak-velocity cap on both platforms.
61
+
62
+ > [!NOTE]
63
+ > The cap controls *peak velocity*, not fling distance. iOS's `UIScrollView` decelerates exponentially while Android's `OverScroller` uses a spline whose distance is roughly `velocity^1.7`, so a single `maxVelocity` value produces a similar starting speed on both platforms but Android's fling will cover proportionally less distance than iOS's. If you need pixel-for-pixel cross-platform parity, drive the scroll yourself via `scrollTo` with an animation library.
64
+
65
+ The distinction between `null` and `1` is intentional: at `1` the cap mechanism is wired up (so future runtime changes via state are instant), whereas `null` skips the install entirely (the scroll view is byte-for-byte identical to RN's default).
66
+
67
+ ### Ref
68
+
69
+ The ref returned by `CappedScrollView` is the underlying RN `ScrollView` instance — so `scrollTo`, `scrollToEnd`, `getScrollableNode`, etc. all work as you'd expect.
70
+
71
+ ```tsx
72
+ const ref = useRef<CappedScrollViewRef>(null);
73
+ // ...
74
+ ref.current?.scrollTo({ y: 0, animated: true });
75
+ ```
76
+
77
+ ## How it works
78
+
79
+ The library never re-implements `ScrollView`. It wraps RN's own `<ScrollView>` (so every prop, every event, sticky headers, refresh control, etc. continue to work) and attaches a small native hook to the underlying platform scroll view to intercept fling velocity.
80
+
81
+ ### iOS
82
+
83
+ A TurboModule resolves the React tag (`getScrollableNode()`) to the underlying `RCTScrollViewComponentView` by walking the window hierarchy and matching on `UIView.tag`, which Fabric sets to the React tag at mount time. Resolution retries every ~33 ms for up to ~1 s, because in Fabric the scroll view's UIView may not exist at the moment `useEffect` runs.
84
+
85
+ Once found, the module attaches a `UIScrollViewDelegate` proxy to the scroll view's `scrollViewDelegateSplitter` — RN exposes this as the official extension point for adding delegates without replacing the primary one, so all of RN's own scroll event plumbing keeps working.
86
+
87
+ The proxy implements `scrollViewWillEndDragging:withVelocity:targetContentOffset:`. UIScrollView reports velocity in points-per-millisecond and accepts an `inout` target offset. The proxy:
88
+
89
+ 1. Computes `cap = fraction × 8000 pt/s` (converted to pt/ms to match UIScrollView's units).
90
+ 2. If the user's fling is already within `cap`, leaves the target untouched.
91
+ 3. Otherwise multiplies `(target − current)` by `cap / peak`, which produces a fling whose peak velocity is exactly `cap` and whose distance is linearly proportional to how much the velocity was clamped.
92
+
93
+ When `maxVelocity` becomes `null`, the proxy is removed via `[splitter removeDelegate:]` — the scroll view returns to its exact default state.
94
+
95
+ ### Android
96
+
97
+ A TurboModule resolves the React tag via `UIManagerHelper.getUIManagerForReactTag(...).resolveView(tag)` (with a Fabric UIManager fallback), retrying on the main thread for up to ~1 s for the same Fabric mount-timing reason.
98
+
99
+ Once the `ReactScrollView` is found, the module replaces its `OverScroller` (Android's deceleration physics object — `mScroller` on the framework `ScrollView` class, plus RN's cached reference on `ReactScrollView`) with a subclass that overrides both `fling(...)` signatures. Both fields are written via reflection because they're declared `private`.
100
+
101
+ The replacement scroller:
102
+
103
+ 1. Computes `cap = fraction × 8000 dp/s × displayMetrics.density` (matching the iOS reference of 8000 pt/s — same numeric value, same logical unit).
104
+ 2. If the requested fling already fits the cap, forwards the fling unmodified.
105
+ 3. Otherwise scales both `velocityX` and `velocityY` by `cap / peak` and calls `super.fling(...)`. The framework's deceleration physics then run from the clamped start velocity, producing a fling whose peak velocity is exactly `cap`. Distance is governed by `OverScroller`'s spline (≈ `velocity^1.7`), so fast flings end up coasting somewhat less than the equivalent iOS fling.
106
+
107
+ When `maxVelocity` becomes `null`, the module restores the original `OverScroller` it captured at install time, so the scroll view returns to its exact default state.
108
+
109
+ ### JS glue
110
+
111
+ `src/CappedScrollView.tsx` is a `forwardRef` wrapper around `<ScrollView>`. On `maxVelocity` change, it calls `innerRef.current?.getScrollableNode()` to get the native React tag and forwards `(tag, value)` to the TurboModule. `null` is encoded as the sentinel `-1`, which the native side interprets as "remove the cap from this scroll view."
112
+
113
+ ## Contributing
114
+
115
+ - [Development workflow](CONTRIBUTING.md#development-workflow)
116
+ - [Sending a pull request](CONTRIBUTING.md#sending-a-pull-request)
117
+ - [Code of conduct](CODE_OF_CONDUCT.md)
118
+
119
+ ## License
120
+
121
+ MIT
122
+
123
+ ---
124
+
125
+ Made with [create-react-native-library](https://github.com/callstack/react-native-builder-bob)
@@ -0,0 +1,67 @@
1
+ buildscript {
2
+ ext.CappedScrollview = [
3
+ kotlinVersion: "2.0.21",
4
+ minSdkVersion: 24,
5
+ compileSdkVersion: 36,
6
+ targetSdkVersion: 36
7
+ ]
8
+
9
+ ext.getExtOrDefault = { prop ->
10
+ if (rootProject.ext.has(prop)) {
11
+ return rootProject.ext.get(prop)
12
+ }
13
+
14
+ return CappedScrollview[prop]
15
+ }
16
+
17
+ repositories {
18
+ google()
19
+ mavenCentral()
20
+ }
21
+
22
+ dependencies {
23
+ classpath "com.android.tools.build:gradle:8.7.2"
24
+ // noinspection DifferentKotlinGradleVersion
25
+ classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:${getExtOrDefault('kotlinVersion')}"
26
+ }
27
+ }
28
+
29
+
30
+ apply plugin: "com.android.library"
31
+ apply plugin: "kotlin-android"
32
+
33
+ apply plugin: "com.facebook.react"
34
+
35
+ android {
36
+ namespace "com.cappedscrollview"
37
+
38
+ compileSdkVersion getExtOrDefault("compileSdkVersion")
39
+
40
+ defaultConfig {
41
+ minSdkVersion getExtOrDefault("minSdkVersion")
42
+ targetSdkVersion getExtOrDefault("targetSdkVersion")
43
+ }
44
+
45
+ buildFeatures {
46
+ buildConfig true
47
+ }
48
+
49
+ buildTypes {
50
+ release {
51
+ minifyEnabled false
52
+ }
53
+ }
54
+
55
+ lint {
56
+ disable "GradleCompatible"
57
+ }
58
+
59
+ compileOptions {
60
+ sourceCompatibility JavaVersion.VERSION_1_8
61
+ targetCompatibility JavaVersion.VERSION_1_8
62
+ }
63
+ }
64
+
65
+ dependencies {
66
+ implementation "com.facebook.react:react-android"
67
+ }
@@ -0,0 +1,2 @@
1
+ <manifest xmlns:android="http://schemas.android.com/apk/res/android">
2
+ </manifest>
@@ -0,0 +1,205 @@
1
+ package com.cappedscrollview
2
+
3
+ import android.os.Handler
4
+ import android.os.Looper
5
+ import android.widget.OverScroller
6
+ import android.widget.ScrollView
7
+ import com.facebook.react.bridge.ReactApplicationContext
8
+ import com.facebook.react.bridge.ReactContext
9
+ import com.facebook.react.module.annotations.ReactModule
10
+ import com.facebook.react.uimanager.UIManagerHelper
11
+ import com.facebook.react.uimanager.common.UIManagerType
12
+ import com.facebook.react.views.scroll.ReactScrollView
13
+ import java.lang.reflect.Field
14
+ import kotlin.math.abs
15
+
16
+ @ReactModule(name = CappedScrollViewModule.NAME)
17
+ class CappedScrollViewModule(reactContext: ReactApplicationContext) :
18
+ NativeCappedScrollViewSpec(reactContext) {
19
+
20
+ private val mainHandler = Handler(Looper.getMainLooper())
21
+
22
+ override fun getName(): String = NAME
23
+
24
+ override fun setMaxVelocity(reactTag: Double, maxVelocity: Double) {
25
+ val tag = reactTag.toInt()
26
+ attemptInstall(tag, maxVelocity, attemptsRemaining = MAX_RESOLVE_ATTEMPTS)
27
+ }
28
+
29
+ private fun attemptInstall(tag: Int, value: Double, attemptsRemaining: Int) {
30
+ mainHandler.post {
31
+ val view = resolveView(reactApplicationContext, tag)
32
+ if (view !is ReactScrollView) {
33
+ if (attemptsRemaining > 0) {
34
+ mainHandler.postDelayed({
35
+ attemptInstall(tag, value, attemptsRemaining - 1)
36
+ }, RETRY_DELAY_MS)
37
+ }
38
+ return@post
39
+ }
40
+ if (value < 0) {
41
+ uninstallCap(view)
42
+ } else {
43
+ installCap(view, value)
44
+ }
45
+ }
46
+ }
47
+
48
+ private fun uninstallCap(view: ReactScrollView) {
49
+ val baseField = scrollViewScrollerField() ?: return
50
+ val current = try {
51
+ baseField.get(view) as? OverScroller
52
+ } catch (_: Throwable) {
53
+ null
54
+ }
55
+ val original = (current as? CappedOverScroller)?.original ?: return
56
+ try {
57
+ baseField.set(view, original)
58
+ } catch (_: Throwable) {
59
+ return
60
+ }
61
+ try {
62
+ reactScrollViewScrollerField()?.set(view, original)
63
+ } catch (_: Throwable) {
64
+ // Best-effort.
65
+ }
66
+ }
67
+
68
+ private fun resolveView(context: ReactContext, tag: Int): android.view.View? {
69
+ val byTag = try {
70
+ UIManagerHelper.getUIManagerForReactTag(context, tag)?.resolveView(tag)
71
+ } catch (_: Throwable) {
72
+ null
73
+ }
74
+ if (byTag != null) return byTag
75
+ return try {
76
+ UIManagerHelper.getUIManager(context, UIManagerType.FABRIC)?.resolveView(tag)
77
+ } catch (_: Throwable) {
78
+ null
79
+ }
80
+ }
81
+
82
+ private fun installCap(view: ReactScrollView, fraction: Double) {
83
+ val baseField = scrollViewScrollerField() ?: return
84
+ val rnField = reactScrollViewScrollerField()
85
+ val current = try {
86
+ baseField.get(view) as? OverScroller
87
+ } catch (_: Throwable) {
88
+ null
89
+ }
90
+ if (current is CappedOverScroller) {
91
+ current.fraction = fraction
92
+ return
93
+ }
94
+ // The cap reference is 8000 dp/s — the same numeric value used on iOS,
95
+ // expressed in dp/pt so the two platforms produce the same scroll feel
96
+ // at the same `maxVelocity` value. Convert to physical pixels for the
97
+ // OverScroller (which accepts px/s).
98
+ val density = view.resources.displayMetrics.density
99
+ val referenceMaxPxPerSec = REFERENCE_MAX_DP_PER_SEC * density
100
+ val replacement = CappedOverScroller(
101
+ view.context,
102
+ fraction,
103
+ referenceMaxPxPerSec.toDouble(),
104
+ current,
105
+ )
106
+ try {
107
+ baseField.set(view, replacement)
108
+ } catch (_: Throwable) {
109
+ return
110
+ }
111
+ try {
112
+ rnField?.set(view, replacement)
113
+ } catch (_: Throwable) {
114
+ // Best-effort; RN's cached reference will fall back to base ScrollView behavior.
115
+ }
116
+ }
117
+
118
+ private fun scrollViewScrollerField(): Field? = scrollerFieldCache ?: run {
119
+ try {
120
+ val field = ScrollView::class.java.getDeclaredField("mScroller")
121
+ field.isAccessible = true
122
+ scrollerFieldCache = field
123
+ field
124
+ } catch (_: Throwable) {
125
+ null
126
+ }
127
+ }
128
+
129
+ private fun reactScrollViewScrollerField(): Field? = rnScrollerFieldCache ?: run {
130
+ try {
131
+ val field = ReactScrollView::class.java.getDeclaredField("mScroller")
132
+ field.isAccessible = true
133
+ rnScrollerFieldCache = field
134
+ field
135
+ } catch (_: NoSuchFieldException) {
136
+ null
137
+ }
138
+ }
139
+
140
+ companion object {
141
+ const val NAME = "CappedScrollView"
142
+ private const val RETRY_DELAY_MS = 33L
143
+ private const val MAX_RESOLVE_ATTEMPTS = 30
144
+ // Public 0..1 cap scale is anchored to 8000 dp/s (matches iOS's 8000 pt/s).
145
+ private const val REFERENCE_MAX_DP_PER_SEC = 8000f
146
+ @Volatile private var scrollerFieldCache: Field? = null
147
+ @Volatile private var rnScrollerFieldCache: Field? = null
148
+ }
149
+ }
150
+
151
+ /**
152
+ * OverScroller subclass that caps peak fling velocity at
153
+ * `referenceMax * fraction` (where `referenceMax` is 8000 dp/s converted to
154
+ * physical pixels, matching iOS's 8000 pt/s reference). The base RN/Android
155
+ * deceleration physics are otherwise left alone, so the resulting fling
156
+ * decelerates naturally from the clamped peak.
157
+ */
158
+ private class CappedOverScroller(
159
+ context: android.content.Context,
160
+ @Volatile var fraction: Double,
161
+ private val referenceMaxPxPerSec: Double,
162
+ val original: OverScroller?,
163
+ ) : OverScroller(context) {
164
+
165
+ override fun fling(
166
+ startX: Int,
167
+ startY: Int,
168
+ velocityX: Int,
169
+ velocityY: Int,
170
+ minX: Int,
171
+ maxX: Int,
172
+ minY: Int,
173
+ maxY: Int,
174
+ overX: Int,
175
+ overY: Int,
176
+ ) {
177
+ val (cx, cy) = applyCap(velocityX, velocityY)
178
+ super.fling(startX, startY, cx, cy, minX, maxX, minY, maxY, overX, overY)
179
+ }
180
+
181
+ override fun fling(
182
+ startX: Int,
183
+ startY: Int,
184
+ velocityX: Int,
185
+ velocityY: Int,
186
+ minX: Int,
187
+ maxX: Int,
188
+ minY: Int,
189
+ maxY: Int,
190
+ ) {
191
+ val (cx, cy) = applyCap(velocityX, velocityY)
192
+ super.fling(startX, startY, cx, cy, minX, maxX, minY, maxY)
193
+ }
194
+
195
+ private fun applyCap(velocityX: Int, velocityY: Int): Pair<Int, Int> {
196
+ val f = fraction.coerceIn(0.0, 1.0)
197
+ if (f >= 1.0) return velocityX to velocityY
198
+ if (f <= 0.0) return 0 to 0
199
+ val cap = referenceMaxPxPerSec * f
200
+ val peak = maxOf(abs(velocityX), abs(velocityY)).toDouble()
201
+ if (peak <= cap) return velocityX to velocityY
202
+ val scale = cap / peak
203
+ return (velocityX * scale).toInt() to (velocityY * scale).toInt()
204
+ }
205
+ }
@@ -0,0 +1,25 @@
1
+ package com.cappedscrollview
2
+
3
+ import com.facebook.react.BaseReactPackage
4
+ import com.facebook.react.bridge.NativeModule
5
+ import com.facebook.react.bridge.ReactApplicationContext
6
+ import com.facebook.react.module.model.ReactModuleInfo
7
+ import com.facebook.react.module.model.ReactModuleInfoProvider
8
+
9
+ class CappedScrollViewPackage : BaseReactPackage() {
10
+ override fun getModule(name: String, reactContext: ReactApplicationContext): NativeModule? =
11
+ if (name == CappedScrollViewModule.NAME) CappedScrollViewModule(reactContext) else null
12
+
13
+ override fun getReactModuleInfoProvider() = ReactModuleInfoProvider {
14
+ mapOf(
15
+ CappedScrollViewModule.NAME to ReactModuleInfo(
16
+ CappedScrollViewModule.NAME,
17
+ CappedScrollViewModule::class.java.name,
18
+ false,
19
+ false,
20
+ false,
21
+ true,
22
+ )
23
+ )
24
+ }
25
+ }
@@ -0,0 +1,9 @@
1
+ #import <React/RCTBridgeModule.h>
2
+ #import <CappedScrollViewSpec/CappedScrollViewSpec.h>
3
+
4
+ NS_ASSUME_NONNULL_BEGIN
5
+
6
+ @interface CappedScrollView : NSObject <NativeCappedScrollViewSpec>
7
+ @end
8
+
9
+ NS_ASSUME_NONNULL_END
@@ -0,0 +1,160 @@
1
+ #import "CappedScrollView.h"
2
+
3
+ #import <React/RCTBridge.h>
4
+ #import <React/RCTBridgeModule.h>
5
+ #import <React/RCTScrollViewComponentView.h>
6
+ #import <React/RCTUtils.h>
7
+
8
+ @interface CappedScrollViewVelocityCapper : NSObject <UIScrollViewDelegate>
9
+ @property (nonatomic, assign) CGFloat maxVelocity;
10
+ @end
11
+
12
+ static RCTScrollViewComponentView *_Nullable CappedScrollViewSearch(UIView *root, NSInteger tag)
13
+ {
14
+ if (root.tag == tag && [root isKindOfClass:[RCTScrollViewComponentView class]]) {
15
+ return (RCTScrollViewComponentView *)root;
16
+ }
17
+ for (UIView *child in root.subviews) {
18
+ RCTScrollViewComponentView *match = CappedScrollViewSearch(child, tag);
19
+ if (match) {
20
+ return match;
21
+ }
22
+ }
23
+ return nil;
24
+ }
25
+
26
+ static RCTScrollViewComponentView *_Nullable CappedScrollViewFindScrollView(NSInteger tag)
27
+ {
28
+ for (UIScene *scene in UIApplication.sharedApplication.connectedScenes) {
29
+ if (![scene isKindOfClass:[UIWindowScene class]]) continue;
30
+ for (UIWindow *window in ((UIWindowScene *)scene).windows) {
31
+ RCTScrollViewComponentView *match = CappedScrollViewSearch(window, tag);
32
+ if (match) {
33
+ return match;
34
+ }
35
+ }
36
+ }
37
+ return nil;
38
+ }
39
+
40
+ // Cross-platform reference for the public 0..1 cap scale: 8000 pt/s on iOS,
41
+ // 8000 dp/s on Android. At maxVelocity=1 the cap exactly matches this value;
42
+ // at 0.5 it is 4000 pt/s, etc.
43
+ static const CGFloat kCappedScrollViewReferenceMaxPtsPerSec = 8000.0;
44
+
45
+ @implementation CappedScrollViewVelocityCapper
46
+
47
+ - (void)scrollViewWillEndDragging:(UIScrollView *)scrollView
48
+ withVelocity:(CGPoint)velocity
49
+ targetContentOffset:(inout CGPoint *)targetContentOffset
50
+ {
51
+ CGFloat fraction = MAX(0.0, MIN(1.0, self.maxVelocity));
52
+ if (fraction >= 1.0) {
53
+ return;
54
+ }
55
+ if (fraction <= 0.0) {
56
+ *targetContentOffset = scrollView.contentOffset;
57
+ return;
58
+ }
59
+
60
+ // UIScrollView reports velocity in points-per-millisecond.
61
+ CGFloat capPerMs = (kCappedScrollViewReferenceMaxPtsPerSec * fraction) / 1000.0;
62
+ CGFloat peak = MAX(fabs(velocity.x), fabs(velocity.y));
63
+ if (peak <= capPerMs) {
64
+ // Natural fling already within the cap — leave it untouched.
65
+ return;
66
+ }
67
+
68
+ // Speed-limit model: scale the predicted target offset toward current by
69
+ // (cap / peak). Linear scaling here means the fling distance is exactly
70
+ // proportional to how much we've clamped peak velocity, matching the
71
+ // Android behaviour (which clamps `velocityY` to `cap` directly).
72
+ CGFloat scale = capPerMs / peak;
73
+ CGPoint current = scrollView.contentOffset;
74
+ CGPoint target = *targetContentOffset;
75
+ target.x = current.x + (target.x - current.x) * scale;
76
+ target.y = current.y + (target.y - current.y) * scale;
77
+ *targetContentOffset = target;
78
+ }
79
+
80
+ @end
81
+
82
+ @implementation CappedScrollView {
83
+ NSMapTable<NSNumber *, CappedScrollViewVelocityCapper *> *_cappersByTag;
84
+ }
85
+
86
+ RCT_EXPORT_MODULE()
87
+
88
+ @synthesize bridge = _bridge;
89
+ @synthesize viewRegistry_DEPRECATED = _viewRegistry_DEPRECATED;
90
+
91
+ - (instancetype)init
92
+ {
93
+ if (self = [super init]) {
94
+ _cappersByTag = [NSMapTable strongToStrongObjectsMapTable];
95
+ }
96
+ return self;
97
+ }
98
+
99
+ - (void)setMaxVelocity:(double)reactTag maxVelocity:(double)maxVelocity
100
+ {
101
+ NSInteger tag = (NSInteger)reactTag;
102
+ CGFloat cap = (CGFloat)maxVelocity;
103
+ __weak __typeof(self) weakSelf = self;
104
+ [self attemptInstallForTag:tag cap:cap attemptsRemaining:30 weakSelf:weakSelf];
105
+ }
106
+
107
+ - (void)attemptInstallForTag:(NSInteger)tag
108
+ cap:(CGFloat)cap
109
+ attemptsRemaining:(NSInteger)attemptsRemaining
110
+ weakSelf:(__weak CappedScrollView *)weakSelf
111
+ {
112
+ RCTExecuteOnMainQueue(^{
113
+ __typeof(self) strongSelf = weakSelf;
114
+ if (!strongSelf) {
115
+ return;
116
+ }
117
+
118
+ RCTScrollViewComponentView *scrollComponent = CappedScrollViewFindScrollView(tag);
119
+ if (!scrollComponent) {
120
+ if (attemptsRemaining > 0) {
121
+ // Fabric mounting can lag the JS-side ref attachment; retry up to ~1s.
122
+ dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(33 * NSEC_PER_MSEC)),
123
+ dispatch_get_main_queue(), ^{
124
+ [strongSelf attemptInstallForTag:tag
125
+ cap:cap
126
+ attemptsRemaining:attemptsRemaining - 1
127
+ weakSelf:weakSelf];
128
+ });
129
+ }
130
+ return;
131
+ }
132
+
133
+ NSNumber *key = @(tag);
134
+ CappedScrollViewVelocityCapper *capper = [strongSelf->_cappersByTag objectForKey:key];
135
+
136
+ if (cap < 0) {
137
+ // Sentinel: caller wants no capper at all.
138
+ if (capper) {
139
+ [scrollComponent.scrollViewDelegateSplitter removeDelegate:capper];
140
+ [strongSelf->_cappersByTag removeObjectForKey:key];
141
+ }
142
+ return;
143
+ }
144
+
145
+ if (!capper) {
146
+ capper = [CappedScrollViewVelocityCapper new];
147
+ [strongSelf->_cappersByTag setObject:capper forKey:key];
148
+ [scrollComponent.scrollViewDelegateSplitter addDelegate:capper];
149
+ }
150
+ capper.maxVelocity = cap;
151
+ });
152
+ }
153
+
154
+ - (std::shared_ptr<facebook::react::TurboModule>)getTurboModule:
155
+ (const facebook::react::ObjCTurboModule::InitParams &)params
156
+ {
157
+ return std::make_shared<facebook::react::NativeCappedScrollViewSpecJSI>(params);
158
+ }
159
+
160
+ @end
@@ -0,0 +1,30 @@
1
+ "use strict";
2
+
3
+ import { forwardRef, useEffect, useRef, useImperativeHandle } from 'react';
4
+ import { ScrollView } from 'react-native';
5
+ import NativeCappedScrollView from "./NativeCappedScrollView.js";
6
+ import { jsx as _jsx } from "react/jsx-runtime";
7
+ // Sentinel sent to native when consumers pass `null`/omit the prop, meaning
8
+ // "no capper installed at all." Negative values are unreachable from the
9
+ // public [0, 1] range, so this is unambiguous on the native side.
10
+ const NO_CAP_SENTINEL = -1;
11
+ export const CappedScrollView = /*#__PURE__*/forwardRef(function CappedScrollView({
12
+ maxVelocity,
13
+ ...rest
14
+ }, ref) {
15
+ const innerRef = useRef(null);
16
+ useImperativeHandle(ref, () => innerRef.current);
17
+ useEffect(() => {
18
+ const handle = innerRef.current?.getScrollableNode?.();
19
+ if (handle == null) {
20
+ return;
21
+ }
22
+ const value = maxVelocity == null ? NO_CAP_SENTINEL : maxVelocity;
23
+ NativeCappedScrollView.setMaxVelocity(handle, value);
24
+ }, [maxVelocity]);
25
+ return /*#__PURE__*/_jsx(ScrollView, {
26
+ ref: innerRef,
27
+ ...rest
28
+ });
29
+ });
30
+ //# sourceMappingURL=CappedScrollView.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"names":["forwardRef","useEffect","useRef","useImperativeHandle","ScrollView","NativeCappedScrollView","jsx","_jsx","NO_CAP_SENTINEL","CappedScrollView","maxVelocity","rest","ref","innerRef","current","handle","getScrollableNode","value","setMaxVelocity"],"sourceRoot":"../../src","sources":["CappedScrollView.tsx"],"mappings":";;AAAA,SACEA,UAAU,EACVC,SAAS,EACTC,MAAM,EACNC,mBAAmB,QAGd,OAAO;AACd,SAASC,UAAU,QAA8B,cAAc;AAC/D,OAAOC,sBAAsB,MAAM,6BAA0B;AAAC,SAAAC,GAAA,IAAAC,IAAA;AAwB9D;AACA;AACA;AACA,MAAMC,eAAe,GAAG,CAAC,CAAC;AAE1B,OAAO,MAAMC,gBAAgB,gBAAGT,UAAU,CAAC,SAASS,gBAAgBA,CAClE;EAAEC,WAAW;EAAE,GAAGC;AAA4B,CAAC,EAC/CC,GAA6B,EAC7B;EACA,MAAMC,QAAQ,GAAGX,MAAM,CAA6B,IAAI,CAAC;EAEzDC,mBAAmB,CAACS,GAAG,EAAE,MAAMC,QAAQ,CAACC,OAA8B,CAAC;EAEvEb,SAAS,CAAC,MAAM;IACd,MAAMc,MAAM,GAAGF,QAAQ,CAACC,OAAO,EAAEE,iBAAiB,GAAG,CAAC;IACtD,IAAID,MAAM,IAAI,IAAI,EAAE;MAClB;IACF;IACA,MAAME,KAAK,GAAGP,WAAW,IAAI,IAAI,GAAGF,eAAe,GAAGE,WAAW;IACjEL,sBAAsB,CAACa,cAAc,CAACH,MAAM,EAAEE,KAAK,CAAC;EACtD,CAAC,EAAE,CAACP,WAAW,CAAC,CAAC;EAEjB,oBAAOH,IAAA,CAACH,UAAU;IAACQ,GAAG,EAAEC,QAAS;IAAA,GAAKF;EAAI,CAAG,CAAC;AAChD,CAAC,CAAC","ignoreList":[]}
@@ -0,0 +1,5 @@
1
+ "use strict";
2
+
3
+ import { TurboModuleRegistry } from 'react-native';
4
+ export default TurboModuleRegistry.getEnforcing('CappedScrollView');
5
+ //# sourceMappingURL=NativeCappedScrollView.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"names":["TurboModuleRegistry","getEnforcing"],"sourceRoot":"../../src","sources":["NativeCappedScrollView.ts"],"mappings":";;AACA,SAASA,mBAAmB,QAAQ,cAAc;AAMlD,eAAeA,mBAAmB,CAACC,YAAY,CAAO,kBAAkB,CAAC","ignoreList":[]}
@@ -0,0 +1,4 @@
1
+ "use strict";
2
+
3
+ export { CappedScrollView } from "./CappedScrollView.js";
4
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"names":["CappedScrollView"],"sourceRoot":"../../src","sources":["index.tsx"],"mappings":";;AAAA,SAASA,gBAAgB,QAAQ,uBAAoB","ignoreList":[]}
@@ -0,0 +1 @@
1
+ {"type":"module"}
@@ -0,0 +1 @@
1
+ {"type":"module"}
@@ -0,0 +1,248 @@
1
+ import { type ComponentRef } from 'react';
2
+ import { ScrollView, type ScrollViewProps } from 'react-native';
3
+ export type CappedScrollViewRef = ComponentRef<typeof ScrollView>;
4
+ export type CappedScrollViewProps = ScrollViewProps & {
5
+ /**
6
+ * Fling velocity cap as a normalized fraction in [0, 1], or `null` to
7
+ * disable the cap mechanism entirely.
8
+ *
9
+ * The reference maximum is 8000 dp/s on Android and 8000 pt/s on iOS
10
+ * (same logical unit), so the same `maxVelocity` value produces the same
11
+ * visual scroll speed on both platforms.
12
+ *
13
+ * - `null` / `undefined` (default) — no capper installed; behaves exactly
14
+ * like a plain RN `ScrollView`.
15
+ * - `1` — capper installed at the reference maximum. Human-paced flings
16
+ * pass through untouched.
17
+ * - `0` — list cannot fling at all.
18
+ * - `0 < x < 1` — peak fling velocity is clamped to `x × 8000` (dp/s on
19
+ * Android, pt/s on iOS). Fling distance scales linearly with `x`.
20
+ */
21
+ maxVelocity?: number | null;
22
+ };
23
+ export declare const CappedScrollView: import("react").ForwardRefExoticComponent<Readonly<Omit<Omit<Readonly<Omit<Readonly<{
24
+ onAccessibilityAction?: ((event: import("react-native").AccessibilityActionEvent) => unknown) | undefined;
25
+ onAccessibilityTap?: (() => unknown) | undefined;
26
+ onLayout?: ((event: import("react-native").LayoutChangeEvent) => unknown) | undefined;
27
+ onMagicTap?: (() => unknown) | undefined;
28
+ onAccessibilityEscape?: (() => unknown) | undefined;
29
+ }>, "onMoveShouldSetResponder" | "onMoveShouldSetResponderCapture" | "onResponderGrant" | "onResponderMove" | "onResponderReject" | "onResponderRelease" | "onResponderStart" | "onResponderEnd" | "onResponderTerminate" | "onResponderTerminationRequest" | "onStartShouldSetResponder" | "onStartShouldSetResponderCapture" | "onMouseEnter" | "onMouseLeave" | "onClick" | "onClickCapture" | "onPointerEnter" | "onPointerEnterCapture" | "onPointerLeave" | "onPointerLeaveCapture" | "onPointerMove" | "onPointerMoveCapture" | "onPointerCancel" | "onPointerCancelCapture" | "onPointerDown" | "onPointerDownCapture" | "onPointerUp" | "onPointerUpCapture" | "onPointerOver" | "onPointerOverCapture" | "onPointerOut" | "onPointerOutCapture" | "onGotPointerCapture" | "onGotPointerCaptureCapture" | "onLostPointerCapture" | "onLostPointerCaptureCapture" | "onBlur" | "onBlurCapture" | "onFocus" | "onFocusCapture" | "onTouchCancel" | "onTouchCancelCapture" | "onTouchEnd" | "onTouchEndCapture" | "onTouchMove" | "onTouchMoveCapture" | "onTouchStart" | "onTouchStartCapture" | "nativeBackgroundAndroid" | "nativeForegroundAndroid" | "renderToHardwareTextureAndroid" | "hasTVPreferredFocus" | "nextFocusDown" | "nextFocusForward" | "nextFocusLeft" | "nextFocusRight" | "nextFocusUp" | "focusable" | "tabIndex" | "shouldRasterizeIOS" | "accessibilityIgnoresInvertColors" | "accessibilityViewIsModal" | "accessibilityShowsLargeContentViewer" | "accessibilityLargeContentTitle" | "aria-modal" | "accessibilityElementsHidden" | "accessibilityLanguage" | "accessibilityRespondsToUserInteraction" | "accessible" | "accessibilityLabel" | "accessibilityHint" | "aria-label" | "accessibilityRole" | "role" | "accessibilityState" | "accessibilityValue" | "aria-valuemax" | "aria-valuemin" | "aria-valuenow" | "aria-valuetext" | "accessibilityActions" | "aria-busy" | "aria-checked" | "aria-disabled" | "aria-expanded" | "aria-selected" | "aria-hidden" | "accessibilityLabelledBy" | "aria-labelledby" | "accessibilityLiveRegion" | "aria-live" | "importantForAccessibility" | "screenReaderFocusable" | "children" | "style" | "collapsable" | "collapsableChildren" | "id" | "testID" | "nativeID" | "needsOffscreenAlphaCompositing" | "hitSlop" | "pointerEvents" | "removeClippedSubviews" | "experimental_accessibilityOrder"> & Omit<Readonly<{
30
+ onMoveShouldSetResponder?: ((e: import("react-native").GestureResponderEvent) => boolean) | undefined;
31
+ onMoveShouldSetResponderCapture?: ((e: import("react-native").GestureResponderEvent) => boolean) | undefined;
32
+ onResponderGrant?: ((e: import("react-native").GestureResponderEvent) => void | boolean) | undefined;
33
+ onResponderMove?: ((e: import("react-native").GestureResponderEvent) => void) | undefined;
34
+ onResponderReject?: ((e: import("react-native").GestureResponderEvent) => void) | undefined;
35
+ onResponderRelease?: ((e: import("react-native").GestureResponderEvent) => void) | undefined;
36
+ onResponderStart?: ((e: import("react-native").GestureResponderEvent) => void) | undefined;
37
+ onResponderEnd?: ((e: import("react-native").GestureResponderEvent) => void) | undefined;
38
+ onResponderTerminate?: ((e: import("react-native").GestureResponderEvent) => void) | undefined;
39
+ onResponderTerminationRequest?: ((e: import("react-native").GestureResponderEvent) => boolean) | undefined;
40
+ onStartShouldSetResponder?: ((e: import("react-native").GestureResponderEvent) => boolean) | undefined;
41
+ onStartShouldSetResponderCapture?: ((e: import("react-native").GestureResponderEvent) => boolean) | undefined;
42
+ }>, "onMouseEnter" | "onMouseLeave" | "onClick" | "onClickCapture" | "onPointerEnter" | "onPointerEnterCapture" | "onPointerLeave" | "onPointerLeaveCapture" | "onPointerMove" | "onPointerMoveCapture" | "onPointerCancel" | "onPointerCancelCapture" | "onPointerDown" | "onPointerDownCapture" | "onPointerUp" | "onPointerUpCapture" | "onPointerOver" | "onPointerOverCapture" | "onPointerOut" | "onPointerOutCapture" | "onGotPointerCapture" | "onGotPointerCaptureCapture" | "onLostPointerCapture" | "onLostPointerCaptureCapture" | "onBlur" | "onBlurCapture" | "onFocus" | "onFocusCapture" | "onTouchCancel" | "onTouchCancelCapture" | "onTouchEnd" | "onTouchEndCapture" | "onTouchMove" | "onTouchMoveCapture" | "onTouchStart" | "onTouchStartCapture" | "nativeBackgroundAndroid" | "nativeForegroundAndroid" | "renderToHardwareTextureAndroid" | "hasTVPreferredFocus" | "nextFocusDown" | "nextFocusForward" | "nextFocusLeft" | "nextFocusRight" | "nextFocusUp" | "focusable" | "tabIndex" | "shouldRasterizeIOS" | "accessibilityIgnoresInvertColors" | "accessibilityViewIsModal" | "accessibilityShowsLargeContentViewer" | "accessibilityLargeContentTitle" | "aria-modal" | "accessibilityElementsHidden" | "accessibilityLanguage" | "accessibilityRespondsToUserInteraction" | "accessible" | "accessibilityLabel" | "accessibilityHint" | "aria-label" | "accessibilityRole" | "role" | "accessibilityState" | "accessibilityValue" | "aria-valuemax" | "aria-valuemin" | "aria-valuenow" | "aria-valuetext" | "accessibilityActions" | "aria-busy" | "aria-checked" | "aria-disabled" | "aria-expanded" | "aria-selected" | "aria-hidden" | "accessibilityLabelledBy" | "aria-labelledby" | "accessibilityLiveRegion" | "aria-live" | "importantForAccessibility" | "screenReaderFocusable" | "children" | "style" | "collapsable" | "collapsableChildren" | "id" | "testID" | "nativeID" | "needsOffscreenAlphaCompositing" | "hitSlop" | "pointerEvents" | "removeClippedSubviews" | "experimental_accessibilityOrder"> & Omit<Readonly<{
43
+ onMouseEnter?: ((event: import("react-native").MouseEvent) => void) | undefined;
44
+ onMouseLeave?: ((event: import("react-native").MouseEvent) => void) | undefined;
45
+ }>, "onClick" | "onClickCapture" | "onPointerEnter" | "onPointerEnterCapture" | "onPointerLeave" | "onPointerLeaveCapture" | "onPointerMove" | "onPointerMoveCapture" | "onPointerCancel" | "onPointerCancelCapture" | "onPointerDown" | "onPointerDownCapture" | "onPointerUp" | "onPointerUpCapture" | "onPointerOver" | "onPointerOverCapture" | "onPointerOut" | "onPointerOutCapture" | "onGotPointerCapture" | "onGotPointerCaptureCapture" | "onLostPointerCapture" | "onLostPointerCaptureCapture" | "onBlur" | "onBlurCapture" | "onFocus" | "onFocusCapture" | "onTouchCancel" | "onTouchCancelCapture" | "onTouchEnd" | "onTouchEndCapture" | "onTouchMove" | "onTouchMoveCapture" | "onTouchStart" | "onTouchStartCapture" | "nativeBackgroundAndroid" | "nativeForegroundAndroid" | "renderToHardwareTextureAndroid" | "hasTVPreferredFocus" | "nextFocusDown" | "nextFocusForward" | "nextFocusLeft" | "nextFocusRight" | "nextFocusUp" | "focusable" | "tabIndex" | "shouldRasterizeIOS" | "accessibilityIgnoresInvertColors" | "accessibilityViewIsModal" | "accessibilityShowsLargeContentViewer" | "accessibilityLargeContentTitle" | "aria-modal" | "accessibilityElementsHidden" | "accessibilityLanguage" | "accessibilityRespondsToUserInteraction" | "accessible" | "accessibilityLabel" | "accessibilityHint" | "aria-label" | "accessibilityRole" | "role" | "accessibilityState" | "accessibilityValue" | "aria-valuemax" | "aria-valuemin" | "aria-valuenow" | "aria-valuetext" | "accessibilityActions" | "aria-busy" | "aria-checked" | "aria-disabled" | "aria-expanded" | "aria-selected" | "aria-hidden" | "accessibilityLabelledBy" | "aria-labelledby" | "accessibilityLiveRegion" | "aria-live" | "importantForAccessibility" | "screenReaderFocusable" | "children" | "style" | "collapsable" | "collapsableChildren" | "id" | "testID" | "nativeID" | "needsOffscreenAlphaCompositing" | "hitSlop" | "pointerEvents" | "removeClippedSubviews" | "experimental_accessibilityOrder"> & Omit<Readonly<{
46
+ onClick?: ((event: import("react-native").PointerEvent) => void) | undefined;
47
+ onClickCapture?: ((event: import("react-native").PointerEvent) => void) | undefined;
48
+ onPointerEnter?: ((event: import("react-native").PointerEvent) => void) | undefined;
49
+ onPointerEnterCapture?: ((event: import("react-native").PointerEvent) => void) | undefined;
50
+ onPointerLeave?: ((event: import("react-native").PointerEvent) => void) | undefined;
51
+ onPointerLeaveCapture?: ((event: import("react-native").PointerEvent) => void) | undefined;
52
+ onPointerMove?: ((event: import("react-native").PointerEvent) => void) | undefined;
53
+ onPointerMoveCapture?: ((event: import("react-native").PointerEvent) => void) | undefined;
54
+ onPointerCancel?: ((e: import("react-native").PointerEvent) => void) | undefined;
55
+ onPointerCancelCapture?: ((e: import("react-native").PointerEvent) => void) | undefined;
56
+ onPointerDown?: ((e: import("react-native").PointerEvent) => void) | undefined;
57
+ onPointerDownCapture?: ((e: import("react-native").PointerEvent) => void) | undefined;
58
+ onPointerUp?: ((e: import("react-native").PointerEvent) => void) | undefined;
59
+ onPointerUpCapture?: ((e: import("react-native").PointerEvent) => void) | undefined;
60
+ onPointerOver?: ((e: import("react-native").PointerEvent) => void) | undefined;
61
+ onPointerOverCapture?: ((e: import("react-native").PointerEvent) => void) | undefined;
62
+ onPointerOut?: ((e: import("react-native").PointerEvent) => void) | undefined;
63
+ onPointerOutCapture?: ((e: import("react-native").PointerEvent) => void) | undefined;
64
+ onGotPointerCapture?: ((e: import("react-native").PointerEvent) => void) | undefined;
65
+ onGotPointerCaptureCapture?: ((e: import("react-native").PointerEvent) => void) | undefined;
66
+ onLostPointerCapture?: ((e: import("react-native").PointerEvent) => void) | undefined;
67
+ onLostPointerCaptureCapture?: ((e: import("react-native").PointerEvent) => void) | undefined;
68
+ }>, "onClick" | "onBlur" | "onBlurCapture" | "onFocus" | "onFocusCapture" | "onTouchCancel" | "onTouchCancelCapture" | "onTouchEnd" | "onTouchEndCapture" | "onTouchMove" | "onTouchMoveCapture" | "onTouchStart" | "onTouchStartCapture" | "nativeBackgroundAndroid" | "nativeForegroundAndroid" | "renderToHardwareTextureAndroid" | "hasTVPreferredFocus" | "nextFocusDown" | "nextFocusForward" | "nextFocusLeft" | "nextFocusRight" | "nextFocusUp" | "focusable" | "tabIndex" | "shouldRasterizeIOS" | "accessibilityIgnoresInvertColors" | "accessibilityViewIsModal" | "accessibilityShowsLargeContentViewer" | "accessibilityLargeContentTitle" | "aria-modal" | "accessibilityElementsHidden" | "accessibilityLanguage" | "accessibilityRespondsToUserInteraction" | "accessible" | "accessibilityLabel" | "accessibilityHint" | "aria-label" | "accessibilityRole" | "role" | "accessibilityState" | "accessibilityValue" | "aria-valuemax" | "aria-valuemin" | "aria-valuenow" | "aria-valuetext" | "accessibilityActions" | "aria-busy" | "aria-checked" | "aria-disabled" | "aria-expanded" | "aria-selected" | "aria-hidden" | "accessibilityLabelledBy" | "aria-labelledby" | "accessibilityLiveRegion" | "aria-live" | "importantForAccessibility" | "screenReaderFocusable" | "children" | "style" | "collapsable" | "collapsableChildren" | "id" | "testID" | "nativeID" | "needsOffscreenAlphaCompositing" | "hitSlop" | "pointerEvents" | "removeClippedSubviews" | "experimental_accessibilityOrder"> & Omit<Readonly<{
69
+ onBlur?: ((event: import("react-native").BlurEvent) => void) | undefined;
70
+ onBlurCapture?: ((event: import("react-native").BlurEvent) => void) | undefined;
71
+ onFocus?: ((event: import("react-native").FocusEvent) => void) | undefined;
72
+ onFocusCapture?: ((event: import("react-native").FocusEvent) => void) | undefined;
73
+ }>, "onClick" | "onTouchCancel" | "onTouchCancelCapture" | "onTouchEnd" | "onTouchEndCapture" | "onTouchMove" | "onTouchMoveCapture" | "onTouchStart" | "onTouchStartCapture" | "nativeBackgroundAndroid" | "nativeForegroundAndroid" | "renderToHardwareTextureAndroid" | "hasTVPreferredFocus" | "nextFocusDown" | "nextFocusForward" | "nextFocusLeft" | "nextFocusRight" | "nextFocusUp" | "focusable" | "tabIndex" | "shouldRasterizeIOS" | "accessibilityIgnoresInvertColors" | "accessibilityViewIsModal" | "accessibilityShowsLargeContentViewer" | "accessibilityLargeContentTitle" | "aria-modal" | "accessibilityElementsHidden" | "accessibilityLanguage" | "accessibilityRespondsToUserInteraction" | "accessible" | "accessibilityLabel" | "accessibilityHint" | "aria-label" | "accessibilityRole" | "role" | "accessibilityState" | "accessibilityValue" | "aria-valuemax" | "aria-valuemin" | "aria-valuenow" | "aria-valuetext" | "accessibilityActions" | "aria-busy" | "aria-checked" | "aria-disabled" | "aria-expanded" | "aria-selected" | "aria-hidden" | "accessibilityLabelledBy" | "aria-labelledby" | "accessibilityLiveRegion" | "aria-live" | "importantForAccessibility" | "screenReaderFocusable" | "children" | "style" | "collapsable" | "collapsableChildren" | "id" | "testID" | "nativeID" | "needsOffscreenAlphaCompositing" | "hitSlop" | "pointerEvents" | "removeClippedSubviews" | "experimental_accessibilityOrder"> & Omit<Readonly<{
74
+ onTouchCancel?: ((e: import("react-native").GestureResponderEvent) => void) | undefined;
75
+ onTouchCancelCapture?: ((e: import("react-native").GestureResponderEvent) => void) | undefined;
76
+ onTouchEnd?: ((e: import("react-native").GestureResponderEvent) => void) | undefined;
77
+ onTouchEndCapture?: ((e: import("react-native").GestureResponderEvent) => void) | undefined;
78
+ onTouchMove?: ((e: import("react-native").GestureResponderEvent) => void) | undefined;
79
+ onTouchMoveCapture?: ((e: import("react-native").GestureResponderEvent) => void) | undefined;
80
+ onTouchStart?: ((e: import("react-native").GestureResponderEvent) => void) | undefined;
81
+ onTouchStartCapture?: ((e: import("react-native").GestureResponderEvent) => void) | undefined;
82
+ }>, "onClick" | "nativeBackgroundAndroid" | "nativeForegroundAndroid" | "renderToHardwareTextureAndroid" | "hasTVPreferredFocus" | "nextFocusDown" | "nextFocusForward" | "nextFocusLeft" | "nextFocusRight" | "nextFocusUp" | "focusable" | "tabIndex" | "shouldRasterizeIOS" | "accessibilityIgnoresInvertColors" | "accessibilityViewIsModal" | "accessibilityShowsLargeContentViewer" | "accessibilityLargeContentTitle" | "aria-modal" | "accessibilityElementsHidden" | "accessibilityLanguage" | "accessibilityRespondsToUserInteraction" | "accessible" | "accessibilityLabel" | "accessibilityHint" | "aria-label" | "accessibilityRole" | "role" | "accessibilityState" | "accessibilityValue" | "aria-valuemax" | "aria-valuemin" | "aria-valuenow" | "aria-valuetext" | "accessibilityActions" | "aria-busy" | "aria-checked" | "aria-disabled" | "aria-expanded" | "aria-selected" | "aria-hidden" | "accessibilityLabelledBy" | "aria-labelledby" | "accessibilityLiveRegion" | "aria-live" | "importantForAccessibility" | "screenReaderFocusable" | "children" | "style" | "collapsable" | "collapsableChildren" | "id" | "testID" | "nativeID" | "needsOffscreenAlphaCompositing" | "hitSlop" | "pointerEvents" | "removeClippedSubviews" | "experimental_accessibilityOrder"> & Omit<Readonly<{
83
+ nativeBackgroundAndroid?: import("react-native/types_generated/Libraries/Components/View/ViewPropTypes").AndroidDrawable | undefined;
84
+ nativeForegroundAndroid?: import("react-native/types_generated/Libraries/Components/View/ViewPropTypes").AndroidDrawable | undefined;
85
+ renderToHardwareTextureAndroid?: boolean | undefined;
86
+ hasTVPreferredFocus?: boolean | undefined;
87
+ nextFocusDown?: number | undefined;
88
+ nextFocusForward?: number | undefined;
89
+ nextFocusLeft?: number | undefined;
90
+ nextFocusRight?: number | undefined;
91
+ nextFocusUp?: number | undefined;
92
+ focusable?: boolean | undefined;
93
+ tabIndex?: 0 | -1;
94
+ onClick?: ((event: import("react-native").GestureResponderEvent) => unknown) | undefined;
95
+ }>, "shouldRasterizeIOS" | "accessibilityIgnoresInvertColors" | "accessibilityViewIsModal" | "accessibilityShowsLargeContentViewer" | "accessibilityLargeContentTitle" | "aria-modal" | "accessibilityElementsHidden" | "accessibilityLanguage" | "accessibilityRespondsToUserInteraction" | "accessible" | "accessibilityLabel" | "accessibilityHint" | "aria-label" | "accessibilityRole" | "role" | "accessibilityState" | "accessibilityValue" | "aria-valuemax" | "aria-valuemin" | "aria-valuenow" | "aria-valuetext" | "accessibilityActions" | "aria-busy" | "aria-checked" | "aria-disabled" | "aria-expanded" | "aria-selected" | "aria-hidden" | "accessibilityLabelledBy" | "aria-labelledby" | "accessibilityLiveRegion" | "aria-live" | "importantForAccessibility" | "screenReaderFocusable" | "children" | "style" | "collapsable" | "collapsableChildren" | "id" | "testID" | "nativeID" | "needsOffscreenAlphaCompositing" | "hitSlop" | "pointerEvents" | "removeClippedSubviews" | "experimental_accessibilityOrder"> & Omit<Readonly<{
96
+ shouldRasterizeIOS?: boolean | undefined;
97
+ }>, "accessibilityIgnoresInvertColors" | "accessibilityViewIsModal" | "accessibilityShowsLargeContentViewer" | "accessibilityLargeContentTitle" | "aria-modal" | "accessibilityElementsHidden" | "accessibilityLanguage" | "accessibilityRespondsToUserInteraction" | "accessible" | "accessibilityLabel" | "accessibilityHint" | "aria-label" | "accessibilityRole" | "role" | "accessibilityState" | "accessibilityValue" | "aria-valuemax" | "aria-valuemin" | "aria-valuenow" | "aria-valuetext" | "accessibilityActions" | "aria-busy" | "aria-checked" | "aria-disabled" | "aria-expanded" | "aria-selected" | "aria-hidden" | "accessibilityLabelledBy" | "aria-labelledby" | "accessibilityLiveRegion" | "aria-live" | "importantForAccessibility" | "screenReaderFocusable" | "children" | "style" | "collapsable" | "collapsableChildren" | "id" | "testID" | "nativeID" | "needsOffscreenAlphaCompositing" | "hitSlop" | "pointerEvents" | "removeClippedSubviews" | "experimental_accessibilityOrder"> & Omit<Readonly<Omit<Readonly<{
98
+ accessibilityLabelledBy?: (string | undefined) | (Array<string> | undefined);
99
+ "aria-labelledby"?: string | undefined;
100
+ accessibilityLiveRegion?: ("none" | "polite" | "assertive") | undefined;
101
+ "aria-live"?: ("polite" | "assertive" | "off") | undefined;
102
+ importantForAccessibility?: ("auto" | "yes" | "no" | "no-hide-descendants") | undefined;
103
+ screenReaderFocusable?: boolean;
104
+ }>, "accessibilityIgnoresInvertColors" | "accessibilityViewIsModal" | "accessibilityShowsLargeContentViewer" | "accessibilityLargeContentTitle" | "aria-modal" | "accessibilityElementsHidden" | "accessibilityLanguage" | "accessibilityRespondsToUserInteraction" | "accessible" | "accessibilityLabel" | "accessibilityHint" | "aria-label" | "accessibilityRole" | "role" | "accessibilityState" | "accessibilityValue" | "aria-valuemax" | "aria-valuemin" | "aria-valuenow" | "aria-valuetext" | "accessibilityActions" | "aria-busy" | "aria-checked" | "aria-disabled" | "aria-expanded" | "aria-selected" | "aria-hidden"> & Omit<Readonly<{
105
+ accessibilityIgnoresInvertColors?: boolean | undefined;
106
+ accessibilityViewIsModal?: boolean | undefined;
107
+ accessibilityShowsLargeContentViewer?: boolean | undefined;
108
+ accessibilityLargeContentTitle?: string | undefined;
109
+ "aria-modal"?: boolean | undefined;
110
+ accessibilityElementsHidden?: boolean | undefined;
111
+ accessibilityLanguage?: string | undefined;
112
+ accessibilityRespondsToUserInteraction?: boolean | undefined;
113
+ }>, "accessible" | "accessibilityLabel" | "accessibilityHint" | "aria-label" | "accessibilityRole" | "role" | "accessibilityState" | "accessibilityValue" | "aria-valuemax" | "aria-valuemin" | "aria-valuenow" | "aria-valuetext" | "accessibilityActions" | "aria-busy" | "aria-checked" | "aria-disabled" | "aria-expanded" | "aria-selected" | "aria-hidden"> & {
114
+ accessible?: boolean | undefined;
115
+ accessibilityLabel?: string | undefined;
116
+ accessibilityHint?: string | undefined;
117
+ "aria-label"?: string | undefined;
118
+ accessibilityRole?: import("react-native").AccessibilityRole | undefined;
119
+ role?: import("react-native").Role | undefined;
120
+ accessibilityState?: import("react-native").AccessibilityState | undefined;
121
+ accessibilityValue?: import("react-native").AccessibilityValue | undefined;
122
+ "aria-valuemax"?: import("react-native").AccessibilityValue["max"] | undefined;
123
+ "aria-valuemin"?: import("react-native").AccessibilityValue["min"] | undefined;
124
+ "aria-valuenow"?: import("react-native").AccessibilityValue["now"] | undefined;
125
+ "aria-valuetext"?: import("react-native").AccessibilityValue["text"] | undefined;
126
+ accessibilityActions?: ReadonlyArray<import("react-native/types_generated/Libraries/Components/View/ViewAccessibility").AccessibilityActionInfo> | undefined;
127
+ "aria-busy"?: boolean | undefined;
128
+ "aria-checked"?: (boolean | undefined) | "mixed";
129
+ "aria-disabled"?: boolean | undefined;
130
+ "aria-expanded"?: boolean | undefined;
131
+ "aria-selected"?: boolean | undefined;
132
+ "aria-hidden"?: boolean | undefined;
133
+ }>, "children" | "style" | "collapsable" | "collapsableChildren" | "id" | "testID" | "nativeID" | "needsOffscreenAlphaCompositing" | "hitSlop" | "pointerEvents" | "removeClippedSubviews" | "experimental_accessibilityOrder"> & Omit<Readonly<{
134
+ children?: React.ReactNode;
135
+ style?: import("react-native/types_generated/Libraries/StyleSheet/StyleSheet").ViewStyleProp | undefined;
136
+ collapsable?: boolean | undefined;
137
+ collapsableChildren?: boolean | undefined;
138
+ id?: string;
139
+ testID?: string | undefined;
140
+ nativeID?: string | undefined;
141
+ needsOffscreenAlphaCompositing?: boolean | undefined;
142
+ hitSlop?: import("react-native/types_generated/Libraries/StyleSheet/EdgeInsetsPropType").EdgeInsetsOrSizeProp | undefined;
143
+ pointerEvents?: ("auto" | "box-none" | "box-only" | "none") | undefined;
144
+ removeClippedSubviews?: boolean | undefined;
145
+ experimental_accessibilityOrder?: Array<string> | undefined;
146
+ }>, never>>, "experimental_accessibilityOrder">, "children" | "removeClippedSubviews" | "automaticallyAdjustContentInsets" | "automaticallyAdjustKeyboardInsets" | "automaticallyAdjustsScrollIndicatorInsets" | "contentInset" | "bounces" | "disableScrollViewPanResponder" | "bouncesZoom" | "alwaysBounceHorizontal" | "alwaysBounceVertical" | "centerContent" | "indicatorStyle" | "directionalLockEnabled" | "canCancelContentTouches" | "maximumZoomScale" | "minimumZoomScale" | "pinchGestureEnabled" | "scrollIndicatorInsets" | "scrollToOverflowEnabled" | "scrollsToTop" | "onScrollToTop" | "showsHorizontalScrollIndicator" | "zoomScale" | "contentInsetAdjustmentBehavior" | "nestedScrollEnabled" | "endFillColor" | "scrollPerfTag" | "overScrollMode" | "persistentScrollbar" | "fadingEdgeLength" | "contentContainerStyle" | "contentOffset" | "disableIntervalMomentum" | "decelerationRate" | "experimental_endDraggingSensitivityMultiplier" | "horizontal" | "invertStickyHeaders" | "keyboardDismissMode" | "keyboardShouldPersistTaps" | "maintainVisibleContentPosition" | "onMomentumScrollBegin" | "onMomentumScrollEnd" | "onScroll" | "onScrollBeginDrag" | "onScrollEndDrag" | "onContentSizeChange" | "onKeyboardDidShow" | "onKeyboardDidHide" | "onKeyboardWillShow" | "onKeyboardWillHide" | "pagingEnabled" | "scrollEnabled" | "scrollEventThrottle" | "showsVerticalScrollIndicator" | "stickyHeaderHiddenOnScroll" | "stickyHeaderIndices" | "StickyHeaderComponent" | "snapToAlignment" | "snapToInterval" | "snapToOffsets" | "snapToStart" | "snapToEnd" | "refreshControl" | "innerViewRef" | "scrollViewRef"> & Omit<Readonly<{
147
+ automaticallyAdjustContentInsets?: boolean | undefined;
148
+ automaticallyAdjustKeyboardInsets?: boolean | undefined;
149
+ automaticallyAdjustsScrollIndicatorInsets?: boolean | undefined;
150
+ contentInset?: import("react-native/types_generated/Libraries/StyleSheet/EdgeInsetsPropType").EdgeInsetsProp | undefined;
151
+ bounces?: boolean | undefined;
152
+ disableScrollViewPanResponder?: boolean | undefined;
153
+ bouncesZoom?: boolean | undefined;
154
+ alwaysBounceHorizontal?: boolean | undefined;
155
+ alwaysBounceVertical?: boolean | undefined;
156
+ centerContent?: boolean | undefined;
157
+ indicatorStyle?: ("default" | "black" | "white") | undefined;
158
+ directionalLockEnabled?: boolean | undefined;
159
+ canCancelContentTouches?: boolean | undefined;
160
+ maximumZoomScale?: number | undefined;
161
+ minimumZoomScale?: number | undefined;
162
+ pinchGestureEnabled?: boolean | undefined;
163
+ scrollIndicatorInsets?: import("react-native/types_generated/Libraries/StyleSheet/EdgeInsetsPropType").EdgeInsetsProp | undefined;
164
+ scrollToOverflowEnabled?: boolean | undefined;
165
+ scrollsToTop?: boolean | undefined;
166
+ onScrollToTop?: (event: import("react-native").ScrollEvent) => void;
167
+ showsHorizontalScrollIndicator?: boolean | undefined;
168
+ zoomScale?: number | undefined;
169
+ contentInsetAdjustmentBehavior?: ("automatic" | "scrollableAxes" | "never" | "always") | undefined;
170
+ }>, "children" | "removeClippedSubviews" | "nestedScrollEnabled" | "endFillColor" | "scrollPerfTag" | "overScrollMode" | "persistentScrollbar" | "fadingEdgeLength" | "contentContainerStyle" | "contentOffset" | "disableIntervalMomentum" | "decelerationRate" | "experimental_endDraggingSensitivityMultiplier" | "horizontal" | "invertStickyHeaders" | "keyboardDismissMode" | "keyboardShouldPersistTaps" | "maintainVisibleContentPosition" | "onMomentumScrollBegin" | "onMomentumScrollEnd" | "onScroll" | "onScrollBeginDrag" | "onScrollEndDrag" | "onContentSizeChange" | "onKeyboardDidShow" | "onKeyboardDidHide" | "onKeyboardWillShow" | "onKeyboardWillHide" | "pagingEnabled" | "scrollEnabled" | "scrollEventThrottle" | "showsVerticalScrollIndicator" | "stickyHeaderHiddenOnScroll" | "stickyHeaderIndices" | "StickyHeaderComponent" | "snapToAlignment" | "snapToInterval" | "snapToOffsets" | "snapToStart" | "snapToEnd" | "refreshControl" | "innerViewRef" | "scrollViewRef"> & Omit<Readonly<{
171
+ nestedScrollEnabled?: boolean | undefined;
172
+ endFillColor?: import("react-native").ColorValue | undefined;
173
+ scrollPerfTag?: string | undefined;
174
+ overScrollMode?: ("auto" | "always" | "never") | undefined;
175
+ persistentScrollbar?: boolean | undefined;
176
+ fadingEdgeLength?: (number | undefined) | {
177
+ start: number;
178
+ end: number;
179
+ };
180
+ }>, "children" | "removeClippedSubviews" | "contentContainerStyle" | "contentOffset" | "disableIntervalMomentum" | "decelerationRate" | "experimental_endDraggingSensitivityMultiplier" | "horizontal" | "invertStickyHeaders" | "keyboardDismissMode" | "keyboardShouldPersistTaps" | "maintainVisibleContentPosition" | "onMomentumScrollBegin" | "onMomentumScrollEnd" | "onScroll" | "onScrollBeginDrag" | "onScrollEndDrag" | "onContentSizeChange" | "onKeyboardDidShow" | "onKeyboardDidHide" | "onKeyboardWillShow" | "onKeyboardWillHide" | "pagingEnabled" | "scrollEnabled" | "scrollEventThrottle" | "showsVerticalScrollIndicator" | "stickyHeaderHiddenOnScroll" | "stickyHeaderIndices" | "StickyHeaderComponent" | "snapToAlignment" | "snapToInterval" | "snapToOffsets" | "snapToStart" | "snapToEnd" | "refreshControl" | "innerViewRef" | "scrollViewRef"> & Omit<Readonly<{
181
+ contentContainerStyle?: import("react-native/types_generated/Libraries/StyleSheet/StyleSheet").ViewStyleProp | undefined;
182
+ contentOffset?: import("react-native/types_generated/Libraries/StyleSheet/PointPropType").PointProp | undefined;
183
+ disableIntervalMomentum?: boolean | undefined;
184
+ decelerationRate?: import("react-native/types_generated/Libraries/Components/ScrollView/ScrollView").DecelerationRateType | undefined;
185
+ experimental_endDraggingSensitivityMultiplier?: number | undefined;
186
+ horizontal?: boolean | undefined;
187
+ invertStickyHeaders?: boolean | undefined;
188
+ keyboardDismissMode?: ("none" | "on-drag" | "interactive") | undefined;
189
+ keyboardShouldPersistTaps?: ("always" | "never" | "handled" | true | false) | undefined;
190
+ maintainVisibleContentPosition?: Readonly<{
191
+ minIndexForVisible: number;
192
+ autoscrollToTopThreshold?: number | undefined;
193
+ }> | undefined;
194
+ onMomentumScrollBegin?: ((event: import("react-native").ScrollEvent) => void) | undefined;
195
+ onMomentumScrollEnd?: ((event: import("react-native").ScrollEvent) => void) | undefined;
196
+ onScroll?: ((event: import("react-native").ScrollEvent) => void) | undefined;
197
+ onScrollBeginDrag?: ((event: import("react-native").ScrollEvent) => void) | undefined;
198
+ onScrollEndDrag?: ((event: import("react-native").ScrollEvent) => void) | undefined;
199
+ onContentSizeChange?: (contentWidth: number, contentHeight: number) => void;
200
+ onKeyboardDidShow?: (event: import("react-native").KeyboardEvent) => void;
201
+ onKeyboardDidHide?: (event: import("react-native").KeyboardEvent) => void;
202
+ onKeyboardWillShow?: (event: import("react-native").KeyboardEvent) => void;
203
+ onKeyboardWillHide?: (event: import("react-native").KeyboardEvent) => void;
204
+ pagingEnabled?: boolean | undefined;
205
+ scrollEnabled?: boolean | undefined;
206
+ scrollEventThrottle?: number | undefined;
207
+ showsVerticalScrollIndicator?: boolean | undefined;
208
+ stickyHeaderHiddenOnScroll?: boolean | undefined;
209
+ stickyHeaderIndices?: ReadonlyArray<number> | undefined;
210
+ StickyHeaderComponent?: (props: Omit<import("react-native/types_generated/Libraries/Components/ScrollView/ScrollViewStickyHeader").ScrollViewStickyHeaderProps, keyof {
211
+ ref?: React.Ref<Readonly<{
212
+ setNextHeaderY: ($$PARAM_0$$: number) => void;
213
+ }>>;
214
+ }> & {
215
+ ref?: React.Ref<Readonly<{
216
+ setNextHeaderY: ($$PARAM_0$$: number) => void;
217
+ }>>;
218
+ }) => React.ReactNode;
219
+ snapToAlignment?: ("start" | "center" | "end") | undefined;
220
+ snapToInterval?: number | undefined;
221
+ snapToOffsets?: ReadonlyArray<number> | undefined;
222
+ snapToStart?: boolean | undefined;
223
+ snapToEnd?: boolean | undefined;
224
+ removeClippedSubviews?: boolean | undefined;
225
+ refreshControl?: React.JSX.Element | undefined;
226
+ children?: React.ReactNode;
227
+ innerViewRef?: React.Ref<import("react-native/types_generated/src/private/webapis/dom/nodes/ReactNativeElement").default>;
228
+ scrollViewRef?: React.Ref<import("react-native/types_generated/Libraries/Components/ScrollView/ScrollView").PublicScrollViewInstance>;
229
+ }>, never>> & {
230
+ /**
231
+ * Fling velocity cap as a normalized fraction in [0, 1], or `null` to
232
+ * disable the cap mechanism entirely.
233
+ *
234
+ * The reference maximum is 8000 dp/s on Android and 8000 pt/s on iOS
235
+ * (same logical unit), so the same `maxVelocity` value produces the same
236
+ * visual scroll speed on both platforms.
237
+ *
238
+ * - `null` / `undefined` (default) — no capper installed; behaves exactly
239
+ * like a plain RN `ScrollView`.
240
+ * - `1` — capper installed at the reference maximum. Human-paced flings
241
+ * pass through untouched.
242
+ * - `0` — list cannot fling at all.
243
+ * - `0 < x < 1` — peak fling velocity is clamped to `x × 8000` (dp/s on
244
+ * Android, pt/s on iOS). Fling distance scales linearly with `x`.
245
+ */
246
+ maxVelocity?: number | null;
247
+ } & import("react").RefAttributes<import("react-native/types_generated/Libraries/Components/ScrollView/ScrollView").PublicScrollViewInstance>>;
248
+ //# sourceMappingURL=CappedScrollView.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"CappedScrollView.d.ts","sourceRoot":"","sources":["../../../src/CappedScrollView.tsx"],"names":[],"mappings":"AAAA,OAAO,EAKL,KAAK,YAAY,EAElB,MAAM,OAAO,CAAC;AACf,OAAO,EAAE,UAAU,EAAE,KAAK,eAAe,EAAE,MAAM,cAAc,CAAC;AAGhE,MAAM,MAAM,mBAAmB,GAAG,YAAY,CAAC,OAAO,UAAU,CAAC,CAAC;AAElE,MAAM,MAAM,qBAAqB,GAAG,eAAe,GAAG;IACpD;;;;;;;;;;;;;;;OAeG;IACH,WAAW,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;CAC7B,CAAC;AAOF,eAAO,MAAM,gBAAgB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;gCAmB4jb,CAAC;;;;;;;;;;;;;;;;;;;WAA74K,CAAC;;;;WAA6F,CAAC;;;;;;;;;;;;;;;IA3C1yQ;;;;;;;;;;;;;;;OAeG;kBACW,MAAM,GAAG,IAAI;8IA0B3B,CAAC"}
@@ -0,0 +1,7 @@
1
+ import type { TurboModule } from 'react-native';
2
+ export interface Spec extends TurboModule {
3
+ setMaxVelocity(reactTag: number, maxVelocity: number): void;
4
+ }
5
+ declare const _default: Spec;
6
+ export default _default;
7
+ //# sourceMappingURL=NativeCappedScrollView.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"NativeCappedScrollView.d.ts","sourceRoot":"","sources":["../../../src/NativeCappedScrollView.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,cAAc,CAAC;AAGhD,MAAM,WAAW,IAAK,SAAQ,WAAW;IACvC,cAAc,CAAC,QAAQ,EAAE,MAAM,EAAE,WAAW,EAAE,MAAM,GAAG,IAAI,CAAC;CAC7D;;AAED,wBAA0E"}
@@ -0,0 +1,3 @@
1
+ export { CappedScrollView } from './CappedScrollView';
2
+ export type { CappedScrollViewProps, CappedScrollViewRef, } from './CappedScrollView';
3
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/index.tsx"],"names":[],"mappings":"AAAA,OAAO,EAAE,gBAAgB,EAAE,MAAM,oBAAoB,CAAC;AACtD,YAAY,EACV,qBAAqB,EACrB,mBAAmB,GACpB,MAAM,oBAAoB,CAAC"}
package/package.json ADDED
@@ -0,0 +1,188 @@
1
+ {
2
+ "name": "react-native-capped-scrollview",
3
+ "version": "0.1.0",
4
+ "description": "Drop-in React Native ScrollView that caps fling velocity via a 0-1 maxVelocity prop. Fabric + TurboModules, iOS and Android.",
5
+ "main": "./lib/module/index.js",
6
+ "types": "./lib/typescript/src/index.d.ts",
7
+ "exports": {
8
+ ".": {
9
+ "source": "./src/index.tsx",
10
+ "types": "./lib/typescript/src/index.d.ts",
11
+ "default": "./lib/module/index.js"
12
+ },
13
+ "./package.json": "./package.json"
14
+ },
15
+ "files": [
16
+ "src",
17
+ "lib",
18
+ "android",
19
+ "ios",
20
+ "cpp",
21
+ "*.podspec",
22
+ "react-native.config.js",
23
+ "!ios/build",
24
+ "!android/build",
25
+ "!android/gradle",
26
+ "!android/gradlew",
27
+ "!android/gradlew.bat",
28
+ "!android/local.properties",
29
+ "!**/__tests__",
30
+ "!**/__fixtures__",
31
+ "!**/__mocks__",
32
+ "!**/.*"
33
+ ],
34
+ "scripts": {
35
+ "example": "yarn workspace react-native-capped-scrollview-example",
36
+ "clean": "del-cli lib",
37
+ "prepare": "bob build",
38
+ "typecheck": "tsc",
39
+ "lint": "eslint \"**/*.{js,ts,tsx}\"",
40
+ "test": "jest",
41
+ "release": "release-it --only-version"
42
+ },
43
+ "keywords": [
44
+ "react-native",
45
+ "ios",
46
+ "android",
47
+ "scrollview",
48
+ "scroll",
49
+ "fling",
50
+ "velocity",
51
+ "max-velocity",
52
+ "fabric",
53
+ "turbomodule"
54
+ ],
55
+ "repository": {
56
+ "type": "git",
57
+ "url": "git+https://github.com/leonsilicon/react-native-capped-scrollview.git"
58
+ },
59
+ "author": "Leon Si <leon@leonsilicon.com> (https://github.com/leonsilicon)",
60
+ "license": "MIT",
61
+ "bugs": {
62
+ "url": "https://github.com/leonsilicon/react-native-capped-scrollview/issues"
63
+ },
64
+ "homepage": "https://github.com/leonsilicon/react-native-capped-scrollview#readme",
65
+ "publishConfig": {
66
+ "registry": "https://registry.npmjs.org/"
67
+ },
68
+ "devDependencies": {
69
+ "@commitlint/config-conventional": "^20.5.0",
70
+ "@eslint/compat": "^2.0.3",
71
+ "@eslint/eslintrc": "^3.3.5",
72
+ "@eslint/js": "^10.0.1",
73
+ "@jest/globals": "^30.0.0",
74
+ "@react-native/babel-preset": "0.85.0",
75
+ "@react-native/eslint-config": "0.85.0",
76
+ "@react-native/jest-preset": "0.85.0",
77
+ "@release-it/conventional-changelog": "^10.0.6",
78
+ "@types/react": "^19.2.0",
79
+ "commitlint": "^20.5.0",
80
+ "del-cli": "^7.0.0",
81
+ "eslint": "^9.39.4",
82
+ "eslint-config-prettier": "^10.1.8",
83
+ "eslint-plugin-ft-flow": "^3.0.11",
84
+ "eslint-plugin-prettier": "^5.5.5",
85
+ "jest": "^30.3.0",
86
+ "lefthook": "^2.1.4",
87
+ "prettier": "^3.8.1",
88
+ "react": "19.2.0",
89
+ "react-native": "0.83.6",
90
+ "react-native-builder-bob": "^0.41.0",
91
+ "release-it": "^19.2.4",
92
+ "turbo": "^2.8.21",
93
+ "typescript": "^6.0.2"
94
+ },
95
+ "peerDependencies": {
96
+ "react": ">=19.0.0",
97
+ "react-native": ">=0.80.0"
98
+ },
99
+ "engines": {
100
+ "node": ">=20"
101
+ },
102
+ "workspaces": [
103
+ "example"
104
+ ],
105
+ "packageManager": "yarn@4.11.0",
106
+ "react-native-builder-bob": {
107
+ "source": "src",
108
+ "output": "lib",
109
+ "targets": [
110
+ [
111
+ "module",
112
+ {
113
+ "esm": true
114
+ }
115
+ ],
116
+ [
117
+ "typescript",
118
+ {
119
+ "project": "tsconfig.build.json"
120
+ }
121
+ ]
122
+ ]
123
+ },
124
+ "codegenConfig": {
125
+ "name": "CappedScrollViewSpec",
126
+ "type": "modules",
127
+ "jsSrcsDir": "src",
128
+ "android": {
129
+ "javaPackageName": "com.cappedscrollview"
130
+ },
131
+ "ios": {
132
+ "modules": {
133
+ "CappedScrollView": {
134
+ "className": "CappedScrollView"
135
+ }
136
+ }
137
+ }
138
+ },
139
+ "prettier": {
140
+ "quoteProps": "consistent",
141
+ "singleQuote": true,
142
+ "tabWidth": 2,
143
+ "trailingComma": "es5",
144
+ "useTabs": false
145
+ },
146
+ "jest": {
147
+ "preset": "@react-native/jest-preset",
148
+ "modulePathIgnorePatterns": [
149
+ "<rootDir>/example/node_modules",
150
+ "<rootDir>/lib/"
151
+ ]
152
+ },
153
+ "commitlint": {
154
+ "extends": [
155
+ "@commitlint/config-conventional"
156
+ ]
157
+ },
158
+ "release-it": {
159
+ "git": {
160
+ "commitMessage": "chore: release ${version}",
161
+ "tagName": "v${version}"
162
+ },
163
+ "npm": {
164
+ "publish": true
165
+ },
166
+ "github": {
167
+ "release": true
168
+ },
169
+ "plugins": {
170
+ "@release-it/conventional-changelog": {
171
+ "preset": {
172
+ "name": "angular"
173
+ }
174
+ }
175
+ }
176
+ },
177
+ "create-react-native-library": {
178
+ "type": "turbo-module",
179
+ "languages": "kotlin-objc",
180
+ "tools": [
181
+ "eslint",
182
+ "jest",
183
+ "lefthook",
184
+ "release-it"
185
+ ],
186
+ "version": "0.62.0"
187
+ }
188
+ }
@@ -0,0 +1,57 @@
1
+ import {
2
+ forwardRef,
3
+ useEffect,
4
+ useRef,
5
+ useImperativeHandle,
6
+ type ComponentRef,
7
+ type Ref,
8
+ } from 'react';
9
+ import { ScrollView, type ScrollViewProps } from 'react-native';
10
+ import NativeCappedScrollView from './NativeCappedScrollView';
11
+
12
+ export type CappedScrollViewRef = ComponentRef<typeof ScrollView>;
13
+
14
+ export type CappedScrollViewProps = ScrollViewProps & {
15
+ /**
16
+ * Fling velocity cap as a normalized fraction in [0, 1], or `null` to
17
+ * disable the cap mechanism entirely.
18
+ *
19
+ * The reference maximum is 8000 dp/s on Android and 8000 pt/s on iOS
20
+ * (same logical unit), so the same `maxVelocity` value produces the same
21
+ * visual scroll speed on both platforms.
22
+ *
23
+ * - `null` / `undefined` (default) — no capper installed; behaves exactly
24
+ * like a plain RN `ScrollView`.
25
+ * - `1` — capper installed at the reference maximum. Human-paced flings
26
+ * pass through untouched.
27
+ * - `0` — list cannot fling at all.
28
+ * - `0 < x < 1` — peak fling velocity is clamped to `x × 8000` (dp/s on
29
+ * Android, pt/s on iOS). Fling distance scales linearly with `x`.
30
+ */
31
+ maxVelocity?: number | null;
32
+ };
33
+
34
+ // Sentinel sent to native when consumers pass `null`/omit the prop, meaning
35
+ // "no capper installed at all." Negative values are unreachable from the
36
+ // public [0, 1] range, so this is unambiguous on the native side.
37
+ const NO_CAP_SENTINEL = -1;
38
+
39
+ export const CappedScrollView = forwardRef(function CappedScrollView(
40
+ { maxVelocity, ...rest }: CappedScrollViewProps,
41
+ ref: Ref<CappedScrollViewRef>
42
+ ) {
43
+ const innerRef = useRef<CappedScrollViewRef | null>(null);
44
+
45
+ useImperativeHandle(ref, () => innerRef.current as CappedScrollViewRef);
46
+
47
+ useEffect(() => {
48
+ const handle = innerRef.current?.getScrollableNode?.();
49
+ if (handle == null) {
50
+ return;
51
+ }
52
+ const value = maxVelocity == null ? NO_CAP_SENTINEL : maxVelocity;
53
+ NativeCappedScrollView.setMaxVelocity(handle, value);
54
+ }, [maxVelocity]);
55
+
56
+ return <ScrollView ref={innerRef} {...rest} />;
57
+ });
@@ -0,0 +1,8 @@
1
+ import type { TurboModule } from 'react-native';
2
+ import { TurboModuleRegistry } from 'react-native';
3
+
4
+ export interface Spec extends TurboModule {
5
+ setMaxVelocity(reactTag: number, maxVelocity: number): void;
6
+ }
7
+
8
+ export default TurboModuleRegistry.getEnforcing<Spec>('CappedScrollView');
package/src/index.tsx ADDED
@@ -0,0 +1,5 @@
1
+ export { CappedScrollView } from './CappedScrollView';
2
+ export type {
3
+ CappedScrollViewProps,
4
+ CappedScrollViewRef,
5
+ } from './CappedScrollView';