Skip to content

Commit 7e1ce75

Browse files
kkafarclaude
andauthored
feat(iOS, Stack, Tabs): improve nested container integration -> introduce Container & ContainerItem (#4227)
## Description Port the container-nesting protocol from Android to iOS so nesting information propagates between containers, and use it to resolve the content scroll view for special effects (e.g. scroll-to-top) across a nesting boundary. On iOS the protocol is expressed at the view-controller level, mirroring the Android side. Shared state/logic lives in two composition holders: `RNSContainerItemSupport` (item side) and `RNSParentContainerItemRegistry` (container side). Closes software-mansion/react-native-screens-labs#1573 Closes software-mansion/react-native-screens-labs#1424 ## Changes - Introduce the `RNSContainer` and `RNSContainerItem` protocols and the two composition holders (`RNSContainerItemSupport`, `RNSParentContainerItemRegistry`) mirroring the Android implementation. - `RNSStackNavigationController` and `RNSTabBarController` adopt `RNSContainer` and self-register / unregister with the nearest parent `RNSContainerItem` from their own `didMoveToParentViewController:` lifecycle (i.e. as the container is added to / removed from the view-controller hierarchy). The host views only mount the controller. - `RNSStackScreenController` and `RNSTabsScreenViewController` adopt `RNSContainerItem`. Content-scroll-view resolution now follows cached -> nested container -> descendant-chain heuristic. - The parent walk follows the view-controller containment chain (`parentViewController`) upwards to the nearest `RNSContainerItem`. - `RNSTabsScreenViewController.resolveContentScrollView` delegates to `findContentScrollView`, removing the duplicated edge / heuristic lookup. - Wire the stack screen's content scroll view via `setContentScrollView:forEdge:` (native `UINavigationBar` scroll edge appearance, top-screen-tap scroll-to-top on iPad), matching the tabs screen. ## Before & after - visual documentation There is no change in behaviour. It should work correctly right now, just not "by accident". ## Test plan Use the `test-stack-svm-tabs-special-effects` to confirm that indeed, special effects now do work in nested container scenario, even when the "standard UIKit heuristic" is broken. ## Checklist - [x] Included code example that can be used to test this change. - [x] For visual changes, included screenshots / GIFs / recordings documenting the change. - [ ] For API changes, updated relevant public types. - [ ] Ensured that CI passes --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 1740f0f commit 7e1ce75

19 files changed

Lines changed: 430 additions & 25 deletions

apps/src/tests/component-integration-tests/scroll-view-marker/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,9 @@ import type { ScenarioGroup } from '@apps/tests/shared/helpers';
22
import TestSvmTabsScrollEdgeEffects from './test-svm-tabs-scroll-edge-effects';
33
import TestStackSvmTabsSpecialEffects from './test-stack-svm-tabs-special-effects';
44

5+
export { default as TestSvmTabsScrollEdgeEffects } from './test-svm-tabs-scroll-edge-effects';
6+
export { default as TestStackSvmTabsSpecialEffects } from './test-stack-svm-tabs-special-effects';
7+
58
const scenarios = {
69
TestSvmTabsScrollEdgeEffects,
710
TestStackSvmTabsSpecialEffects,

ios/gamma/stack/host/RNSStackNavigationController.h

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
11
#pragma once
22

3+
#import "RNSContainer.h"
34
#include "RNSStackNavigationBarCoordinator.h"
45
#include "RNSStackScreenComponentView.h"
56

67
@protocol RNSViewFrameChangeDelegate;
78

8-
@interface RNSStackNavigationController : UINavigationController
9+
@interface RNSStackNavigationController : UINavigationController <RNSContainer>
910

1011
@property (nonatomic, weak, nullable) id<RNSViewFrameChangeDelegate> navigationBarFrameChangeDelegate;
1112

ios/gamma/stack/host/RNSStackNavigationController.mm

Lines changed: 47 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
#import "RNSStackNavigationController.h"
2+
#import "RNSContainer.h"
23
#import "RNSLog.h"
4+
#import "RNSParentContainerItemRegistry.h"
35
#import "RNSStackOperation.h"
46
#import "RNSStackScreenController.h"
57
#import "RNSViewFrameChangeDelegate.h"
@@ -8,6 +10,7 @@
810
@implementation RNSStackNavigationController {
911
NSMutableArray<RNSPushOperation *> *_Nonnull _pendingPushOperations;
1012
NSMutableArray<RNSPopOperation *> *_Nonnull _pendingPopOperations;
13+
RNSParentContainerItemRegistry *_Nonnull _parentContainerRegistry;
1114
}
1215

1316
- (instancetype)init
@@ -24,6 +27,50 @@ - (void)initState
2427
{
2528
_pendingPushOperations = [NSMutableArray array];
2629
_pendingPopOperations = [NSMutableArray array];
30+
_parentContainerRegistry = [RNSParentContainerItemRegistry new];
31+
}
32+
33+
#pragma mark-- Layout
34+
35+
- (void)viewDidLayoutSubviews
36+
{
37+
[super viewDidLayoutSubviews];
38+
[_navigationBarFrameChangeDelegate viewFrameDidChange:self.navigationBar];
39+
}
40+
41+
#pragma mark - RNSContainer
42+
43+
- (nullable UIScrollView *)resolveCurrentContentScrollView
44+
{
45+
// We assume `topViewController` corresponds to the currently presented screen.
46+
UIViewController *topController = self.topViewController;
47+
if (![topController isKindOfClass:RNSStackScreenController.class]) {
48+
return nil;
49+
}
50+
return [static_cast<RNSStackScreenController *>(topController) findContentScrollView];
51+
}
52+
53+
- (void)attachToParentContainerItem
54+
{
55+
[_parentContainerRegistry attachContainer:self];
56+
}
57+
58+
- (void)detachFromParentContainerItem
59+
{
60+
[_parentContainerRegistry detachContainer:self];
61+
}
62+
63+
#pragma mark - View controller containment
64+
65+
- (void)didMoveToParentViewController:(UIViewController *)parent
66+
{
67+
[super didMoveToParentViewController:parent];
68+
69+
if (parent != nil) {
70+
[self attachToParentContainerItem];
71+
} else {
72+
[self detachFromParentContainerItem];
73+
}
2774
}
2875

2976
- (BOOL)hasPendingOperations
@@ -75,14 +122,6 @@ - (void)performContainerUpdateIfNeeded
75122
[_pendingPushOperations removeAllObjects];
76123
}
77124

78-
#pragma mark - Layout
79-
80-
- (void)viewDidLayoutSubviews
81-
{
82-
[super viewDidLayoutSubviews];
83-
[_navigationBarFrameChangeDelegate viewFrameDidChange:self.navigationBar];
84-
}
85-
86125
#pragma mark - Debug
87126

88127
- (void)dumpStackModel

ios/gamma/stack/screen/RNSStackScreenComponentView.h

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,4 +43,16 @@ typedef NS_ENUM(int, RNSStackScreenActivityMode) {
4343

4444
@end
4545

46+
#pragma mark - Content scroll view
47+
48+
@interface RNSStackScreenComponentView ()
49+
50+
/**
51+
* Content scroll view registered by a descendant `RNSScrollViewMarkerComponentView`, or nil if
52+
* none has been registered. Queried by the owning controller (`RNSContainerItem`).
53+
*/
54+
- (nullable UIScrollView *)cachedContentScrollView;
55+
56+
@end
57+
4658
NS_ASSUME_NONNULL_END

ios/gamma/stack/screen/RNSStackScreenComponentView.mm

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@
88
#import <rnscreens/RNSStackScreenComponentDescriptor.h>
99

1010
#import "RNSConversions-Stack.h"
11+
#import "RNSScrollViewMarkerComponentView.h"
12+
#import "RNSScrollViewSeeking.h"
1113
#import "RNSStackHeaderConfigComponentView.h"
1214
#import "RNSStackHostComponentView.h"
1315
#import "RNSStackNavigationController.h"
@@ -18,7 +20,7 @@
1820

1921
namespace react = facebook::react;
2022

21-
@interface RNSStackScreenComponentView () <RCTMountingTransactionObserving>
23+
@interface RNSStackScreenComponentView () <RCTMountingTransactionObserving, RNSScrollViewSeeking>
2224
@end
2325

2426
#pragma mark - View implementation
@@ -27,6 +29,11 @@ @implementation RNSStackScreenComponentView {
2729
RNSStackScreenController *_Nonnull _controller;
2830
RNSStackScreenComponentEventEmitter *_Nonnull _reactEventEmitter;
2931

32+
// Content scroll view registered by a descendant `RNSScrollViewMarkerComponentView`. Queried by
33+
// the owning `RNSStackScreenController` (as `RNSContainerItem`) when resolving the content
34+
// scroll view for special effects.
35+
__weak UIScrollView *_Nullable _contentScrollView;
36+
3037
// Flags
3138
BOOL _hasUpdatedActivityMode;
3239
}
@@ -79,6 +86,21 @@ - (void)invalidateImpl
7986
});
8087
}
8188

89+
#pragma mark - RNSScrollViewSeeking
90+
91+
- (void)registerDescendantScrollView:(UIScrollView *)scrollView fromMarker:(RNSScrollViewMarkerComponentView *)marker
92+
{
93+
// Native scroll-edge behavior (UINavigationBar scroll edge appearance, top-screen-tap scroll-to-top on iPad).
94+
[_controller setContentScrollView:scrollView forEdge:NSDirectionalRectEdgeAll];
95+
// Cache used by the container-nesting content-scroll-view resolution.
96+
_contentScrollView = scrollView;
97+
}
98+
99+
- (nullable UIScrollView *)cachedContentScrollView
100+
{
101+
return _contentScrollView;
102+
}
103+
82104
#pragma mark - Events
83105

84106
- (nonnull RNSStackScreenComponentEventEmitter *)reactEventEmitter

ios/gamma/stack/screen/RNSStackScreenController.h

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,15 @@
11
#pragma once
22

33
#import <UIKit/UIKit.h>
4+
#import "RNSContainerItem.h"
45

56
NS_ASSUME_NONNULL_BEGIN
67

78
@class RNSStackScreenComponentView;
89
@class RNSStackController;
910
@class RNSStackScreenHeaderCoordinator;
1011

11-
@interface RNSStackScreenController : UIViewController
12+
@interface RNSStackScreenController : UIViewController <RNSContainerItem>
1213

1314
@property (nonatomic, strong, readonly, nonnull) RNSStackScreenHeaderCoordinator *headerCoordinator;
1415

ios/gamma/stack/screen/RNSStackScreenController.mm

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
#import "RNSStackScreenController.h"
2+
#import "RNSContainer.h"
3+
#import "RNSContainerItemSupport.h"
24
#import "RNSLog.h"
35
#import "RNSStackHostComponentView.h"
46
#import "RNSStackNavigationController.h"
@@ -8,17 +10,42 @@
810

911
@implementation RNSStackScreenController {
1012
RNSStackScreenComponentView *_Nonnull _screenView;
13+
RNSContainerItemSupport *_Nonnull _containerItemSupport;
1114
}
1215

1316
- (instancetype)initWithComponentView:(RNSStackScreenComponentView *)componentView
1417
{
1518
if (self = [super initWithNibName:nil bundle:nil]) {
1619
_screenView = componentView;
1720
_headerCoordinator = [[RNSStackScreenHeaderCoordinator alloc] initWithScreenController:self];
21+
_containerItemSupport = [RNSContainerItemSupport new];
1822
}
1923
return self;
2024
}
2125

26+
#pragma mark - RNSContainerItem
27+
28+
- (void)registerNestedContainer:(id<RNSContainer>)container
29+
{
30+
[_containerItemSupport registerNestedContainer:container];
31+
}
32+
33+
- (void)unregisterNestedContainer:(id<RNSContainer>)container
34+
{
35+
[_containerItemSupport unregisterNestedContainer:container];
36+
}
37+
38+
- (nullable id<RNSContainer>)resolveNestedContainer
39+
{
40+
return [_containerItemSupport resolveNestedContainer];
41+
}
42+
43+
- (nullable UIScrollView *)findContentScrollView
44+
{
45+
return [_containerItemSupport findContentScrollViewWithCachedScrollView:[_screenView cachedContentScrollView]
46+
heuristicRoot:_screenView];
47+
}
48+
2249
- (RNSStackScreenComponentEventEmitter *)reactEventEmitter
2350
{
2451
return [_screenView reactEventEmitter];
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
#pragma once
2+
3+
#import <UIKit/UIKit.h>
4+
5+
NS_ASSUME_NONNULL_BEGIN
6+
7+
/**
8+
* A navigation container (e.g. stack, tabs) that hosts items and can resolve the content scroll
9+
* view of whichever item is currently presented.
10+
*
11+
* On iOS the concept is implemented at the view-controller level: `RNSStackNavigationController`
12+
* and `RNSTabBarController` conform to this protocol.
13+
*/
14+
@protocol RNSContainer <NSObject>
15+
16+
/**
17+
* A container can host multiple items, each with its own content scroll view. It is up to the
18+
* implementer to decide which scroll view to return here (if any) for the item that is currently
19+
* presented.
20+
*/
21+
- (nullable UIScrollView *)resolveCurrentContentScrollView;
22+
23+
@end
24+
25+
NS_ASSUME_NONNULL_END
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
#pragma once
2+
3+
#import <UIKit/UIKit.h>
4+
#import "RNSContentScrollViewProviding.h"
5+
6+
NS_ASSUME_NONNULL_BEGIN
7+
8+
@protocol RNSContainer;
9+
10+
/**
11+
* A single item hosted by an `RNSContainer` (e.g. a stack screen, a tab). An item may itself host
12+
* a single nested `RNSContainer` and exposes the content scroll view of its subtree via
13+
* `findContentScrollView` (inherited from `RNSContentScrollViewProviding`).
14+
*
15+
* On iOS the concept is implemented at the view-controller level: `RNSStackScreenController` and
16+
* `RNSTabsScreenViewController` conform to this protocol.
17+
*/
18+
@protocol RNSContainerItem <RNSContentScrollViewProviding>
19+
20+
#pragma mark - Nested Container handling
21+
22+
/**
23+
* A `RNSContainerItem` supports at most a single nested `RNSContainer`. Registering a second
24+
* container while one is already registered overwrites the previous one. This is an intentional
25+
* design invariant: a single item is expected to host at most one nested container.
26+
*/
27+
- (void)registerNestedContainer:(id<RNSContainer>)container;
28+
29+
- (void)unregisterNestedContainer:(id<RNSContainer>)container;
30+
31+
- (nullable id<RNSContainer>)resolveNestedContainer;
32+
33+
#pragma mark - Content Scroll View support
34+
35+
// `findContentScrollView` is inherited from `RNSContentScrollViewProviding`.
36+
37+
@end
38+
39+
NS_ASSUME_NONNULL_END
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
#pragma once
2+
3+
#import <UIKit/UIKit.h>
4+
5+
NS_ASSUME_NONNULL_BEGIN
6+
7+
@protocol RNSContainer;
8+
9+
/**
10+
* Composition holder for the `RNSContainerItem` side of the container-nesting mechanism.
11+
*
12+
* It owns the (weak) reference to the nested container and implements the content-scroll-view
13+
* resolution order shared by all container items. The cached content scroll view itself is NOT
14+
* owned here - it lives on the item's component view and is passed in by the owner.
15+
*/
16+
@interface RNSContainerItemSupport : NSObject
17+
18+
- (void)registerNestedContainer:(id<RNSContainer>)container;
19+
20+
- (void)unregisterNestedContainer:(id<RNSContainer>)container;
21+
22+
- (nullable id<RNSContainer>)resolveNestedContainer;
23+
24+
/**
25+
* Resolves the content scroll view using the shared order:
26+
* 1. the provided `cachedScrollView` (registered on the owner's view by the scroll view marker),
27+
* 2. the nested container's current content scroll view,
28+
* 3. a first-descendant-chain heuristic rooted at `rootView`.
29+
*/
30+
- (nullable UIScrollView *)findContentScrollViewWithCachedScrollView:(nullable UIScrollView *)cachedScrollView
31+
heuristicRoot:(nullable UIView *)rootView;
32+
33+
@end
34+
35+
NS_ASSUME_NONNULL_END

0 commit comments

Comments
 (0)