Skip to content

Commit 514b5b2

Browse files
committed
Share explicit scroll snap target selection across Android and iOS
1 parent d14ae50 commit 514b5b2

13 files changed

Lines changed: 1048 additions & 72 deletions

File tree

.github/workflows/test-kmp.yml

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,9 @@ on:
3434
- 'packages/react-native/ReactCommon/react/utils/ManagedObjectWrapper.*'
3535
- 'packages/react-native/React/React-RCTFabric.podspec'
3636
- 'packages/react-native/React-Core.podspec'
37+
- 'packages/react-native/React/Fabric/Mounting/ComponentViews/ScrollView/**'
38+
- 'packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/scroll/**'
39+
- 'packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/scroll/**'
3740
- 'packages/react-native/React/Base/RCTMultipartStreamReader.*'
3841
- 'packages/react-native/ReactAndroid/src/main/java/com/facebook/react/devsupport/MultipartStreamReader.kt'
3942
- 'packages/react-native/ReactAndroid/src/test/java/com/facebook/react/devsupport/MultipartStreamReaderTest.kt'
@@ -78,6 +81,9 @@ on:
7881
- 'packages/react-native/ReactCommon/react/utils/ManagedObjectWrapper.*'
7982
- 'packages/react-native/React/React-RCTFabric.podspec'
8083
- 'packages/react-native/React-Core.podspec'
84+
- 'packages/react-native/React/Fabric/Mounting/ComponentViews/ScrollView/**'
85+
- 'packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/scroll/**'
86+
- 'packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/scroll/**'
8187
- 'packages/react-native/React/Base/RCTMultipartStreamReader.*'
8288
- 'packages/react-native/ReactAndroid/src/main/java/com/facebook/react/devsupport/MultipartStreamReader.kt'
8389
- 'packages/react-native/ReactAndroid/src/test/java/com/facebook/react/devsupport/MultipartStreamReaderTest.kt'
@@ -155,6 +161,10 @@ jobs:
155161
run: |
156162
./scripts/test-apple-multipart.sh
157163
python3 scripts/test-android-multipart.py --max-workers 2
164+
- name: Compare native and shared scroll snap adapters
165+
if: ${{ !cancelled() && steps.shared.outcome == 'success' }}
166+
working-directory: packages/react-native/ReactShared
167+
run: python3 scripts/test-apple-scroll-snap.py --output "$RUNNER_TEMP/apple-scroll-snap" --benchmark-repeats 0
158168
- name: Test packaged XCFramework consumption
159169
if: ${{ !cancelled() && steps.shared.outcome == 'success' }}
160170
working-directory: packages/react-native/ReactShared
@@ -179,6 +189,8 @@ jobs:
179189
path: |
180190
packages/react-native/ReactShared/build/reports/tests
181191
packages/react-native/ReactShared/build/test-results
192+
${{ runner.temp }}/apple-scroll-snap/**/*.json
193+
${{ runner.temp }}/apple-scroll-snap/**/*.log
182194
packages/react-native/ReactShared/build/apple-multipart-test/**/*.log
183195
packages/react-native/ReactShared/build/android-multipart-test/build/test-results
184196
packages/react-native/ReactShared/build/apple-distribution/**/*.json

packages/react-native/React/Fabric/Mounting/ComponentViews/ScrollView/RCTEnhancedScrollView.mm

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,15 +7,44 @@
77

88
#import "RCTEnhancedScrollView.h"
99
#import <React/RCTUtils.h>
10+
#import <TargetConditionals.h>
1011
#import <react/utils/FloatComparison.h>
1112

13+
#if RCT_USE_KMP && TARGET_OS_IOS && !TARGET_OS_MACCATALYST
14+
#define RCT_SCROLL_SNAP_USE_KMP 1
15+
#import <ReactNativeShared/ReactNativeShared.h>
16+
#else
17+
#define RCT_SCROLL_SNAP_USE_KMP 0
18+
#endif
19+
1220
@interface RCTEnhancedScrollView () <UIScrollViewDelegate>
1321
@end
1422

1523
@implementation RCTEnhancedScrollView {
1624
__weak id<UIScrollViewDelegate> _publicDelegate;
1725
BOOL _isSetContentOffsetDisabled;
26+
#if RCT_SCROLL_SNAP_USE_KMP
27+
RNSScrollSnapOffsets *_sharedSnapOffsets;
28+
#endif
29+
}
30+
31+
#if RCT_SCROLL_SNAP_USE_KMP
32+
@synthesize snapToOffsets = _snapToOffsets;
33+
34+
- (void)setSnapToOffsets:(NSArray<NSNumber *> *)snapToOffsets
35+
{
36+
_snapToOffsets = [snapToOffsets copy];
37+
_sharedSnapOffsets = nil;
38+
if (_snapToOffsets.count > 0) {
39+
// Convert once per property update, retaining the existing floatValue precision.
40+
RNSKotlinDoubleArray *offsets = [RNSKotlinDoubleArray arrayWithSize:(int32_t)_snapToOffsets.count];
41+
for (NSUInteger i = 0; i < _snapToOffsets.count; i++) {
42+
[offsets setIndex:(int32_t)i value:_snapToOffsets[i].floatValue];
43+
}
44+
_sharedSnapOffsets = [[RNSScrollSnapOffsets alloc] initWithOffsets:offsets];
45+
}
1846
}
47+
#endif
1948

2049
+ (BOOL)automaticallyNotifiesObserversForKey:(NSString *)key
2150
{
@@ -199,6 +228,15 @@ - (void)scrollViewWillEndDragging:(UIScrollView *)scrollView
199228

200229
// Calculate the snap offsets adjacent to the initial offset target
201230
CGFloat targetOffset = isHorizontal ? targetContentOffset->x : targetContentOffset->y;
231+
#if RCT_SCROLL_SNAP_USE_KMP
232+
targetOffset = [_sharedSnapOffsets resolveCurrentOffset:offsetAlongAxis
233+
targetOffset:targetOffset
234+
maximumOffset:maximumOffset
235+
velocity:velocityAlongAxis
236+
snapToStart:self.snapToStart
237+
snapToEnd:self.snapToEnd]
238+
.targetOffset;
239+
#else
202240
CGFloat smallerOffset = 0.0;
203241
CGFloat largerOffset = maximumOffset;
204242

@@ -250,6 +288,7 @@ - (void)scrollViewWillEndDragging:(UIScrollView *)scrollView
250288

251289
// Make sure the new offset isn't out of bounds
252290
targetOffset = MIN(MAX(0, targetOffset), maximumOffset);
291+
#endif
253292

254293
// Set new targetContentOffset
255294
if (isHorizontal) {

packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactHorizontalScrollView.kt

Lines changed: 35 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,8 @@ import com.facebook.react.R
3535
import com.facebook.react.common.ReactConstants
3636
import com.facebook.react.common.build.ReactBuildConfig
3737
import com.facebook.react.internal.featureflags.ReactNativeFeatureFlags
38+
import com.facebook.react.shared.ScrollSnapDirection
39+
import com.facebook.react.shared.ScrollSnapOffsets
3840
import com.facebook.react.uimanager.BackgroundStyleApplicator
3941
import com.facebook.react.uimanager.HasChildPressedStateDelay
4042
import com.facebook.react.uimanager.LengthPercentage
@@ -169,6 +171,7 @@ constructor(context: Context, private val fpsListener: FpsListener? = null) :
169171
private var disableIntervalMomentum = false
170172
private var snapInterval = 0
171173
private var snapOffsets: List<Int>? = null
174+
private var sharedSnapOffsets: ScrollSnapOffsets? = null
172175
private var snapToStart = true
173176
private var snapToEnd = true
174177
private var snapToAlignment = SNAP_ALIGNMENT_DISABLED
@@ -277,6 +280,7 @@ constructor(context: Context, private val fpsListener: FpsListener? = null) :
277280
disableIntervalMomentum = false
278281
snapInterval = 0
279282
snapOffsets = null
283+
sharedSnapOffsets = null
280284
snapToStart = true
281285
snapToEnd = true
282286
snapToAlignment = SNAP_ALIGNMENT_DISABLED
@@ -388,6 +392,7 @@ constructor(context: Context, private val fpsListener: FpsListener? = null) :
388392

389393
public open fun setSnapOffsets(snapOffsets: List<Int>?) {
390394
this.snapOffsets = snapOffsets
395+
sharedSnapOffsets = snapOffsets?.let { ScrollSnapOffsets.fromIntegerOffsets(it) }
391396
}
392397

393398
public open fun setSnapToStart(snapToStart: Boolean) {
@@ -1223,8 +1228,8 @@ constructor(context: Context, private val fpsListener: FpsListener? = null) :
12231228

12241229
var smallerOffset = 0
12251230
var largerOffset = maximumOffset
1226-
var firstOffset = 0
1227-
var lastOffset = maximumOffset
1231+
val firstOffset = 0
1232+
val lastOffset = maximumOffset
12281233
val viewportWidth = width - paddingStart - paddingEnd
12291234

12301235
// offsets are from the right edge in RTL layouts
@@ -1233,26 +1238,21 @@ constructor(context: Context, private val fpsListener: FpsListener? = null) :
12331238
velocityX = -velocityX
12341239
}
12351240

1236-
// get the nearest snap points to the target offset
1237-
val offsets = snapOffsets
1238-
if (!offsets.isNullOrEmpty()) {
1239-
firstOffset = offsets[0]
1240-
lastOffset = offsets[offsets.size - 1]
1241-
1242-
for (i in offsets.indices) {
1243-
val offset = offsets[i]
1244-
if (offset <= targetOffset) {
1245-
if (targetOffset - offset < targetOffset - smallerOffset) {
1246-
smallerOffset = offset
1247-
}
1248-
}
1249-
if (offset >= targetOffset) {
1250-
if (offset - targetOffset < largerOffset - targetOffset) {
1251-
largerOffset = offset
1252-
}
1253-
}
1254-
}
1255-
} else if (snapToAlignment != SNAP_ALIGNMENT_DISABLED) {
1241+
val snapResult =
1242+
sharedSnapOffsets
1243+
?.takeIf { !snapOffsets.isNullOrEmpty() }
1244+
?.resolve(
1245+
currentOffset =
1246+
(if (layoutDirection == LAYOUT_DIRECTION_RTL) maximumOffset - scrollX
1247+
else scrollX)
1248+
.toDouble(),
1249+
targetOffset = targetOffset.toDouble(),
1250+
maximumOffset = maximumOffset.toDouble(),
1251+
velocity = velocityX.toDouble(),
1252+
snapToStart = snapToStart,
1253+
snapToEnd = snapToEnd,
1254+
)
1255+
if (snapResult == null && snapToAlignment != SNAP_ALIGNMENT_DISABLED) {
12561256
if (snapInterval > 0) {
12571257
val ratio = targetOffset.toDouble() / snapInterval
12581258
smallerOffset =
@@ -1303,7 +1303,7 @@ constructor(context: Context, private val fpsListener: FpsListener? = null) :
13031303
smallerOffset = max(smallerOffset, smallerChildOffset)
13041304
largerOffset = kotlin.math.min(largerOffset, largerChildOffset)
13051305
}
1306-
} else {
1306+
} else if (snapResult == null) {
13071307
val interval = getSnapInterval().toDouble()
13081308
val ratio = targetOffset.toDouble() / interval
13091309
smallerOffset = (floor(ratio) * interval).toInt()
@@ -1321,7 +1321,18 @@ constructor(context: Context, private val fpsListener: FpsListener? = null) :
13211321
if (layoutDirection == LAYOUT_DIRECTION_RTL) {
13221322
currentOffset = maximumOffset - currentOffset
13231323
}
1324-
if (!snapToEnd && targetOffset >= lastOffset) {
1324+
if (snapResult != null) {
1325+
if (!hasCustomizedFlingAnimator) {
1326+
when (snapResult.direction) {
1327+
ScrollSnapDirection.FORWARD ->
1328+
velocityX += ((snapResult.selectedOffset.toInt() - targetOffset) * 10.0).toInt()
1329+
ScrollSnapDirection.BACKWARD ->
1330+
velocityX -= ((targetOffset - snapResult.selectedOffset.toInt()) * 10.0).toInt()
1331+
ScrollSnapDirection.NONE -> {}
1332+
}
1333+
}
1334+
targetOffset = snapResult.targetOffset.toInt()
1335+
} else if (!snapToEnd && targetOffset >= lastOffset) {
13251336
if (currentOffset >= lastOffset) {
13261337
// free scrolling
13271338
} else {

packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactNestedScrollView.kt

Lines changed: 30 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,8 @@ import com.facebook.react.R
3737
import com.facebook.react.bridge.ReadableMap
3838
import com.facebook.react.common.ReactConstants
3939
import com.facebook.react.internal.featureflags.ReactNativeFeatureFlags
40+
import com.facebook.react.shared.ScrollSnapDirection
41+
import com.facebook.react.shared.ScrollSnapOffsets
4042
import com.facebook.react.uimanager.BackgroundStyleApplicator
4143
import com.facebook.react.uimanager.HasChildPressedStateDelay
4244
import com.facebook.react.uimanager.LengthPercentage
@@ -203,6 +205,7 @@ constructor(context: Context, private val fpsListener: FpsListener? = null) :
203205
private var disableIntervalMomentum = false
204206
private var snapInterval = 0
205207
private var snapOffsets: List<Int>? = null
208+
private var sharedSnapOffsets: ScrollSnapOffsets? = null
206209
private var snapToStart = true
207210
private var snapToEnd = true
208211
private var snapToAlignment = SNAP_ALIGNMENT_DISABLED
@@ -252,6 +255,7 @@ constructor(context: Context, private val fpsListener: FpsListener? = null) :
252255
disableIntervalMomentum = false
253256
snapInterval = 0
254257
snapOffsets = null
258+
sharedSnapOffsets = null
255259
snapToStart = true
256260
snapToEnd = true
257261
snapToAlignment = SNAP_ALIGNMENT_DISABLED
@@ -359,6 +363,7 @@ constructor(context: Context, private val fpsListener: FpsListener? = null) :
359363

360364
open fun setSnapOffsets(snapOffsets: List<Int>?) {
361365
this.snapOffsets = snapOffsets
366+
sharedSnapOffsets = snapOffsets?.let { ScrollSnapOffsets.fromIntegerOffsets(it) }
362367
}
363368

364369
open fun setSnapToStart(snapToStart: Boolean) {
@@ -923,29 +928,20 @@ constructor(context: Context, private val fpsListener: FpsListener? = null) :
923928

924929
var smallerOffset = 0
925930
var largerOffset = maximumOffset
926-
var firstOffset = 0
927-
var lastOffset = maximumOffset
931+
val firstOffset = 0
932+
val lastOffset = maximumOffset
928933
val viewportHeight = height - paddingBottom - paddingTop
929934

930-
val currentSnapOffsets = snapOffsets
931-
if (currentSnapOffsets != null) {
932-
firstOffset = currentSnapOffsets[0]
933-
lastOffset = currentSnapOffsets[currentSnapOffsets.size - 1]
934-
935-
for (i in currentSnapOffsets.indices) {
936-
val offset = currentSnapOffsets[i]
937-
if (offset <= targetOffset) {
938-
if (targetOffset - offset < targetOffset - smallerOffset) {
939-
smallerOffset = offset
940-
}
941-
}
942-
if (offset >= targetOffset) {
943-
if (offset - targetOffset < largerOffset - targetOffset) {
944-
largerOffset = offset
945-
}
946-
}
947-
}
948-
} else if (snapToAlignment != SNAP_ALIGNMENT_DISABLED) {
935+
val snapResult =
936+
sharedSnapOffsets?.resolve(
937+
currentOffset = scrollY.toDouble(),
938+
targetOffset = targetOffset.toDouble(),
939+
maximumOffset = maximumOffset.toDouble(),
940+
velocity = velocityY.toDouble(),
941+
snapToStart = snapToStart,
942+
snapToEnd = snapToEnd,
943+
)
944+
if (snapResult == null && snapToAlignment != SNAP_ALIGNMENT_DISABLED) {
949945
if (snapInterval > 0) {
950946
val ratio = targetOffset.toDouble() / snapInterval
951947
smallerOffset =
@@ -998,7 +994,7 @@ constructor(context: Context, private val fpsListener: FpsListener? = null) :
998994
smallerOffset = max(smallerOffset, smallerChildOffset)
999995
largerOffset = min(largerOffset, largerChildOffset)
1000996
}
1001-
} else {
997+
} else if (snapResult == null) {
1002998
val interval = getSnapInterval().toDouble()
1003999
val ratio = targetOffset.toDouble() / interval
10041000
smallerOffset = (floor(ratio) * interval).toInt()
@@ -1009,7 +1005,18 @@ constructor(context: Context, private val fpsListener: FpsListener? = null) :
10091005
if (abs(targetOffset - smallerOffset) < abs(largerOffset - targetOffset)) smallerOffset
10101006
else largerOffset
10111007

1012-
if (!snapToEnd && targetOffset >= lastOffset) {
1008+
if (snapResult != null) {
1009+
if (!hasCustomizedFlingAnimator) {
1010+
when (snapResult.direction) {
1011+
ScrollSnapDirection.FORWARD ->
1012+
velocityY += ((snapResult.selectedOffset.toInt() - targetOffset) * 10.0).toInt()
1013+
ScrollSnapDirection.BACKWARD ->
1014+
velocityY -= ((targetOffset - snapResult.selectedOffset.toInt()) * 10.0).toInt()
1015+
ScrollSnapDirection.NONE -> {}
1016+
}
1017+
}
1018+
targetOffset = snapResult.targetOffset.toInt()
1019+
} else if (!snapToEnd && targetOffset >= lastOffset) {
10131020
if (scrollY >= lastOffset) {
10141021
// free scrolling
10151022
} else {

0 commit comments

Comments
 (0)