Skip to content

Commit 83910b3

Browse files
authored
feat: add ScrollViewMarker experimental component (#3674)
## Description This PR adds a PoC of `ScrollViewMarker` experimental native component. It is introduced to handle two responsibilities: 1. Configuration of `ScrollView` (in particular edge effects) I want to move these configuration options off the "screen components", and have a single component that can be used to configure the `ScrollView`. 2. Detection of `ScrollView` in native view hierarchy Currently, both we & UIKit employ very simple "first descendant chain" heuristics to detect the presence of `ScrollView` in the view hierarchy & use it as "content ScrollView". This sometimes works, but it is very easy to break by user code. Now, user will be required to wrap the `ScrollView` in our component, so this is an additional step, but it will allow us to robustly detect the presence of `ScrollView`. There is open question of what do we do, if there are multiple `ScrollViews` a user want to configure. There can be only a single "content `ScrollView`". Atm. I think that reasonable approach is to have non-required `isContentScrollView` property with `true` as default value, and if there are multiple `ScrollView` instances, we can throw an error asking to set this property to `false` for all, but the actual "content `ScrollView`". We should decide on default value of `isContentScrollView` based on what we estimate as more frequent use case. Alternatively, we can expose two components: `ScrollViewMarker` & `ContentScrollViewMarker`, which will handle that prop configuration for the user? Let's discuss below. Beside adding a component I've also implemented basic support for scroll edge effects configuration. `RNSScrollViewSeeking` protocol is introduced. Other component can implement it to be notified when `ScrollViewMarker` is attached to view hierarchy. Currently it is not used anywhere & it's shape is not final, but it serves PoC purposes. Closes <software-mansion/react-native-screens-labs#981> ## Caveats * <a84419e> Similarly to iOS, I use UIManagerListener.didMountItems to detect a moment when the view hierarchy is assembled. > [!caution] > I've tested & views are created in bottom-up order -> that means the > `ScrolLViewMarker` will usually be created BEFORE e.g. a `StackHost`, > therefore its `UIManagerListener` listener will be added first -> it > will be called before listener of `StackHost`. If for some reason order > of view creation changes, e.g. `ScrollViewMarker` will be rendered in > consecutive render, the order here will also change. ## Visual documentation N/A ## Test plan It's rather hard to test this component in isolation & somehow observe an effect in JS. If someone has an idea - let me know. Right now, I've introduced `test-svm-configures-scroll-view`, which uses `StackContainer` to make scroll edge effects visible, but mostly this test is about prepared environment for testing on native side. Especially on Android, where there are no edge effects. ## Checklist * [x] Included code example that can be used to test this change. * [x] Updated / created local changelog entries in relevant test files. * [x] For visual changes, included screenshots / GIFs / recordings documenting the change. * [x] For API changes, updated relevant public types. * [x] Ensured that CI passes
1 parent 9420a19 commit 83910b3

29 files changed

Lines changed: 751 additions & 26 deletions

FabricExample/__tests__/App.test.tsx

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@
22
* @format
33
*/
44

5-
65
import React from 'react';
76
import ReactTestRenderer from 'react-test-renderer';
87
import App from '../App';

FabricExample/ios/Podfile.lock

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2192,7 +2192,7 @@ EXTERNAL SOURCES:
21922192

21932193
SPEC CHECKSUMS:
21942194
FBLazyVector: 974207232af1c5372a56239516ccc450a6420bfd
2195-
hermes-engine: 83fbd293b074bd63933c39ddfa88ffb390a0f6c4
2195+
hermes-engine: 83594ce32ee796061eeceb760a80b9dff31adc8f
21962196
RCTDeprecation: 8ae59687fd548d481aa5ce8014ac7cd9b47e7316
21972197
RCTRequired: d711b3887891fab6a77c6b9cdc49a3d8b171e36a
21982198
RCTSwiftUI: 96986e49a4fdc2c2103929dee2641e1b57edf33d
@@ -2263,10 +2263,10 @@ SPEC CHECKSUMS:
22632263
ReactAppDependencyProvider: 625d2f6d9d5ef01acc9dfe2b5385504bbffd2ad0
22642264
ReactCodegen: 7cc1904b7db3c7b68dfd4b5424175a013051874f
22652265
ReactCommon: ac616b2f169c57d56bd4b1a02f8a2dbb0b395722
2266-
ReactNativeDependencies: a79f93bdc7f0de496dbc2aa051e665dad1763ab0
2266+
ReactNativeDependencies: 97ee09ffb32e629f30549f67a3ebbf71a4dff077
22672267
RNGestureHandler: f080747d181c86d346827ba389209e1afb528628
22682268
RNReanimated: 72296a949b2e629ed59666d445fa7ac9ab066571
2269-
RNScreens: 8c846783cda52889bbf9c69e71ff0f2ed1187743
2269+
RNScreens: 3475d50b4a24a1412586be0d37876f7443ef1730
22702270
RNWorklets: ec060e76f9d7b7786d83a233e310b2ec778fb3eb
22712271
Yoga: b8206e6746bd28c028572774cece66d5348240c1
22722272

FabricExample/metro.config.js

Lines changed: 36 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,8 @@ const { getDefaultConfig, mergeConfig } = require('@react-native/metro-config');
99

1010
const fs = require('fs');
1111
const path = require('path');
12-
const exclusionList = require('metro-config/private/defaults/exclusionList').default;
12+
const exclusionList =
13+
require('metro-config/private/defaults/exclusionList').default;
1314
const escape = require('escape-string-regexp');
1415

1516
const libPackage = require('../package.json');
@@ -21,9 +22,11 @@ const appPackage = require('./package.json');
2122
* in `react-navigation` submodule & causes runtime issues.
2223
*/
2324
function reactNavigationOptionalModuleFilter(module) {
24-
return module in appPackage.dependencies === true ||
25+
return (
26+
module in appPackage.dependencies === true ||
2527
module in libPackage.devDependencies === true ||
26-
module in libPackage.dependencies === true;
28+
module in libPackage.dependencies === true
29+
);
2730
}
2831

2932
/**
@@ -32,8 +35,7 @@ function reactNavigationOptionalModuleFilter(module) {
3235
*/
3336
function blockListProvider(modules, nodeModulesDir) {
3437
return modules.map(
35-
m =>
36-
new RegExp(`^${escape(path.join(nodeModulesDir, m))}\\/.*$`),
38+
m => new RegExp(`^${escape(path.join(nodeModulesDir, m))}\\/.*$`),
3739
);
3840
}
3941

@@ -55,7 +57,6 @@ const modules = [
5557
...Object.keys(libPackage.peerDependencies),
5658
];
5759

58-
5960
// Currently each `@react-navigation` package has `src/index.tsx`.
6061
const reactNavigationIndexExts = ['tsx', 'ts', 'js', 'jsx'];
6162

@@ -66,14 +67,18 @@ const reactNavigationDuplicatedModules = [
6667
'react-native',
6768
'react-native-screens',
6869
'react-dom', // TODO: Consider whether this won't conflict, especially that RN 78 uses React 19 & react-navigation still uses React 18.
69-
].concat([
70-
'react-native-safe-area-context',
71-
'react-native-gesture-handler',
72-
].filter(reactNavigationOptionalModuleFilter));
70+
].concat(
71+
['react-native-safe-area-context', 'react-native-gesture-handler'].filter(
72+
reactNavigationOptionalModuleFilter,
73+
),
74+
);
7375

7476
const appNodeModules = path.join(appDir, 'node_modules');
7577
const libNodeModules = path.join(libRootDir, 'node_modules');
76-
const reactNavigationNodeModules = path.join(reactNavigationDir, 'node_modules');
78+
const reactNavigationNodeModules = path.join(
79+
reactNavigationDir,
80+
'node_modules',
81+
);
7782

7883
const config = {
7984
projectRoot: appDir,
@@ -84,7 +89,14 @@ const config = {
8489
resolver: {
8590
resolverMainFields: ['react-native', 'browser', 'main'],
8691

87-
blockList: exclusionList(blockListProvider(modules, libNodeModules).concat(blockListProvider(reactNavigationDuplicatedModules, reactNavigationNodeModules))),
92+
blockList: exclusionList(
93+
blockListProvider(modules, libNodeModules).concat(
94+
blockListProvider(
95+
reactNavigationDuplicatedModules,
96+
reactNavigationNodeModules,
97+
),
98+
),
99+
),
88100

89101
extraNodeModules: modules.reduce((acc, name) => {
90102
acc[name] = path.join(__dirname, 'node_modules', name);
@@ -108,15 +120,25 @@ const config = {
108120
// Project node modules + directory where `react-native-screens` repo lives in + react navigation node modules.
109121
// These are consulted in order of definition.
110122
// TODO: make it so this does not depend on whether the user renamed the repo or not...
111-
nodeModulesPaths: [appNodeModules, path.join(appDir, '../../'), libNodeModules, reactNavigationNodeModules],
123+
nodeModulesPaths: [
124+
appNodeModules,
125+
path.join(appDir, '../../'),
126+
libNodeModules,
127+
reactNavigationNodeModules,
128+
],
112129

113130
resolveRequest: (context, moduleName, platform) => {
114131
// We want to enforce that in case of react navigation the `src` files
115132
// are transformed & bundled instead of the pretransformed ones in `@react-navigation/xxx/lib` directory.
116133
if (moduleName.startsWith('@react-navigation/')) {
117134
for (const fileExt of reactNavigationIndexExts) {
118135
// App node modules contain symlink to react-navigation submodule.
119-
const moduleEntryPoint = path.join(appNodeModules, moduleName, 'src', `index.${fileExt}`);
136+
const moduleEntryPoint = path.join(
137+
appNodeModules,
138+
moduleName,
139+
'src',
140+
`index.${fileExt}`,
141+
);
120142
if (fs.existsSync(moduleEntryPoint)) {
121143
return {
122144
filePath: moduleEntryPoint,
@@ -146,4 +168,3 @@ const config = {
146168
};
147169

148170
module.exports = mergeConfig(getDefaultConfig(__dirname), config);
149-

android/src/main/java/com/swmansion/rnscreens/RNScreensPackage.kt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import com.facebook.react.module.annotations.ReactModuleList
77
import com.facebook.react.module.model.ReactModuleInfo
88
import com.facebook.react.module.model.ReactModuleInfoProvider
99
import com.facebook.react.uimanager.ViewManager
10+
import com.swmansion.rnscreens.gamma.scrollviewmarker.ScrollViewMarkerViewManager
1011
import com.swmansion.rnscreens.gamma.stack.host.StackHostViewManager
1112
import com.swmansion.rnscreens.gamma.stack.screen.StackScreenViewManager
1213
import com.swmansion.rnscreens.gamma.tabs.host.TabsHostViewManager
@@ -55,6 +56,7 @@ class RNScreensPackage : BaseReactPackage() {
5556
SafeAreaViewManager(),
5657
StackHostViewManager(),
5758
StackScreenViewManager(),
59+
ScrollViewMarkerViewManager(),
5860
)
5961
}
6062

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
package com.swmansion.rnscreens.gamma.helpers
2+
3+
import com.facebook.react.bridge.UIManager
4+
import com.facebook.react.uimanager.ThemedReactContext
5+
import com.facebook.react.uimanager.UIManagerHelper
6+
import com.facebook.react.uimanager.common.UIManagerType
7+
8+
internal fun UIManagerHelper.getFabricUIManagerNotNull(reactContext: ThemedReactContext): UIManager =
9+
checkNotNull(this.getUIManager(reactContext, UIManagerType.FABRIC)) {
10+
"[RNScreens] UIManager must not be null"
11+
}
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
package com.swmansion.rnscreens.gamma.scrollviewmarker
2+
3+
import android.annotation.SuppressLint
4+
import android.view.ViewGroup
5+
import android.widget.ScrollView
6+
import androidx.core.view.children
7+
import androidx.core.widget.NestedScrollView
8+
import com.facebook.react.bridge.UIManager
9+
import com.facebook.react.bridge.UIManagerListener
10+
import com.facebook.react.common.annotations.UnstableReactNativeAPI
11+
import com.facebook.react.uimanager.ThemedReactContext
12+
import com.facebook.react.uimanager.UIManagerHelper
13+
import com.facebook.react.views.view.ReactViewGroup
14+
import com.swmansion.rnscreens.gamma.helpers.getFabricUIManagerNotNull
15+
16+
@OptIn(UnstableReactNativeAPI::class)
17+
@SuppressLint("ViewConstructor") // Should never be inflated / restored
18+
class ScrollViewMarker(
19+
private val reactContext: ThemedReactContext,
20+
) : ReactViewGroup(reactContext),
21+
UIManagerListener {
22+
init {
23+
// We're adding ourselves during a batch, therefore we expect to receive its finalization callbacks
24+
UIManagerHelper.getFabricUIManagerNotNull(reactContext).addUIManagerEventListener(this)
25+
}
26+
27+
private var hasAttemptedRegistration: Boolean = false
28+
29+
/**
30+
* Currently we discover only ScrollView or NestedScrollView.
31+
* It'll crash in case scroll view detection fails.
32+
*
33+
* Call it only after the children have been already attached and not yet detached.
34+
*
35+
* This method intentionally ignores horizontal scroll views - we don't have support for "edge effects"
36+
* on Android nor we have any other use for them.
37+
*/
38+
private fun findScrollView(): ViewGroup {
39+
val childScrollView =
40+
checkNotNull(children.find { childView -> childView is ScrollView || childView is NestedScrollView }) {
41+
"[RNScreens] Failed to find supported type of ScrollView in children of ScrollViewMarker"
42+
}
43+
44+
return childScrollView as ViewGroup
45+
}
46+
47+
private fun findFirstSeekingAncestor(): ScrollViewSeeking? {
48+
var currentView = parent
49+
50+
while (currentView != null) {
51+
if (currentView is ScrollViewSeeking) {
52+
return currentView
53+
}
54+
currentView = currentView.parent
55+
}
56+
57+
return null
58+
}
59+
60+
private fun registerWithSeekingAncestor() {
61+
val scrollView = findScrollView()
62+
findFirstSeekingAncestor()?.registerScrollView(this, scrollView)
63+
}
64+
65+
private fun maybeRegisterWithSeekingAncestor() {
66+
if (hasAttemptedRegistration) {
67+
return
68+
}
69+
70+
registerWithSeekingAncestor()
71+
hasAttemptedRegistration = true
72+
}
73+
74+
// UIManagerListener
75+
76+
override fun didMountItems(uiManager: UIManager) {
77+
maybeRegisterWithSeekingAncestor()
78+
}
79+
80+
override fun willDispatchViewUpdates(uiManager: UIManager) = Unit
81+
82+
override fun willMountItems(uiManager: UIManager) = Unit
83+
84+
override fun didDispatchMountItems(uiManager: UIManager) = Unit
85+
86+
override fun didScheduleMountItems(uiManager: UIManager) = Unit
87+
88+
internal fun onViewManagerDropViewInstance() {
89+
UIManagerHelper.getFabricUIManagerNotNull(reactContext).removeUIManagerEventListener(this)
90+
}
91+
}
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
package com.swmansion.rnscreens.gamma.scrollviewmarker
2+
3+
import com.facebook.react.module.annotations.ReactModule
4+
import com.facebook.react.uimanager.ThemedReactContext
5+
import com.facebook.react.uimanager.ViewGroupManager
6+
import com.facebook.react.uimanager.ViewManagerDelegate
7+
import com.facebook.react.viewmanagers.RNSScrollViewMarkerManagerDelegate
8+
import com.facebook.react.viewmanagers.RNSScrollViewMarkerManagerInterface
9+
10+
@ReactModule(name = ScrollViewMarkerViewManager.REACT_CLASS)
11+
class ScrollViewMarkerViewManager :
12+
ViewGroupManager<ScrollViewMarker>(),
13+
RNSScrollViewMarkerManagerInterface<ScrollViewMarker> {
14+
private val delegate: ViewManagerDelegate<ScrollViewMarker> =
15+
RNSScrollViewMarkerManagerDelegate<ScrollViewMarker, ScrollViewMarkerViewManager>(this)
16+
17+
override fun getName() = REACT_CLASS
18+
19+
override fun getDelegate() = delegate
20+
21+
override fun createViewInstance(reactContext: ThemedReactContext): ScrollViewMarker = ScrollViewMarker(reactContext)
22+
23+
override fun onDropViewInstance(view: ScrollViewMarker) {
24+
super.onDropViewInstance(view)
25+
view.onViewManagerDropViewInstance()
26+
}
27+
28+
// iOS only
29+
override fun setLeftScrollEdgeEffect(
30+
view: ScrollViewMarker?,
31+
value: String?,
32+
) = Unit
33+
34+
// iOS only
35+
override fun setTopScrollEdgeEffect(
36+
view: ScrollViewMarker?,
37+
value: String?,
38+
) = Unit
39+
40+
// iOS only
41+
override fun setRightScrollEdgeEffect(
42+
view: ScrollViewMarker?,
43+
value: String?,
44+
) = Unit
45+
46+
// iOS only
47+
override fun setBottomScrollEdgeEffect(
48+
view: ScrollViewMarker?,
49+
value: String?,
50+
) = Unit
51+
52+
companion object {
53+
const val REACT_CLASS = "RNSScrollViewMarker"
54+
}
55+
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
package com.swmansion.rnscreens.gamma.scrollviewmarker
2+
3+
import android.view.ViewGroup
4+
5+
internal interface ScrollViewSeeking {
6+
// scrollView is a ViewGroup, because there is no universal ScrollView component on Android.
7+
// It might a be ScrollView, NestedScrollView (different inheritance hierarchies) or any other
8+
// ViewGroup that implements appropriate scrolling interfaces.
9+
fun registerScrollView(
10+
marker: ScrollViewMarker,
11+
scrollView: ViewGroup,
12+
)
13+
}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import Colors from '../styling/Colors';
2+
3+
let GLOBAL_NEXT_COLOR_ID = 0;
4+
5+
export function generateNextColor() {
6+
const colors = [
7+
Colors.BlueDark100,
8+
Colors.GreenDark100,
9+
Colors.RedDark100,
10+
Colors.YellowDark100,
11+
Colors.PurpleDark100,
12+
Colors.BlueLight100,
13+
Colors.GreenLight100,
14+
Colors.RedLight100,
15+
Colors.YellowLight100,
16+
Colors.PurpleLight100,
17+
];
18+
const index = GLOBAL_NEXT_COLOR_ID;
19+
GLOBAL_NEXT_COLOR_ID += 1;
20+
return colors[index % colors.length];
21+
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import { ScenarioGroup } from '../../shared/helpers';
2+
import TestSvmConfiguresScrollView from './test-svm-configures-scroll-view';
3+
4+
const ScrollViewMarkerScenarioGroup: ScenarioGroup = {
5+
name: 'ScrollViewMarker scenarios',
6+
details: 'Scenarios related to ScrollViewMarker component',
7+
scenarios: [TestSvmConfiguresScrollView],
8+
};
9+
10+
export default ScrollViewMarkerScenarioGroup;

0 commit comments

Comments
 (0)