diff --git a/FabricExample/__tests__/App.test.tsx b/FabricExample/__tests__/App.test.tsx index 5e1206bdaa..e532f701ee 100644 --- a/FabricExample/__tests__/App.test.tsx +++ b/FabricExample/__tests__/App.test.tsx @@ -2,7 +2,6 @@ * @format */ - import React from 'react'; import ReactTestRenderer from 'react-test-renderer'; import App from '../App'; diff --git a/FabricExample/ios/Podfile.lock b/FabricExample/ios/Podfile.lock index 0e5850a1aa..fd0986e222 100644 --- a/FabricExample/ios/Podfile.lock +++ b/FabricExample/ios/Podfile.lock @@ -2192,7 +2192,7 @@ EXTERNAL SOURCES: SPEC CHECKSUMS: FBLazyVector: 974207232af1c5372a56239516ccc450a6420bfd - hermes-engine: ea345dc52e1a9b901a2b2e66be524a973154ff61 + hermes-engine: 83594ce32ee796061eeceb760a80b9dff31adc8f RCTDeprecation: 8ae59687fd548d481aa5ce8014ac7cd9b47e7316 RCTRequired: d711b3887891fab6a77c6b9cdc49a3d8b171e36a RCTSwiftUI: 96986e49a4fdc2c2103929dee2641e1b57edf33d @@ -2263,10 +2263,10 @@ SPEC CHECKSUMS: ReactAppDependencyProvider: 625d2f6d9d5ef01acc9dfe2b5385504bbffd2ad0 ReactCodegen: 7cc1904b7db3c7b68dfd4b5424175a013051874f ReactCommon: ac616b2f169c57d56bd4b1a02f8a2dbb0b395722 - ReactNativeDependencies: 8b4dd00142a189920d68b5bb85c46506eff6d39b + ReactNativeDependencies: 97ee09ffb32e629f30549f67a3ebbf71a4dff077 RNGestureHandler: f080747d181c86d346827ba389209e1afb528628 RNReanimated: 72296a949b2e629ed59666d445fa7ac9ab066571 - RNScreens: 3a7ff46e00609d263758d646c1e6baad39921a53 + RNScreens: 8fc4bf3a466b750a19850eb301d2290733bb703e RNWorklets: ec060e76f9d7b7786d83a233e310b2ec778fb3eb Yoga: b8206e6746bd28c028572774cece66d5348240c1 diff --git a/FabricExample/metro.config.js b/FabricExample/metro.config.js index 25b485a451..1b1165caa7 100644 --- a/FabricExample/metro.config.js +++ b/FabricExample/metro.config.js @@ -9,7 +9,8 @@ const { getDefaultConfig, mergeConfig } = require('@react-native/metro-config'); const fs = require('fs'); const path = require('path'); -const exclusionList = require('metro-config/private/defaults/exclusionList').default; +const exclusionList = + require('metro-config/private/defaults/exclusionList').default; const escape = require('escape-string-regexp'); const libPackage = require('../package.json'); @@ -21,9 +22,11 @@ const appPackage = require('./package.json'); * in `react-navigation` submodule & causes runtime issues. */ function reactNavigationOptionalModuleFilter(module) { - return module in appPackage.dependencies === true || + return ( + module in appPackage.dependencies === true || module in libPackage.devDependencies === true || - module in libPackage.dependencies === true; + module in libPackage.dependencies === true + ); } /** @@ -32,8 +35,7 @@ function reactNavigationOptionalModuleFilter(module) { */ function blockListProvider(modules, nodeModulesDir) { return modules.map( - m => - new RegExp(`^${escape(path.join(nodeModulesDir, m))}\\/.*$`), + m => new RegExp(`^${escape(path.join(nodeModulesDir, m))}\\/.*$`), ); } @@ -55,7 +57,6 @@ const modules = [ ...Object.keys(libPackage.peerDependencies), ]; - // Currently each `@react-navigation` package has `src/index.tsx`. const reactNavigationIndexExts = ['tsx', 'ts', 'js', 'jsx']; @@ -66,14 +67,18 @@ const reactNavigationDuplicatedModules = [ 'react-native', 'react-native-screens', 'react-dom', // TODO: Consider whether this won't conflict, especially that RN 78 uses React 19 & react-navigation still uses React 18. -].concat([ - 'react-native-safe-area-context', - 'react-native-gesture-handler', -].filter(reactNavigationOptionalModuleFilter)); +].concat( + ['react-native-safe-area-context', 'react-native-gesture-handler'].filter( + reactNavigationOptionalModuleFilter, + ), +); const appNodeModules = path.join(appDir, 'node_modules'); const libNodeModules = path.join(libRootDir, 'node_modules'); -const reactNavigationNodeModules = path.join(reactNavigationDir, 'node_modules'); +const reactNavigationNodeModules = path.join( + reactNavigationDir, + 'node_modules', +); const config = { projectRoot: appDir, @@ -84,7 +89,14 @@ const config = { resolver: { resolverMainFields: ['react-native', 'browser', 'main'], - blockList: exclusionList(blockListProvider(modules, libNodeModules).concat(blockListProvider(reactNavigationDuplicatedModules, reactNavigationNodeModules))), + blockList: exclusionList( + blockListProvider(modules, libNodeModules).concat( + blockListProvider( + reactNavigationDuplicatedModules, + reactNavigationNodeModules, + ), + ), + ), extraNodeModules: modules.reduce((acc, name) => { acc[name] = path.join(__dirname, 'node_modules', name); @@ -108,7 +120,12 @@ const config = { // Project node modules + directory where `react-native-screens` repo lives in + react navigation node modules. // These are consulted in order of definition. // TODO: make it so this does not depend on whether the user renamed the repo or not... - nodeModulesPaths: [appNodeModules, path.join(appDir, '../../'), libNodeModules, reactNavigationNodeModules], + nodeModulesPaths: [ + appNodeModules, + path.join(appDir, '../../'), + libNodeModules, + reactNavigationNodeModules, + ], resolveRequest: (context, moduleName, platform) => { // We want to enforce that in case of react navigation the `src` files @@ -116,7 +133,12 @@ const config = { if (moduleName.startsWith('@react-navigation/')) { for (const fileExt of reactNavigationIndexExts) { // App node modules contain symlink to react-navigation submodule. - const moduleEntryPoint = path.join(appNodeModules, moduleName, 'src', `index.${fileExt}`); + const moduleEntryPoint = path.join( + appNodeModules, + moduleName, + 'src', + `index.${fileExt}`, + ); if (fs.existsSync(moduleEntryPoint)) { return { filePath: moduleEntryPoint, @@ -146,4 +168,3 @@ const config = { }; module.exports = mergeConfig(getDefaultConfig(__dirname), config); - diff --git a/android/src/main/java/com/swmansion/rnscreens/RNScreensPackage.kt b/android/src/main/java/com/swmansion/rnscreens/RNScreensPackage.kt index 5ed5735171..f772a53d40 100644 --- a/android/src/main/java/com/swmansion/rnscreens/RNScreensPackage.kt +++ b/android/src/main/java/com/swmansion/rnscreens/RNScreensPackage.kt @@ -7,6 +7,7 @@ import com.facebook.react.module.annotations.ReactModuleList import com.facebook.react.module.model.ReactModuleInfo import com.facebook.react.module.model.ReactModuleInfoProvider import com.facebook.react.uimanager.ViewManager +import com.swmansion.rnscreens.gamma.scrollviewmarker.ScrollViewMarkerViewManager import com.swmansion.rnscreens.gamma.stack.host.StackHostViewManager import com.swmansion.rnscreens.gamma.stack.screen.StackScreenViewManager import com.swmansion.rnscreens.gamma.tabs.TabsHostViewManager @@ -55,6 +56,7 @@ class RNScreensPackage : BaseReactPackage() { SafeAreaViewManager(), StackHostViewManager(), StackScreenViewManager(), + ScrollViewMarkerViewManager(), ) } diff --git a/android/src/main/java/com/swmansion/rnscreens/gamma/helpers/UIManagerHelperExt.kt b/android/src/main/java/com/swmansion/rnscreens/gamma/helpers/UIManagerHelperExt.kt new file mode 100644 index 0000000000..c67502bfe0 --- /dev/null +++ b/android/src/main/java/com/swmansion/rnscreens/gamma/helpers/UIManagerHelperExt.kt @@ -0,0 +1,11 @@ +package com.swmansion.rnscreens.gamma.helpers + +import com.facebook.react.bridge.UIManager +import com.facebook.react.uimanager.ThemedReactContext +import com.facebook.react.uimanager.UIManagerHelper +import com.facebook.react.uimanager.common.UIManagerType + +internal fun UIManagerHelper.getFabricUIManagerNotNull(reactContext: ThemedReactContext): UIManager = + checkNotNull(this.getUIManager(reactContext, UIManagerType.FABRIC)) { + "[RNScreens] UIManager must not be null" + } diff --git a/android/src/main/java/com/swmansion/rnscreens/gamma/scrollviewmarker/ScrollViewMarker.kt b/android/src/main/java/com/swmansion/rnscreens/gamma/scrollviewmarker/ScrollViewMarker.kt new file mode 100644 index 0000000000..d285cdea93 --- /dev/null +++ b/android/src/main/java/com/swmansion/rnscreens/gamma/scrollviewmarker/ScrollViewMarker.kt @@ -0,0 +1,88 @@ +package com.swmansion.rnscreens.gamma.scrollviewmarker + +import android.annotation.SuppressLint +import android.view.ViewGroup +import android.widget.ScrollView +import androidx.core.view.children +import androidx.core.widget.NestedScrollView +import com.facebook.react.bridge.UIManager +import com.facebook.react.bridge.UIManagerListener +import com.facebook.react.common.annotations.UnstableReactNativeAPI +import com.facebook.react.uimanager.ThemedReactContext +import com.facebook.react.uimanager.UIManagerHelper +import com.facebook.react.views.view.ReactViewGroup +import com.swmansion.rnscreens.gamma.helpers.getFabricUIManagerNotNull + +@OptIn(UnstableReactNativeAPI::class) +@SuppressLint("ViewConstructor") // Should never be inflated / restored +class ScrollViewMarker( + private val reactContext: ThemedReactContext, +) : ReactViewGroup(reactContext), + UIManagerListener { + init { + // We're adding ourselves during a batch, therefore we expect to receive its finalization callbacks + UIManagerHelper.getFabricUIManagerNotNull(reactContext).addUIManagerEventListener(this) + } + + private var hasAttemptedRegistration: Boolean = false + + /** + * Currently we discover only ScrollView or NestedScrollView. + * It'll crash in case scroll view detection fails. + * + * Call it only after the children have been already attached and not yet detached. + */ + private fun findScrollView(): ViewGroup { + val childScrollView = + checkNotNull(children.find { childView -> childView is ScrollView || childView is NestedScrollView }) { + "[RNScreens] Failed to find supported type of ScrollView in children of ScrollViewMarker" + } + + return childScrollView as ViewGroup + } + + private fun findFirstSeekingAncestor(): ScrollViewSeeking? { + var currentView = parent + + while (currentView != null) { + if (currentView is ScrollViewSeeking) { + return currentView + } + currentView = currentView.parent + } + + return null + } + + private fun registerWithSeekingAncestor() { + val scrollView = findScrollView() + findFirstSeekingAncestor()?.registerScrollView(this, scrollView) + } + + private fun maybeRegisterWithSeekingAncestor() { + if (hasAttemptedRegistration) { + return + } + + registerWithSeekingAncestor() + hasAttemptedRegistration = true + } + + // UIManagerListener + + override fun didMountItems(uiManager: UIManager) { + maybeRegisterWithSeekingAncestor() + } + + override fun willDispatchViewUpdates(uiManager: UIManager) = Unit + + override fun willMountItems(uiManager: UIManager) = Unit + + override fun didDispatchMountItems(uiManager: UIManager) = Unit + + override fun didScheduleMountItems(uiManager: UIManager) = Unit + + internal fun onViewManagerDropViewInstance() { + UIManagerHelper.getFabricUIManagerNotNull(reactContext).removeUIManagerEventListener(this) + } +} diff --git a/android/src/main/java/com/swmansion/rnscreens/gamma/scrollviewmarker/ScrollViewMarkerViewManager.kt b/android/src/main/java/com/swmansion/rnscreens/gamma/scrollviewmarker/ScrollViewMarkerViewManager.kt new file mode 100644 index 0000000000..ba376f8dd5 --- /dev/null +++ b/android/src/main/java/com/swmansion/rnscreens/gamma/scrollviewmarker/ScrollViewMarkerViewManager.kt @@ -0,0 +1,55 @@ +package com.swmansion.rnscreens.gamma.scrollviewmarker + +import com.facebook.react.module.annotations.ReactModule +import com.facebook.react.uimanager.ThemedReactContext +import com.facebook.react.uimanager.ViewGroupManager +import com.facebook.react.uimanager.ViewManagerDelegate +import com.facebook.react.viewmanagers.RNSScrollViewMarkerManagerDelegate +import com.facebook.react.viewmanagers.RNSScrollViewMarkerManagerInterface + +@ReactModule(name = ScrollViewMarkerViewManager.REACT_CLASS) +class ScrollViewMarkerViewManager : + ViewGroupManager(), + RNSScrollViewMarkerManagerInterface { + private val delegate: ViewManagerDelegate = + RNSScrollViewMarkerManagerDelegate(this) + + override fun getName() = REACT_CLASS + + override fun getDelegate() = delegate + + override fun createViewInstance(reactContext: ThemedReactContext): ScrollViewMarker = ScrollViewMarker(reactContext) + + override fun onDropViewInstance(view: ScrollViewMarker) { + super.onDropViewInstance(view) + view.onViewManagerDropViewInstance() + } + + // iOS only + override fun setLeftScrollEdgeEffect( + view: ScrollViewMarker?, + value: String?, + ) = Unit + + // iOS only + override fun setTopScrollEdgeEffect( + view: ScrollViewMarker?, + value: String?, + ) = Unit + + // iOS only + override fun setRightScrollEdgeEffect( + view: ScrollViewMarker?, + value: String?, + ) = Unit + + // iOS only + override fun setBottomScrollEdgeEffect( + view: ScrollViewMarker?, + value: String?, + ) = Unit + + companion object { + const val REACT_CLASS = "RNSScrollViewMarker" + } +} diff --git a/android/src/main/java/com/swmansion/rnscreens/gamma/scrollviewmarker/ScrollViewSeeking.kt b/android/src/main/java/com/swmansion/rnscreens/gamma/scrollviewmarker/ScrollViewSeeking.kt new file mode 100644 index 0000000000..52667d366d --- /dev/null +++ b/android/src/main/java/com/swmansion/rnscreens/gamma/scrollviewmarker/ScrollViewSeeking.kt @@ -0,0 +1,13 @@ +package com.swmansion.rnscreens.gamma.scrollviewmarker + +import android.view.ViewGroup + +internal interface ScrollViewSeeking { + // scrollView is a ViewGroup, because there is no universal ScrollView component on Android. + // It might a be ScrollView, NestedScrollView (different inheritance hierarchies) or any other + // ViewGroup that implements appropriate scrolling interfaces. + fun registerScrollView( + marker: ScrollViewMarker, + scrollView: ViewGroup, + ) +} diff --git a/apps/src/shared/utils/color-generator.ts b/apps/src/shared/utils/color-generator.ts new file mode 100644 index 0000000000..c7058851f7 --- /dev/null +++ b/apps/src/shared/utils/color-generator.ts @@ -0,0 +1,21 @@ +import Colors from '../styling/Colors'; + +let GLOBAL_NEXT_COLOR_ID = 0; + +export function generateNextColor() { + const colors = [ + Colors.BlueDark100, + Colors.GreenDark100, + Colors.RedDark100, + Colors.YellowDark100, + Colors.PurpleDark100, + Colors.BlueLight100, + Colors.GreenLight100, + Colors.RedLight100, + Colors.YellowLight100, + Colors.PurpleLight100, + ]; + const index = GLOBAL_NEXT_COLOR_ID; + GLOBAL_NEXT_COLOR_ID += 1; + return colors[index % colors.length]; +} diff --git a/apps/src/tests/single-feature-tests/scroll-view-marker/index.ts b/apps/src/tests/single-feature-tests/scroll-view-marker/index.ts new file mode 100644 index 0000000000..6505fe62e0 --- /dev/null +++ b/apps/src/tests/single-feature-tests/scroll-view-marker/index.ts @@ -0,0 +1,10 @@ +import { ScenarioGroup } from '../../shared/helpers'; +import TestSvmConfiguresScrollView from './test-svm-configures-scroll-view'; + +const ScrollViewMarkerScenarioGroup: ScenarioGroup = { + name: 'ScrollViewMarker scenarios', + details: 'Scenarios related to ScrollViewMarker component', + scenarios: [TestSvmConfiguresScrollView], +}; + +export default ScrollViewMarkerScenarioGroup; diff --git a/apps/src/tests/single-feature-tests/scroll-view-marker/test-svm-configures-scroll-view.tsx b/apps/src/tests/single-feature-tests/scroll-view-marker/test-svm-configures-scroll-view.tsx new file mode 100644 index 0000000000..77249968c7 --- /dev/null +++ b/apps/src/tests/single-feature-tests/scroll-view-marker/test-svm-configures-scroll-view.tsx @@ -0,0 +1,80 @@ +import React from 'react'; +import { Scenario } from '../../shared/helpers'; +import { ScrollView, StyleSheet, Text, View } from 'react-native'; +import { ScrollViewMarker } from 'react-native-screens/experimental'; +import { StackContainer } from '../../../shared/gamma/containers/stack'; +import { Rectangle } from '../../../shared/Rectangle'; +import Colors from '../../../shared/styling/Colors'; +import { generateNextColor } from '../../../shared/utils/color-generator'; + +const SCENARIO: Scenario = { + name: 'Basic functionality', + key: 'test-svm-configures-scroll-view', + details: + 'Allows to test the basic functionality of ScrollViewMarker component. ' + + 'It utilizes the StackContainer, to allow for observation of edge effects ' + + 'applied to the container edges. On Android this test serves only as a setup ' + + 'for native debugging.', + platforms: ['ios', 'android'], + AppComponent: App, +}; + +export default SCENARIO; + +export function App() { + return ( + + ); +} + +function ContentScreen() { + return ( + + Interrupt "first descendant chain" heuristic + + + {Array.from({ length: 12 }).map((_, index) => { + return ( + + ); + })} + + + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + }, + fillParent: { + flex: 1, + width: '100%', + height: '100%', + }, +}); diff --git a/ios/conversion/RNSConversions-ScrollViewMarker.h b/ios/conversion/RNSConversions-ScrollViewMarker.h new file mode 100644 index 0000000000..2d043c6b07 --- /dev/null +++ b/ios/conversion/RNSConversions-ScrollViewMarker.h @@ -0,0 +1,26 @@ +#pragma once + +#if defined(__cplusplus) && RCT_NEW_ARCH_ENABLED && RNS_GAMMA_ENABLED + +#import +#import "RNSEnums.h" + +namespace rnscreens::conversion { + +namespace react = facebook::react; + +RNSScrollEdgeEffect RNSScrollEdgeEffectFromSVMLeftEdgeEffect( + react::RNSScrollViewMarkerLeftScrollEdgeEffect edgeEffect); + +RNSScrollEdgeEffect RNSScrollEdgeEffectFromSVMTopEdgeEffect( + react::RNSScrollViewMarkerTopScrollEdgeEffect edgeEffect); + +RNSScrollEdgeEffect RNSScrollEdgeEffectFromSVMRightEdgeEffect( + react::RNSScrollViewMarkerRightScrollEdgeEffect edgeEffect); + +RNSScrollEdgeEffect RNSScrollEdgeEffectFromSVMBottomEdgeEffect( + react::RNSScrollViewMarkerBottomScrollEdgeEffect edgeEffect); + +}; // namespace rnscreens::conversion + +#endif // defined(__cplusplus) && RCT_NEW_ARCH_ENABLED && RNS_GAMMA_ENABLED diff --git a/ios/conversion/RNSConversions-ScrollViewMarker.mm b/ios/conversion/RNSConversions-ScrollViewMarker.mm new file mode 100644 index 0000000000..ac8e5f5f62 --- /dev/null +++ b/ios/conversion/RNSConversions-ScrollViewMarker.mm @@ -0,0 +1,44 @@ +#import "RNSConversions-ScrollViewMarker.h" + +#if RCT_NEW_ARCH_ENABLED && RNS_GAMMA_ENABLED + +#import + +namespace react = facebook::react; + +#define SWITCH_EDGE_EFFECT(X) \ + switch (edgeEffect) { \ + using enum react::X; \ + case Automatic: \ + return RNSScrollEdgeEffectAutomatic; \ + case Hard: \ + return RNSScrollEdgeEffectHard; \ + case Soft: \ + return RNSScrollEdgeEffectSoft; \ + case Hidden: \ + return RNSScrollEdgeEffectHidden; \ + default: \ + RCTLogError(@"[RNScreens] unsupported edge effect"); \ + return RNSScrollEdgeEffectAutomatic; \ + } + +#define EDGE_EFFECT_CONV_FUNC_IMPL(direction) \ + RNSScrollEdgeEffect RNSScrollEdgeEffectFromSVM##direction##EdgeEffect( \ + react::RNSScrollViewMarker##direction##ScrollEdgeEffect edgeEffect) \ + { \ + SWITCH_EDGE_EFFECT(RNSScrollViewMarker##direction##ScrollEdgeEffect); \ + } + +namespace rnscreens::conversion { + +EDGE_EFFECT_CONV_FUNC_IMPL(Left); +EDGE_EFFECT_CONV_FUNC_IMPL(Top); +EDGE_EFFECT_CONV_FUNC_IMPL(Right); +EDGE_EFFECT_CONV_FUNC_IMPL(Bottom); + +}; // namespace rnscreens::conversion + +#undef EDGE_EFFECT_CONV_FUNC_IMPL +#undef SWITCH_EDGE_EFFECT + +#endif // RCT_NEW_ARCH_ENABLED && RNS_GAMMA_ENABLED diff --git a/ios/conversion/RNSConversions-Stack.h b/ios/conversion/RNSConversions-Stack.h index 96030f021a..c13f960808 100644 --- a/ios/conversion/RNSConversions-Stack.h +++ b/ios/conversion/RNSConversions-Stack.h @@ -1,6 +1,6 @@ #pragma once -#if RCT_NEW_ARCH_ENABLED && RNS_GAMMA_ENABLED +#if defined(__cplusplus) && RCT_NEW_ARCH_ENABLED && RNS_GAMMA_ENABLED #import #import "RNSStackScreenComponentView.h" @@ -20,4 +20,4 @@ RNSStackScreenActivityMode convert(react::RNSStackScreenActivityMode mode); }; // namespace rnscreens::conversion -#endif // RCT_NEW_ARCH_ENABLED && RNS_GAMMA_ENABLED +#endif // defined(__cplusplus) && RCT_NEW_ARCH_ENABLED && RNS_GAMMA_ENABLED diff --git a/ios/conversion/RNSConversions.h b/ios/conversion/RNSConversions.h index 48a5345b65..8e918cfb55 100644 --- a/ios/conversion/RNSConversions.h +++ b/ios/conversion/RNSConversions.h @@ -139,10 +139,11 @@ RNSSplitViewScreenColumnType RNSSplitViewScreenColumnTypeFromScreenProp(react::R }; // namespace rnscreens::conversion -#if RCT_NEW_ARCH_ENABLED +#if RCT_NEW_ARCH_ENABLED && RNS_GAMMA_ENABLED +#import "RNSConversions-ScrollViewMarker.h" #import "RNSConversions-Stack.h" -#endif // RCT_NEW_ARCH_ENABLED +#endif // RCT_NEW_ARCH_ENABLED && RNS_GAMMA_ENABLED -#endif +#endif // defined(__cplusplus) diff --git a/ios/gamma/scroll-view-marker/RNSScrollViewMarkerComponentView.h b/ios/gamma/scroll-view-marker/RNSScrollViewMarkerComponentView.h new file mode 100644 index 0000000000..4badf56505 --- /dev/null +++ b/ios/gamma/scroll-view-marker/RNSScrollViewMarkerComponentView.h @@ -0,0 +1,11 @@ +#pragma once + +#import "RNSReactBaseView.h" + +NS_ASSUME_NONNULL_BEGIN + +@interface RNSScrollViewMarkerComponentView : RNSReactBaseView + +@end + +NS_ASSUME_NONNULL_END diff --git a/ios/gamma/scroll-view-marker/RNSScrollViewMarkerComponentView.mm b/ios/gamma/scroll-view-marker/RNSScrollViewMarkerComponentView.mm new file mode 100644 index 0000000000..a53a54940c --- /dev/null +++ b/ios/gamma/scroll-view-marker/RNSScrollViewMarkerComponentView.mm @@ -0,0 +1,262 @@ +#import "RNSScrollViewMarkerComponentView.h" +#import "RNSConversions-ScrollViewMarker.h" +#import "RNSEnums.h" +#import "RNSScrollEdgeEffectApplicator.h" +#import "RNSScrollViewSeeking.h" + +#import +#import +#import +#import + +#import +#import + +namespace react = facebook::react; + +@interface RNSScrollViewMarkerComponentView () +@end + +@implementation RNSScrollViewMarkerComponentView { + BOOL _hasAttemptedRegistration; + BOOL _needsEdgeEffectUpdate; + + RNSScrollEdgeEffect _leftScrollEdgeEffect; + RNSScrollEdgeEffect _topScrollEdgeEffect; + RNSScrollEdgeEffect _rightScrollEdgeEffect; + RNSScrollEdgeEffect _bottomScrollEdgeEffect; +} + +- (instancetype)initWithFrame:(CGRect)frame +{ + if (self = [super initWithFrame:frame]) { + [self initState]; + } + return self; +} + +#pragma mark - Private + +- (void)initState +{ + [self resetProps]; + _hasAttemptedRegistration = NO; + _needsEdgeEffectUpdate = NO; +} + +- (void)resetProps +{ + static const auto defaultProps = std::make_shared(); + _props = defaultProps; + _leftScrollEdgeEffect = RNSScrollEdgeEffectAutomatic; + _topScrollEdgeEffect = RNSScrollEdgeEffectAutomatic; + _rightScrollEdgeEffect = RNSScrollEdgeEffectAutomatic; + _bottomScrollEdgeEffect = RNSScrollEdgeEffectAutomatic; +} + +/** + * This method throws an error in debug mode in case it fails to find the ScrollView instance, + * as it does not make sense to use this component if the ScrollView is not there. + */ +- (nullable UIScrollView *)findScrollView +{ + // It allows 0 for cases where the child is unmounted + RCTAssert( + self.subviews.count <= 1, + @"[RNScreens] ScrollViewMarker expects at most a single child. Subviews: %@", + self.subviews); + + UIScrollView *_Nullable foundScrollView = [self resolveScrollViewFromChildView:self.subviews.firstObject]; + + RCTAssert(foundScrollView != nil, @"[RNScreens] Failed to find ScrollView"); // debug assertion only + return foundScrollView; +} + +- (nullable id)findFirstSeekingAncestor +{ + const UIView *superview = self.superview; + while (superview != nil) { + if ([superview respondsToSelector:@selector(registerDescendantScrollView:fromMarker:)]) { + return static_cast>(superview); + } + superview = superview.superview; + } + return nil; +} + +- (void)maybeRegisterWithSeekingAncestor +{ + if (_hasAttemptedRegistration) { + return; + } + + [self registerWithSeekingAncestor]; + _hasAttemptedRegistration = YES; +} + +- (void)registerWithSeekingAncestor +{ + UIScrollView *scrollView = [self findScrollView]; + + if (scrollView == nil) { + return; + } + + id seekingAncestor = [self findFirstSeekingAncestor]; + + if (seekingAncestor == nil) { + return; + } + + [seekingAncestor registerDescendantScrollView:scrollView fromMarker:self]; +} + +- (void)configureScrollView:(nullable UIScrollView *)scrollView +{ + if (scrollView == nil) { + return; + } + [RNSScrollEdgeEffectApplicator applyToScrollView:scrollView withProvider:self]; +} + +/** + * Tries to resolve UIScrollView from the passed childView. + * + * Currently it supports only direct `UIScrollView` or react-native's `` component. + */ +- (nullable UIScrollView *)resolveScrollViewFromChildView:(nullable UIView *)childView +{ + if (childView == nil) { + return nil; + } + + if ([childView isKindOfClass:UIScrollView.class]) { + return static_cast(childView); + } + + if ([childView isKindOfClass:RCTScrollViewComponentView.class]) { + return static_cast(childView).scrollView; + } + + return nil; +} + +#pragma mark - Override + +// TODO: This will be way too late to configure options etc. +// Potentially we want to run in the end of transaction, before containers are updated. +- (void)willMoveToWindow:(UIWindow *)newWindow +{ + [super willMoveToWindow:newWindow]; + [self maybeRegisterWithSeekingAncestor]; +} + +#pragma mark - RNSScrollEdgeEffectProviding + +- (RNSScrollEdgeEffect)leftScrollEdgeEffect +{ + return _leftScrollEdgeEffect; +} + +- (RNSScrollEdgeEffect)topScrollEdgeEffect +{ + return _topScrollEdgeEffect; +} + +- (RNSScrollEdgeEffect)rightScrollEdgeEffect +{ + return _rightScrollEdgeEffect; +} + +- (RNSScrollEdgeEffect)bottomScrollEdgeEffect +{ + return _bottomScrollEdgeEffect; +} + +#pragma mark - RCTComponentViewProtocol + +- (void)updateProps:(const facebook::react::Props::Shared &)props + oldProps:(const facebook::react::Props::Shared &)oldProps +{ + using namespace rnscreens::conversion; + + const auto &oldComponentProps = *std::static_pointer_cast(_props); + const auto &newComponentProps = *std::static_pointer_cast(props); + + if (oldComponentProps.leftScrollEdgeEffect != newComponentProps.leftScrollEdgeEffect) { + _leftScrollEdgeEffect = RNSScrollEdgeEffectFromSVMLeftEdgeEffect(newComponentProps.leftScrollEdgeEffect); + _needsEdgeEffectUpdate = true; + } + + if (oldComponentProps.topScrollEdgeEffect != newComponentProps.topScrollEdgeEffect) { + _topScrollEdgeEffect = RNSScrollEdgeEffectFromSVMTopEdgeEffect(newComponentProps.topScrollEdgeEffect); + _needsEdgeEffectUpdate = true; + } + + if (oldComponentProps.rightScrollEdgeEffect != newComponentProps.rightScrollEdgeEffect) { + _rightScrollEdgeEffect = RNSScrollEdgeEffectFromSVMRightEdgeEffect(newComponentProps.rightScrollEdgeEffect); + _needsEdgeEffectUpdate = true; + } + + if (oldComponentProps.bottomScrollEdgeEffect != newComponentProps.bottomScrollEdgeEffect) { + _bottomScrollEdgeEffect = RNSScrollEdgeEffectFromSVMBottomEdgeEffect(newComponentProps.bottomScrollEdgeEffect); + _needsEdgeEffectUpdate = true; + } + + [super updateProps:props oldProps:oldProps]; +} + +- (void)finalizeUpdates:(RNComponentViewUpdateMask)updateMask +{ + // This assumes that in first render children are mounted before props are updated. + // Also this handles only first render & prop value update. It does not handle case + // where child scrollview potentially changes. Separate question is whether we even + // want to handle such case. + if (_needsEdgeEffectUpdate) { + _needsEdgeEffectUpdate = NO; + [self configureScrollView:[self findScrollView]]; + } + + // It allows 0 for cases where the child is unmounted + RCTAssert( + self.subviews.count <= 1, + @"[RNScreens] ScrollViewMarker expects at most a single child. Subviews: %@", + self.subviews); + + [super finalizeUpdates:updateMask]; +} + +- (void)mountChildComponentView:(UIView *)childComponentView index:(NSInteger)index +{ + [super mountChildComponentView:childComponentView index:index]; +} + +- (void)unmountChildComponentView:(UIView *)childComponentView index:(NSInteger)index +{ + [super unmountChildComponentView:childComponentView index:index]; +} + ++ (BOOL)shouldBeRecycled +{ + return NO; +} + ++ (react::ComponentDescriptorProvider)componentDescriptorProvider +{ + return react::concreteComponentDescriptorProvider(); +} + +#pragma mark - RCTMountingTransactionObserving + +- (void)mountingTransactionDidMount:(const facebook::react::MountingTransaction &)transaction + withSurfaceTelemetry:(const facebook::react::SurfaceTelemetry &)surfaceTelemetry +{ + [self maybeRegisterWithSeekingAncestor]; +} + +@end + +Class RNSScrollViewMarkerCls(void) +{ + return RNSScrollViewMarkerComponentView.class; +} diff --git a/ios/gamma/scroll-view-marker/RNSScrollViewSeeking.h b/ios/gamma/scroll-view-marker/RNSScrollViewSeeking.h new file mode 100644 index 0000000000..21413ae33f --- /dev/null +++ b/ios/gamma/scroll-view-marker/RNSScrollViewSeeking.h @@ -0,0 +1,15 @@ +#pragma once + +#import + +@class RNSScrollViewMarkerComponentView; + +@protocol RNSScrollViewSeeking + +/** + * Call this method to register a ScrollView wrapped by marker with an interested component (receiver). + */ +- (void)registerDescendantScrollView:(nonnull UIScrollView *)scrollView + fromMarker:(nonnull RNSScrollViewMarkerComponentView *)marker; + +@end diff --git a/ios/helpers/scroll-view/RNSScrollEdgeEffectApplicator.h b/ios/helpers/scroll-view/RNSScrollEdgeEffectApplicator.h index 20abb7cecc..b1c43b7d04 100644 --- a/ios/helpers/scroll-view/RNSScrollEdgeEffectApplicator.h +++ b/ios/helpers/scroll-view/RNSScrollEdgeEffectApplicator.h @@ -10,5 +10,6 @@ @end @interface RNSScrollEdgeEffectApplicator : NSObject -+ (void)applyToScrollView:(UIScrollView *)scrollView withProvider:(id)provider; ++ (void)applyToScrollView:(nonnull UIScrollView *)scrollView + withProvider:(nonnull id)provider; @end diff --git a/ios/helpers/scroll-view/RNSScrollEdgeEffectApplicator.mm b/ios/helpers/scroll-view/RNSScrollEdgeEffectApplicator.mm index 0723b64d95..c0fb1333ea 100644 --- a/ios/helpers/scroll-view/RNSScrollEdgeEffectApplicator.mm +++ b/ios/helpers/scroll-view/RNSScrollEdgeEffectApplicator.mm @@ -5,7 +5,8 @@ @implementation RNSScrollEdgeEffectApplicator -+ (void)applyToScrollView:(UIScrollView *)scrollView withProvider:(id)provider ++ (void)applyToScrollView:(nonnull UIScrollView *)scrollView + withProvider:(nonnull id)provider { #if RNS_IPHONE_OS_VERSION_AVAILABLE(26_0) if (@available(iOS 26, *)) { diff --git a/package.json b/package.json index 85253b7bae..f425d08184 100644 --- a/package.json +++ b/package.json @@ -220,6 +220,9 @@ }, "RNSSafeAreaView": { "className": "RNSSafeAreaViewComponentView" + }, + "RNSScrollViewMarker": { + "className": "RNSScrollViewMarkerComponentView" } } } diff --git a/src/components/gamma/scroll-view-marker/ScrollViewMarker.tsx b/src/components/gamma/scroll-view-marker/ScrollViewMarker.tsx new file mode 100644 index 0000000000..5ae523338a --- /dev/null +++ b/src/components/gamma/scroll-view-marker/ScrollViewMarker.tsx @@ -0,0 +1,17 @@ +import React from 'react'; +import ScrollViewMarkerNativeComponent from '../../../fabric/gamma/ScrollViewMarkerNativeComponent'; +import type { ScrollViewMarkerProps } from './ScrollViewMarker.types'; + +export default function ScrollViewMarker(props: ScrollViewMarkerProps) { + const { scrollEdgeEffects, ...rest } = props; + + return ( + + ); +} diff --git a/src/components/gamma/scroll-view-marker/ScrollViewMarker.types.ts b/src/components/gamma/scroll-view-marker/ScrollViewMarker.types.ts new file mode 100644 index 0000000000..c21afb33bf --- /dev/null +++ b/src/components/gamma/scroll-view-marker/ScrollViewMarker.types.ts @@ -0,0 +1,14 @@ +import type { ViewProps } from 'react-native'; +import type { ScrollEdgeEffect } from '../../../types'; + +export interface ScrollViewMarkerProps { + children: ViewProps['children']; + style?: ViewProps['style']; + + scrollEdgeEffects?: { + bottom?: ScrollEdgeEffect; + left?: ScrollEdgeEffect; + right?: ScrollEdgeEffect; + top?: ScrollEdgeEffect; + }; +} diff --git a/src/components/gamma/scroll-view-marker/index.ts b/src/components/gamma/scroll-view-marker/index.ts new file mode 100644 index 0000000000..c3894cfc22 --- /dev/null +++ b/src/components/gamma/scroll-view-marker/index.ts @@ -0,0 +1,3 @@ +export type * from './ScrollViewMarker.types'; + +export { default as ScrollViewMarker } from './ScrollViewMarker'; diff --git a/src/experimental/index.ts b/src/experimental/index.ts index d198829103..e740db3cea 100644 --- a/src/experimental/index.ts +++ b/src/experimental/index.ts @@ -12,3 +12,5 @@ export { default as Stack } from '../components/gamma/stack'; export { default as Split } from '../components/gamma/split'; export { default as SafeAreaView } from '../components/safe-area/SafeAreaView'; + +export { default as ScrollViewMarker } from '../components/gamma/scroll-view-marker/ScrollViewMarker'; diff --git a/src/experimental/types.ts b/src/experimental/types.ts index 95ecab0ab7..093d00641c 100644 --- a/src/experimental/types.ts +++ b/src/experimental/types.ts @@ -8,3 +8,4 @@ export * from '../components/gamma/split/SplitHost.types'; export * from '../components/gamma/split/SplitScreen.types'; export * from '../components/gamma/stack/StackScreen.types'; export * from '../components/safe-area/SafeAreaView.types'; +export type * from '../components/gamma/scroll-view-marker/ScrollViewMarker.types'; diff --git a/src/fabric/gamma/ScrollViewMarkerNativeComponent.ts b/src/fabric/gamma/ScrollViewMarkerNativeComponent.ts new file mode 100644 index 0000000000..e76a42042b --- /dev/null +++ b/src/fabric/gamma/ScrollViewMarkerNativeComponent.ts @@ -0,0 +1,15 @@ +'use client'; + +import type { CodegenTypes as CT, ViewProps } from 'react-native'; +import { codegenNativeComponent } from 'react-native'; + +type ScrollEdgeEffect = 'automatic' | 'hard' | 'soft' | 'hidden'; + +interface NativeProps extends ViewProps { + leftScrollEdgeEffect?: CT.WithDefault; + topScrollEdgeEffect?: CT.WithDefault; + rightScrollEdgeEffect?: CT.WithDefault; + bottomScrollEdgeEffect?: CT.WithDefault; +} + +export default codegenNativeComponent('RNSScrollViewMarker');