feat(Android, Stack v5): handle header configuration and custom subviews#3796
feat(Android, Stack v5): handle header configuration and custom subviews#3796
Conversation
It doesn't use any CoordinatorLayout features.
I'm not really sure which context should we use. Might want to revisit it later.
TODO: handle layout of CoordinatorLayout, now it's added in a random place.
I did not apply any changes to color - header won't be transparent but will have correct layout for transparent header.
…derSubviewProviding internal
Unfortunately, the text is not ellipsized correctly when subviews are used.
…ners where possible
There was a problem hiding this comment.
Pull request overview
This large pull request implements header configuration and custom subviews support for Material Design 3 (M3) App Bar in Stack v5, enabling developers to customize headers with title, type (small/medium/large), and custom subviews positioned in leading, center, trailing, and background areas. The implementation includes significant refactoring of native code organization and integration between JavaScript, native Android, and C++ layers.
Changes:
- Added new
Stack.HeaderConfigandStackHeaderSubviewTypeScript components with platform-specific implementations (Android/iOS) - Reorganized native Android header code from
screen.headerpackage to dedicatedheaderpackage with structured subcomponents (config,subview) - Implemented C++ shadow node support for the new header and subview components
- Extracted
ShadowStateProxyutility class for shared state synchronization between Yoga and native layouts - Integrated header configuration into the StackContainer to enable route-level header customization via
setRouteOptions - Fixed debug logging to prevent circular reference issues using a new
safeStringifyutility - Replaced old test file with comprehensive test scenario demonstrating header configuration capabilities
Reviewed changes
Copilot reviewed 67 out of 71 changed files in this pull request and generated no comments.
Show a summary per file
| File | Description |
|---|---|
| src/components/gamma/stack/header/ | New TypeScript components for header config with platform-specific implementations |
| src/components/gamma/stack/host/, screen/ | Reorganized exports using index files following convention |
| android/src/main/java/com/swmansion/rnscreens/gamma/stack/header/ | New native Android implementation with coordinator pattern for header lifecycle |
| android/src/main/java/com/swmansion/rnscreens/gamma/common/ShadowStateProxy.kt | Extracted state proxy for layout synchronization |
| common/cpp/react/renderer/components/rnscreens/RNSStackHeader* | C++ shadow nodes and component descriptors |
| apps/src/tests/single-feature-tests/stack-v5/test-stack-subviews.tsx | Comprehensive test demonstrating header configuration |
| apps/src/shared/gamma/containers/stack/ | Integration of header config into StackContainer |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| val trailingSubview: StackHeaderSubviewProviding? | ||
| val backgroundSubview: StackHeaderSubviewProviding? | ||
|
|
||
| val isRtl: Boolean |
There was a problem hiding this comment.
In the repo we write this as RTL instead of Rtl so we should probably stick to that
| attachAppBarListeners(appBar) | ||
|
|
||
| populateAppBar(appBar, config) | ||
| maybeApplyRtlCollapsingToolbarLayoutWorkaround(coordinatorLayout, config, appBar) |
|
|
||
| private fun setContentBehavior(coordinatorLayout: StackHeaderCoordinatorLayout) { | ||
| val params = coordinatorLayout.stackScreenWrapper.layoutParams as CoordinatorLayout.LayoutParams | ||
| if (params.behavior == null) { |
There was a problem hiding this comment.
does it mean we can only set the behavior once?
There was a problem hiding this comment.
That's what we do right now, we set it once and keep it while it's necessary. I didn't notice any problems with this approach. If something comes up in the future, we can reapply the behavior on each rebuild.
|
|
||
| // endregion | ||
|
|
||
| private fun maybeApplyRtlCollapsingToolbarLayoutWorkaround( |
| if (index == getChildCount(parent) - 1 && parent.headerConfig != null) { | ||
| parent.headerConfig?.let { parent.detachHeaderConfig(it) } | ||
| } else { | ||
| super.removeViewAt(parent, index) | ||
| } |
There was a problem hiding this comment.
Normally, I wouldn't count on that being true. But since we are controlling it, maybe we could leave it as is
There was a problem hiding this comment.
Well, we're not rendering header config in StackScreen directly, we're doing this in our example container implementation (StackContainer.tsx).
I decided to add additional checks to throw an exception in runtime if HeaderConfig is not the last child of StackScreen.
Description
Adds support for header configuration and providing custom subviews for M3 App Bar in Stack v5.
Closes https://github.com/software-mansion/react-native-screens-labs/issues/916.
Details
JS API
After some discussions we decided that the API for header configuration will look as follows:
Stackobject will provide aStack.HeaderConfigcomponent only.Stack.HeaderConfigcomponent.Stack.HeaderConfigwill be split in similar way toTabsHostandTabsScreenbetween platforms:title,hidden),android/iosprop.StackHeaderSubviewwill be Android-only).High-level flow of updates
Header attachment
StackHeaderConfigis attached toStackScreen.StackHeaderCoordinatorLayoutcreatesStackHeaderCoordinatorinstance.CLattaches its ownonHeaderHeightChangedcallback to the coordinator. It will be called whenAppBarLayoutis scrolled and will propagate correct Y offset toStackScreen.StackHeaderCoordinatorLayoutattaches its own callback asonHeaderConfigAttachListeneronStackScreen(weak ref) and ifstackScreen.headerConfigis available, it uses the config.StackHeaderCoordinatorLayout.handleHeaderConfigAttachruns and attaches its own callback asonHeaderConfigChangeListeneron new config (weak ref). This callback passes all updates toheaderCoordinatorviaapplyHeaderConfig(it also batches the updates). Initial update is performed.Subview attachment
StackHeaderSubviewis attached toStackHeaderConfig.StackHeaderSubviewattaches itself asonStackHeaderSubviewChangeListener(weak ref) to the subview (to informHeaderCoordinatorabout e.g.collapseModeprop change on the subview).HeaderConfiginformsHeaderCoordinatorviaonConfigChangeListenerabout the new subview.Offset shadow tree update
AppBarLayout.ScrollingViewBehavior.onDependentViewChangedruns.StackHeaderScrollingViewBehaviorto allowheaderCoordinatorto attach its own callback.StackScreen's offset viaonHeaderHeightChangedcallback.AppBarLayout(so that it works with both non-transparent and transparent header). They update size & offset forHeaderConfigand offsets forHeaderSubviews viasyncShadowStatemethod.Scrolling behaviors
M3 App Bar suppors various scrolling behaviors. In order to use them, we need to use
ScrollingViewBehaviorviaCoordinatorLayout. Important thing to note is that the content below the header has constant size. Scrolling the content scrolls the app bar and the content. The size of the content is calculated byCoordinatorLayoutso that the content is fully visible when header is collapsed. When header is expanded, some part of the screen is cut off.Please also note that for this to work we need to use
NestedScrollViewor at least regularScrollViewwithnestedScrollEnabled={true}."Transparent" header
The intention of this PR is to focus only on layout-related props, not the final API. I added
transparentthat changes the layout but not the background color.Another thing to consider is whether
transparentprop should exist or should it be layout-related only & background color can be handled via regular stylingbackgroundColorprop. This might be even more useful for iOS implementation of the header but it needs to be researched (cc @kmichalikk)."Transparent" header does not make sense with scrolling behaviors because the content will never be rendered below the header. That's why in this mode the screen fills the entire container and does not move.
Custom subviews sizing
In Stack v4 we faced many problems with handling custom subviews. For Stack v5 we decided that the size of
leading(leftin LTR,rightin RTL),centerandtrailing(rightin LTR,leftin RTL) subviews will be determined fully by Yoga and it must depend on the size of the content inside of the subview. Subviews will not be aware of the position and size of other subviews and flex behaviors will not work (but obviously if you wrap subview content in a view with specificwidthandheight, inside the wrapperflexwill work as usual). The layout of the subview (position in the toolbar) will be determined fully by the native layout.This simplified approach will allow us to reliably layout the subviews without Yoga and native layout fighting each other.
Stack v5 will additionally support
backgroundsubview for collapsing headers (mediumandlarge). This view will be rendered as the background of the header. The subview will always be resized to the full size of theAppBarLayout(usingflexwill be supported).Layout synchronization between Yoga and native
Note
The background subview might have
collapseMode: 'parallax'effect applied - then it's position moves slower thanAppBarLayout. Light blue box represents background subview withcollapseMode: 'parallax'as this is the most complicated example, best for the explanation.Problems with custom subview ordering
Title in small header
When
smallheader is used, we would usually rely on title prop fromToolbar. There is however one caveat when using custom views - title is laid out before subviews therefore leading subview will always appear after the title. This in isolation might not be considered a problem (due to this being a native behavior) but inmedium/largecollapsing header, title is managed byCollapsingToolbarLayoutwhich doesn't use the title fromToolbarbut attaches its own customdummyViewas a subview ofToolbar-> in this configuration if you add a subview withGravity.START, it will be laid out before the title. We don't want the behavior to differ in such manner between header types so we decided that we should align this. It's easier to manage title insmallbehavior and leavemedium/largeas is (but it turns out that for RTL we need to do some hacky workarounds for collpsing header either way, details below).To ensure that title is laid out after the leading subview, we don't rely on
Toolbar.titlebut add our own view that tries to 1:1 mimic native title view.Ensuring correct subview order in RTL
For
smallheader, we need to make sure that leading subview and title view is added in correct order.For
medium/largeheader, the situation is more complicated.dummyViewis always added last, after our subviews. This causes incorrect ordering. In order to fix this, we need to make sure thatdummyViewis created and attached (by forcingmeasure). Then we manually change the order of the subviews (movingdummyViewto the index 0 fixes the problem).Known issues & future upgrades related to layout
Adding/removing subviews in runtime
The order of inserting subviews is critical for correct layout and it is very fragile. For now, when subviews are added/removed in runtime, we're recreating the hierarchy from scratch.
rebuild_subview.mp4
In the future we might research whether we can optimize this to prevent unnecessary rebuilds.
Background subview collapse mode
Currently,
backgroundsubview supports only 2 of 3 availablecollapseModes:offandparallax. As we wanted to allow usingflex: 1inside thebackgroundsubview, we need to set the size of theHeaderConfigcomponent & use absolute fill on the subview so that it matchesAppBarLayout. This however is problematic forpincollapse mode. If subview takes all the space, it behaves exactly likeoffcollapse mode - there is no additional space between the bottom edge of the header subview and bottom edge ofAppBarLayout/CollapsingToolbarLayoutso it scrolls immediately.In the future, we can consider exposing maunal size configuration for the subview (or finding another solution) to support
collapseMode: 'pin'.Maintaining scroll on rebuild
Currently, when header needs rebuilding, it doesn't preserve
AppBarLayout's scroll position and it expands automatically.rebuild_scroll.mp4
We should research whether we can cache the scroll offset between rebuilds.
hitSlophitSlopworks correctly now but in the context of native hierarchy. When in collapsing toolbar you click belowMaterialToolbareven though thehitSloprange would be enough, the touch won't be registered. Additionally, collapsing header usesdummyViewthat does not display any text when header is expanded but it will prevent touches which might be surprising for users.We should consider supporting
hitSlopin fullHeaderConfigarea.Margins for header items
Margins between subviews are inconsistent due to differences in layout.
smalllargeWe should consider exposing full margin configuration for subviews (it's a little bit complicated with the margins used in native layout) and maybe provide more consistent defaults.
Scroll flags
Android allows full customization of scrolling behavior.
We need to expose the scroll flags in one of the follow-up PRs. They are hard-coded for now.
Handling insets
For now, we're relying on
fitsSystemWindowonAppBarLayoutto apply padding to avoid system bars and display cutout. This will be problematic for nested stack support, using insets from decor view in first render and maunal control (similar to #3835).In the future, we should manually apply the inset (but I remember from my research that this might be more difficult than it seems). If insets are read from decor, we might be able to use choreographer for
requestLayoutinStackContainerinstead ofpost(currently, using choreographer as in tabs causes layout jump on first render after rebuild).Title layout customization
We need to add support for centering the title & handle our managed title view in
smallheader.Navigation icon and menu
We need to add navigation icon (back button) and allow its customization. In the future we want to also expose Menu API.
RTL text ellipsize bug
There are some problems with text ellipsize in RTL when custom subviews are used.
rtl_ellipisze_bug.mp4
We should check whether this is a native bug or not.
Changes
JS
StackHeaderConfigcomponent with Android base implementation and skeleton for iOSStackHeaderSubviewcomponentreact-native.config.jsStackContainerto accept header configuration as a prop and render header config component if the prop is non-nullNative
detachFromCurrentParentViewextensionShadowStateProxyfromStackScreento allow re-use between componentsStackHeaderConfigandStackHeaderSubviewOnHeaderConfigAttachListener,OnHeaderConfigChangeListenerinterfacesrequestLayoutworksC++
StackHeaderConfigandStackHeaderSubviewHeaderSubviewVisual documentation
general.mp4
small_opaque.mp4
small_transparent.mp4
large_opaque.mp4
large_transparent.mp4
Medium is analogous to large, just differs in size.
Test plan
Run
test-stack-subviews.tsx. This test contains many props as subview layout is depended on many factors but some parts of the test should be extracted and tested separately (e.g. hidden, transparent, ...). With @LKuchno we decided that this SFT will be split and scenarios will be added in the future as some parts of the implementation might still change.Checklist