feat: Implement subviews layout in header for iOS stack v5#3868
feat: Implement subviews layout in header for iOS stack v5#3868kmichalikk wants to merge 11 commits into
Conversation
9f7743a to
3126537
Compare
d8a04d2 to
2571b25
Compare
3126537 to
47620e6
Compare
5acc30e to
19fb657
Compare
There was a problem hiding this comment.
Pull request overview
Adds an MVP iOS implementation for Stack v5 header configuration with support for laying out React Native-backed header items (left/right/title/subtitle/large subtitle) and synchronizing their shadow state frames with UIKit navigation bar layout.
Changes:
- Introduces new Fabric native components for iOS header config + header items/spacers, plus corresponding iOS coordinators to apply configuration to
UINavigationItem/UINavigationBar. - Adds C++ shadow-node/state plumbing to correct header/header-item layout origins based on native-measured frames.
- Adds a dedicated single-feature test scenario for iOS header subviews (and updates scenario registration/naming).
Reviewed changes
Copilot reviewed 52 out of 52 changed files in this pull request and generated 8 comments.
Show a summary per file
| File | Description |
|---|---|
| src/fabric/gamma/stack/StackHeaderItemSpacerIOSNativeComponent.ts | Codegen spec for iOS header spacers |
| src/fabric/gamma/stack/StackHeaderItemIOSNativeComponent.ts | Codegen spec for iOS header items |
| src/fabric/gamma/stack/StackHeaderConfigIOSNativeComponent.ts | Adds subtitle/largeTitle props to iOS header config |
| src/components/gamma/stack/header/StackHeaderConfig.types.ts | Adds subtitle to shared header config props |
| src/components/gamma/stack/header/StackHeaderConfig.ios.types.ts | Defines iOS header item/spacer config types |
| src/components/gamma/stack/header/StackHeaderConfig.ios.tsx | Renders iOS header config children (items/spacers) |
| src/components/gamma/stack/header/ios/StackHeaderItemSpacer.ios.types.ts | JS spacer wrapper prop types |
| src/components/gamma/stack/header/ios/StackHeaderItemSpacer.ios.tsx | JS spacer wrapper component |
| src/components/gamma/stack/header/ios/StackHeaderItem.ios.types.ts | JS header item wrapper prop types |
| src/components/gamma/stack/header/ios/StackHeaderItem.ios.tsx | JS header item wrapper component |
| package.json | Registers new Fabric component view class mappings |
| ios/gamma/stack/screen/RNSStackScreenHeaderCoordinator.mm | New coordinator to apply header data + shadow sync |
| ios/gamma/stack/screen/RNSStackScreenHeaderCoordinator.h | Coordinator interface |
| ios/gamma/stack/screen/RNSStackScreenController.mm | Creates/uses header coordinator in lifecycle |
| ios/gamma/stack/screen/RNSStackScreenController.h | Exposes headerCoordinator property |
| ios/gamma/stack/screen/RNSStackScreenComponentView.mm | Adds header config lookup helper |
| ios/gamma/stack/screen/RNSStackScreenComponentView.h | Declares header config lookup helper |
| ios/gamma/stack/screen/RNSStackNavigationItemCoordinator.mm | Applies header data to UINavigationItem |
| ios/gamma/stack/screen/RNSStackNavigationItemCoordinator.h | Navigation item coordinator interface |
| ios/gamma/stack/RNSStackNavigationBarCoordinator.mm | Applies bar-level config to UINavigationController |
| ios/gamma/stack/RNSStackNavigationBarCoordinator.h | Navigation bar coordinator interface |
| ios/gamma/stack/RNSShadowStateFrameTracker.mm | Utility to debounce frame-based state updates |
| ios/gamma/stack/RNSShadowStateFrameTracker.h | Utility header |
| ios/gamma/stack/host/RNSStackNavigationController.mm | Dummy VC workaround + shadow-state updates on layout |
| ios/gamma/stack/host/RNSStackHostComponentView.mm | Auto-layout constraints for embedded controller view |
| ios/gamma/stack/header/RNSStackHeaderItemSpacerComponentView.mm | Fabric component view for header spacers |
| ios/gamma/stack/header/RNSStackHeaderItemSpacerComponentView.h | Spacer component view interface |
| ios/gamma/stack/header/RNSStackHeaderItemInvalidationDelegate.h | Delegate for header item invalidation |
| ios/gamma/stack/header/RNSStackHeaderItemComponentView.mm | Fabric component view for header items |
| ios/gamma/stack/header/RNSStackHeaderItemComponentView.h | Header item component view interface |
| ios/gamma/stack/header/RNSStackHeaderData.mm | Immutable header data container impl |
| ios/gamma/stack/header/RNSStackHeaderData.h | Immutable header data container interface |
| ios/gamma/stack/header/RNSStackHeaderConfigComponentView.mm | Fabric component view building header data/items |
| ios/gamma/stack/header/RNSStackHeaderConfigComponentView.h | Header config component view interface |
| ios/gamma/stack/header/RNSHeaderItemSpacerPlacement.h | Native enum for spacer placement |
| ios/gamma/stack/header/RNSHeaderItemPlacement.h | Native enum for item placement |
| ios/conversion/RNSConversions-Stack.mm | Adds enum conversions for header placements |
| ios/conversion/RNSConversions-Stack.h | Declares new conversion templates |
| common/cpp/react/renderer/components/rnscreens/RNSStackHeaderItemState.h | New state type for header item frame/offset |
| common/cpp/react/renderer/components/rnscreens/RNSStackHeaderItemState.cpp | State TU |
| common/cpp/react/renderer/components/rnscreens/RNSStackHeaderItemShadowNode.h | Shadow node that applies native frame corrections |
| common/cpp/react/renderer/components/rnscreens/RNSStackHeaderItemShadowNode.cpp | Shadow node layout correction implementation |
| common/cpp/react/renderer/components/rnscreens/RNSStackHeaderItemComponentDescriptor.h | Component descriptor for header items |
| common/cpp/react/renderer/components/rnscreens/RNSStackHeaderConfigState.h | Extends state to carry size/offset on non-Android |
| common/cpp/react/renderer/components/rnscreens/RNSStackHeaderConfigShadowNode.h | Adds non-Android layout override |
| common/cpp/react/renderer/components/rnscreens/RNSStackHeaderConfigShadowNode.cpp | Applies origin corrections from state on iOS |
| common/cpp/react/renderer/components/rnscreens/RNSStackHeaderConfigComponentDescriptor.h | Makes adopt logic cross-platform |
| apps/src/tests/single-feature-tests/stack-v5/test-stack-subviews-ios/scenario.md | Adds iOS header subviews scenario doc |
| apps/src/tests/single-feature-tests/stack-v5/test-stack-subviews-ios/index.tsx | Adds iOS interactive scenario implementation |
| apps/src/tests/single-feature-tests/stack-v5/test-stack-subviews-android/index.tsx | Renames Android scenario label |
| apps/src/tests/single-feature-tests/stack-v5/index.ts | Registers iOS + Android subviews scenarios separately |
| apps/src/shared/gamma/containers/stack/StackContainer.tsx | Formatting-only change in stack mapping destructure |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
kligarski
left a comment
There was a problem hiding this comment.
- Pressables don't work on first load.
pressables_bug.mov
- Aren't we missing
RNSGammaStubs?
| - custom view buttons don't move to overflow menu (native behavior); one needs to specify `menuRepresentation` for them (not implemented yet) | ||
| - items are collapsed in the order: title, right buttons (one by one), left buttons (all at once); this is in line with native behavior | ||
|
|
||
| ## Steps |
| ## E2E test | ||
|
|
There was a problem hiding this comment.
Let's follow convention here - add OTHER and explanation that it's still todo.
| return ( | ||
| <PressableWithFeedback | ||
| {...pressableProps} | ||
| style={{ width: 30, height: 30, backgroundColor: 'blue' }} |
There was a problem hiding this comment.
When testing pressables, I usually depend on its color changing when clicked. If we override the color here, PressableWithFeedback loses its WithFeedback functionality. I think we should stick to default colors here, maybe we can open separate PR that will allow color customization for PressableWithFeedback component.
There was a problem hiding this comment.
I think we're really missing some configuration here, e.g. I can't test titleView without going to landscape mode or modifying the code of the test. There should be some configuration of left/right subviews I think, even something like enable/disable.
| title?: string | undefined; | ||
| /** | ||
| * @summary Subtitle displayed in the header. | ||
| * |
There was a problem hiding this comment.
Let's add a comment that it's currently unsupported on Android (but it will be, hopefully).
|
|
||
| @implementation RNSStackNavigationItemCoordinator | ||
|
|
||
| #pragma mark - Public |
There was a problem hiding this comment.
nit: not sure if we need this
| Point RNSStackHeaderConfigShadowNode::getContentOriginOffset( | ||
| bool /*includeTransform*/) const { | ||
| auto stateData = getStateData(); | ||
| return stateData.contentOffset; | ||
| } | ||
| #endif // ANDROID | ||
| #else // ANDROID | ||
| void RNSStackHeaderConfigShadowNode::layout(LayoutContext layoutContext) { | ||
| YogaLayoutableShadowNode::layout(layoutContext); | ||
| applyFrameCorrections(); | ||
| } | ||
|
|
||
| void RNSStackHeaderConfigShadowNode::applyFrameCorrections() { | ||
| ensureUnsealed(); | ||
|
|
||
| const auto &stateData = getStateData(); | ||
| layoutMetrics_.frame.origin.x = stateData.contentOffset.x; | ||
| layoutMetrics_.frame.origin.y = stateData.contentOffset.y; | ||
| } | ||
| #endif |
There was a problem hiding this comment.
Why do we want to modify layoutMetrics_.frame.origin here instead of relying on getContentOriginOffset? Not sure if there is much difference tbh? cc @kkafar here
There was a problem hiding this comment.
Similar question regarding header items.
There was a problem hiding this comment.
Or maybe that's better because to handle RTL we need it on Android anyway for header subviews? I'm not sure.
| const Size frameSize{}; | ||
| const Point contentOffset{}; |
There was a problem hiding this comment.
We should be able to move this to shared part between Android and iOS.
There was a problem hiding this comment.
Do we even need this file?
|
|
||
| - (RNSStackScreenController *)requireScreenController | ||
| { | ||
| RCTAssert(_screenController != nil, @"Screen Controller cannot be nil"); |
There was a problem hiding this comment.
| RCTAssert(_screenController != nil, @"Screen Controller cannot be nil"); | |
| RCTAssert(_screenController != nil, @"[RNScreens] Screen Controller cannot be nil"); |
Closes https://github.com/software-mansion/react-native-screens-labs/issues/856
Description
This PR adds basic support for header configuration under new stack v5 on iOS. The primary focus here is to support the layout of custom react views inside the header. The following places could host a custom view:
leftBarButtonItems: multiple items, any of which can be react viewrightBarButtonItems: same as abovetitleView: custom view to replace the regular titlesubtitleView: to replace subview, below the titlelargeSubtitleView: custom view below large titleThey are expected to be laid out seemlessly along native elements & with shadow state position matching the native hierarchy.
The configuration flow in new implementation follows roughly this diagram:
It was decided to give each item (no matter if it ends up as custom view or regular label button) a react native component. This way, each change to the props of each item maintains the same update flow: whenever props are updated, the menu item gets invalidated, which notifies the header config, which triggers the rebuild.
Caution
Optimization of the menu rebuild is not handled in this PR. For now, each invalidation triggers full menu rebuild. Same goes for any other optimization. This is an MVP implementation which will be iterated upon.
Caution
For now, no configuration is allowed for back button. Value of
leftItemsSupplementBackButtonis hardcoded toYESso no matter if left items are present or not, the back button is present. In fact, if the back button is not present, back gestures don't work - also tested on native app. So it's set to always be visible. DOUBLE CAUTION: default iOS gesture recognizer has this behavior. If we add a custom recognizer delegate, presence of back button won't affect the ability to swipe to pop.Caution
HeaderConfig is not exposed outside StackContainer. Testing the header requires modifying the values directly in StackContainer. The API is not final and is subject to change any time.
Important
I hadn't really considered item menus implementation (shown by long pressing items, if configured). Menus can hold native items (custom views don't work), and can be nested. The "configure-by-native-component" flow would be awkward for such menus. We need a better API for this. I hope it to be seamless with the API proposed here. EDIT: we now have https://github.com/software-mansion/react-native-screens-labs/pull/1164
Important
Let's make sure we are okay with the proposed implementation. I want to have the same item configuration logic for toolbar in the future.
Before & after - visual documentation
header-items.mov
Test plan
For now, there are props: title, subtitle, left and right items, titleItem, subtitleItem, largeSubtitleItem, largeTitleEnabled. TitleItem should take precedence over title. The title controls both regular and large title, same for subtitle.
There are regular label buttons and custom view buttons. There are also spacers (fixed, fixed + width, flexible; for header, only fixed works on iOS 26, fixed with width works on iOS 18, flexible will work on toolbar when it will be introduced).
Use
single-feature-tests/stack-v5/test-stack-subviews-ios. Setting the props should work. Mixing custom and regular buttons should work. The shadow tree position should match whatever is visible on the screen. Button in, out, click events should work.Caution
For now, hitSlop and clicking on largeHeader subview don't work. Both should be fixed with https://github.com/software-mansion/react-native-screens-labs/issues/1417
Checklist