diff --git a/packages/react-native/React/Fabric/Mounting/ComponentViews/ScrollView/RCTScrollViewComponentView.mm b/packages/react-native/React/Fabric/Mounting/ComponentViews/ScrollView/RCTScrollViewComponentView.mm index 7ee29f914ff7..647dab74cb32 100644 --- a/packages/react-native/React/Fabric/Mounting/ComponentViews/ScrollView/RCTScrollViewComponentView.mm +++ b/packages/react-native/React/Fabric/Mounting/ComponentViews/ScrollView/RCTScrollViewComponentView.mm @@ -88,6 +88,39 @@ static UIScrollViewIndicatorStyle RCTUIScrollViewIndicatorStyleFromProps(const S userInfo:userInfo]; } +#if TARGET_OS_OSX // [macOS +// AppKit works out where a view's layer goes, flipped or not, when the view's frame is set. +static void RCTRelayoutLayer(RCTPlatformView *view) +{ + NSPoint origin = view.frame.origin; + [view setFrameOrigin:NSMakePoint(origin.x + 1, origin.y)]; + [view setFrameOrigin:origin]; +} + +// The document view, which counts from the bottom while the list is inverted. +@interface RCTScrollViewContainerView : RCTUIView +@property (nonatomic, assign) BOOL inverted; +@end + +@implementation RCTScrollViewContainerView + +- (BOOL)isFlipped +{ + return !_inverted; +} + +- (void)setInverted:(BOOL)inverted +{ + _inverted = inverted; + RCTRelayoutLayer(self); + for (RCTPlatformView *subview in self.subviews) { + RCTRelayoutLayer(subview); + } +} + +@end +#endif // macOS] + @interface RCTScrollViewComponentView () < #if !TARGET_OS_OSX // [macOS] UIScrollViewDelegate, @@ -108,6 +141,9 @@ - (void)_adjustForMaintainVisibleContentPosition; @end @implementation RCTScrollViewComponentView { +#if TARGET_OS_OSX // [macOS + BOOL _isInverted; +#endif // macOS] ScrollViewShadowNode::ConcreteState::Shared _state; CGSize _contentSize; NSTimeInterval _lastScrollEventDispatchTime; @@ -167,10 +203,11 @@ - (instancetype)initWithFrame:(CGRect)frame _isAccessibilityAPIUsed = NO; [self addSubview:_scrollView]; - _containerView = [[RCTUIView alloc] initWithFrame:CGRectZero]; // [macOS] #if !TARGET_OS_OSX // [macOS] + _containerView = [[RCTUIView alloc] initWithFrame:CGRectZero]; [_scrollView addSubview:_containerView]; #else // [macOS + _containerView = [[RCTScrollViewContainerView alloc] initWithFrame:CGRectZero]; // Force overlay scrollbar style to avoid layout issues with legacy scrollbars. _scrollView.scrollerStyle = NSScrollerStyleOverlay; // Don't set autoresizingMask — AppKit corrupts the documentView frame during tile/resize. @@ -369,6 +406,40 @@ - (void)updateLayoutMetrics:(const LayoutMetrics &)layoutMetrics } } +#if TARGET_OS_OSX // [macOS +// An inverted list gives its ScrollView scaleY(-1), as on iOS. Here the document view and the +// content count from the bottom instead (see RCTViewComponentView's invertsSubviewsVertically), +// so AppKit's scrolling, hit-testing and pointer tracking match what is drawn, and a content +// offset still counts from the list's first item. +- (void)setTransform3D:(CATransform3D)transform3D +{ + BOOL isInverted = CATransform3DEqualToTransform(transform3D, CATransform3DMakeScale(1, -1, 1)); + [super setTransform3D:isInverted ? CATransform3DIdentity : transform3D]; + [self applyInversion:isInverted]; +} + +- (void)applyInversion:(BOOL)isInverted +{ + if (_isInverted == isInverted) { + return; + } + _isInverted = isInverted; + + CGPoint contentOffset = _scrollView.contentOffset; + ((RCTScrollViewContainerView *)_containerView).inverted = isInverted; + if ([_contentView isKindOfClass:[RCTViewComponentView class]]) { + ((RCTViewComponentView *)_contentView).invertsSubviewsVertically = isInverted; + } + // The clip view takes in its document's flippedness when the document is set. Scroll events + // sent while it is swapped carry offsets from part-way through, so send the settled one after. + _scrollView.documentView = nil; + _scrollView.documentView = _containerView; + _scrollView.contentOffset = contentOffset; + [self _forceDispatchNextScrollEvent]; + [self scrollViewDidScroll:_scrollView]; +} +#endif // macOS] + - (bool)isInverted { // Look into the entry at position 2,2 to check if scaleY is applied @@ -629,11 +700,21 @@ - (void)mountChildComponentView:(RCTUIView *)childComp [_containerView insertSubview:childComponentView atIndex:index]; if (![childComponentView conformsToProtocol:@protocol(RCTCustomPullToRefreshViewProtocol)]) { _contentView = childComponentView; +#if TARGET_OS_OSX // [macOS + if ([childComponentView isKindOfClass:[RCTViewComponentView class]]) { + ((RCTViewComponentView *)childComponentView).invertsSubviewsVertically = _isInverted; + } +#endif // macOS] } } - (void)unmountChildComponentView:(RCTUIView *)childComponentView index:(NSInteger)index // [macOS] { +#if TARGET_OS_OSX // [macOS + if ([childComponentView isKindOfClass:[RCTViewComponentView class]]) { + ((RCTViewComponentView *)childComponentView).invertsSubviewsVertically = NO; + } +#endif // macOS] [childComponentView removeFromSuperview]; if (![childComponentView conformsToProtocol:@protocol(RCTCustomPullToRefreshViewProtocol)] && _contentView == childComponentView) { diff --git a/packages/react-native/React/Fabric/Mounting/ComponentViews/View/RCTViewComponentView.h b/packages/react-native/React/Fabric/Mounting/ComponentViews/View/RCTViewComponentView.h index 89f77212b32c..46ff57a0c431 100644 --- a/packages/react-native/React/Fabric/Mounting/ComponentViews/View/RCTViewComponentView.h +++ b/packages/react-native/React/Fabric/Mounting/ComponentViews/View/RCTViewComponentView.h @@ -97,6 +97,12 @@ NS_ASSUME_NONNULL_BEGIN #if TARGET_OS_OSX // [macOS - (BOOL)handleKeyboardEvent:(NSEvent *)event; - (facebook::react::DataTransfer)dataTransferForPasteboard:(NSPasteboard *)pasteboard; + +/* + * Lays the subviews out from the bottom, for an inverted ScrollView's content. A subview's own + * scaleY(-1), which an inverted list gives each cell, is then left off. + */ +@property (nonatomic, assign) BOOL invertsSubviewsVertically; #endif // macOS] /* diff --git a/packages/react-native/React/Fabric/Mounting/ComponentViews/View/RCTViewComponentView.mm b/packages/react-native/React/Fabric/Mounting/ComponentViews/View/RCTViewComponentView.mm index 473eab307dca..4183ec4ab25a 100644 --- a/packages/react-native/React/Fabric/Mounting/ComponentViews/View/RCTViewComponentView.mm +++ b/packages/react-native/React/Fabric/Mounting/ComponentViews/View/RCTViewComponentView.mm @@ -61,6 +61,7 @@ @implementation RCTViewComponentView { BOOL _hasClipViewBoundsObserver; NSTrackingArea *_trackingArea; BOOL _allowsVibrancy; + CATransform3D _requestedTransform3D; #endif // macOS] NSMutableArray *_reactSubviews; // [macOS] NSSet *_Nullable _propKeysManagedByAnimated_DO_NOT_USE_THIS_IS_BROKEN; @@ -89,6 +90,7 @@ - (instancetype)initWithFrame:(CGRect)frame _removeClippedSubviews = NO; #if TARGET_OS_OSX // [macOS _allowsVibrancy = NO; + _requestedTransform3D = CATransform3DIdentity; self.mouseDownCanMoveWindow = YES; #endif // macOS] } @@ -2274,6 +2276,73 @@ - (void)viewDidMoveToWindow [super viewDidMoveToWindow]; } +#pragma mark - Inverted ScrollView content + +// AppKit hit-tests, tracks the pointer and scrolls in view coordinates, which leave out layer +// transforms, so an inverted list can't be flipped with scaleY(-1) as on iOS. Its ScrollView lays +// the content out from the bottom instead (RCTScrollViewComponentView), and the cells' flips are +// left off. The flips stay in the props, so layout and measurement see what iOS sees. + +static BOOL RCTIsVerticalFlip(CATransform3D transform) +{ + return CATransform3DEqualToTransform(transform, CATransform3DMakeScale(1, -1, 1)); +} + +// AppKit works out where a view's layer goes, flipped or not, when the view's frame is set. +static void RCTRelayoutLayer(RCTPlatformView *view) +{ + NSPoint origin = view.frame.origin; + [view setFrameOrigin:NSMakePoint(origin.x + 1, origin.y)]; + [view setFrameOrigin:origin]; +} + +- (BOOL)isFlipped +{ + return !_invertsSubviewsVertically; +} + +- (void)setInvertsSubviewsVertically:(BOOL)invertsSubviewsVertically +{ + if (_invertsSubviewsVertically == invertsSubviewsVertically) { + return; + } + _invertsSubviewsVertically = invertsSubviewsVertically; + RCTRelayoutLayer(self); + for (RCTPlatformView *subview in self.subviews) { + if ([subview isKindOfClass:[RCTViewComponentView class]]) { + [(RCTViewComponentView *)subview applyTransform3DForSuperview]; + } + RCTRelayoutLayer(subview); + } +} + +- (void)setTransform3D:(CATransform3D)transform3D +{ + _requestedTransform3D = transform3D; + [super setTransform3D:[self transform3DForSuperview]]; +} + +- (CATransform3D)transform3DForSuperview +{ + BOOL isInvertedCell = RCTIsVerticalFlip(_requestedTransform3D) && + [self.superview isKindOfClass:[RCTViewComponentView class]] && + ((RCTViewComponentView *)self.superview).invertsSubviewsVertically; + return isInvertedCell ? CATransform3DIdentity : _requestedTransform3D; +} + +- (void)applyTransform3DForSuperview +{ + if (RCTIsVerticalFlip(_requestedTransform3D)) { + [super setTransform3D:[self transform3DForSuperview]]; + } +} + +- (void)viewDidMoveToSuperview +{ + [super viewDidMoveToSuperview]; + [self applyTransform3DForSuperview]; +} + - (void)updateTrackingAreas { BOOL hasMouseEventHandler = diff --git a/packages/virtualized-lists/Lists/VirtualizedList.js b/packages/virtualized-lists/Lists/VirtualizedList.js index 180546e77f0a..fe090408a638 100644 --- a/packages/virtualized-lists/Lists/VirtualizedList.js +++ b/packages/virtualized-lists/Lists/VirtualizedList.js @@ -962,9 +962,11 @@ class VirtualizedList extends StateSafePureComponent< const {ListEmptyComponent, ListFooterComponent, ListHeaderComponent} = this.props; const {data, horizontal} = this.props; - // macOS natively supports inverted lists, thus not needing an inversion style + // macOS's old architecture inverts a ScrollView natively, so needs no inversion style. On the + // new architecture its ScrollView takes the style as iOS does, and inverts natively from it. const inversionStyle = - this.props.inverted && Platform.OS !== 'macos' // [macOS] + this.props.inverted && + (Platform.OS !== 'macos' || global.nativeFabricUIManager != null) // [macOS] ? horizontalOrDefault(this.props.horizontal) ? styles.horizontallyInverted : styles.verticallyInverted