Commit 6b51e5e
authored
feat(Android, Stack v5): handle header configuration and custom subviews (#3796)
## Description
Adds support for header configuration and providing custom subviews for
M3 App Bar in Stack v5.
Closes
software-mansion/react-native-screens-labs#916.
### Details
#### JS API
After some discussions we decided that the API for header configuration
will look as follows:
1. `Stack` object will provide a `Stack.HeaderConfig` component only.
2. All header configuration (and header subview configuration) will be
done via props on `Stack.HeaderConfig` component.
3. `Stack.HeaderConfig` will be split in similar way to `TabsHost` and
`TabsScreen` between platforms:
- common props will be available on both platforms (`title`, `hidden`),
- platform-specific props will be available via `android`/`ios` prop.
4. Due to differences between platforms, components related to
subviews/bar button items will be separate (e.g. `StackHeaderSubview`
will be Android-only).
#### High-level flow of updates
##### Header attachment
1. `StackHeaderConfig` is attached to `StackScreen`.
2. `StackHeaderCoordinatorLayout` creates `StackHeaderCoordinator`
instance. `CL` attaches its own `onHeaderHeightChanged` callback to the
coordinator. It will be called when `AppBarLayout` is scrolled and will
propagate correct Y offset to `StackScreen`.
3. `StackHeaderCoordinatorLayout` attaches its own callback as
`onHeaderConfigAttachListener` on `StackScreen` (weak ref) and if
`stackScreen.headerConfig` is available, it uses the config.
3. `StackHeaderCoordinatorLayout.handleHeaderConfigAttach` runs and
attaches its own callback as `onHeaderConfigChangeListener` on new
config (weak ref). This callback passes all updates to
`headerCoordinator` via `applyHeaderConfig` (it also batches the
updates). Initial update is performed.
##### Subview attachment
1. `StackHeaderSubview` is attached to `StackHeaderConfig`.
2. `StackHeaderSubview` attaches itself as
`onStackHeaderSubviewChangeListener` (weak ref) to the subview (to
inform `HeaderCoordinator` about e.g. `collapseMode` prop change on the
subview).
3. `HeaderConfig` informs `HeaderCoordinator` via
`onConfigChangeListener` about the new subview.
##### Offset shadow tree update
1. When scrolling event happens,
`AppBarLayout.ScrollingViewBehavior.onDependentViewChanged` runs.
2. We override this method in `StackHeaderScrollingViewBehavior` to
allow `headerCoordinator` to attach its own callback.
3. This callback allows to update `StackScreen`'s offset via
`onHeaderHeightChanged` callback.
5. Additionally, we attach layout and offset listeners to `AppBarLayout`
(so that it works with both non-transparent and transparent header).
They update size & offset for `HeaderConfig` and offsets for
`HeaderSubview`s via `syncShadowState` method.
#### Scrolling behaviors
M3 App Bar suppors various scrolling behaviors. In order to use them, we
need to use `ScrollingViewBehavior` via `CoordinatorLayout`. 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 by `CoordinatorLayout` so 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 `NestedScrollView`
or at least regular `ScrollView` with `nestedScrollEnabled={true}`.
#### "Transparent" header
The intention of this PR is to focus only on layout-related props, not
the final API. I added `transparent` that changes the layout but not the
background color.
Another thing to consider is whether `transparent` prop should exist or
should it be layout-related only & background color can be handled via
regular styling `backgroundColor` prop. 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` (`left` in LTR, `right`
in RTL), `center` and `trailing` (`right` in LTR, `left` in 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 specific
`width` and `height`, inside the wrapper `flex` will 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 `background` subview for collapsing
headers (`medium` and `large`). This view will be rendered as the
background of the header. The subview will always be resized to the full
size of the `AppBarLayout` (using `flex` will be supported).
#### Layout synchronization between Yoga and native
> [!NOTE]
>
> The background subview might have `collapseMode: 'parallax'` effect
applied - then it's position moves slower than `AppBarLayout`. Light
blue box represents background subview with `collapseMode: 'parallax'`
as this is the most complicated example, best for the explanation.
<img width="5137" height="4927" alt="headerNativeLayout_v2"
src="https://github.com/user-attachments/assets/7f4fd50f-ce1a-409b-b37c-acb82a31ffc8"
/>
<img width="6237" height="4911" alt="headerReactLayout1_v2"
src="https://github.com/user-attachments/assets/6743bae4-6450-4071-ba82-69d2459491db"
/>
<img width="5301" height="4284" alt="headerReactLayout2_v2"
src="https://github.com/user-attachments/assets/2522d3fa-309e-4743-9d11-30a578e72455"
/>
#### Problems with custom subview ordering
##### Title in small header
When `small` header is used, we would usually rely on title prop from
`Toolbar`. 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 in `medium`/`large`
collapsing header, title is managed by `CollapsingToolbarLayout` which
doesn't use the title from `Toolbar` but attaches its own custom
`dummyView` as a subview of `Toolbar` -> in this configuration if you
add a subview with `Gravity.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 in `small` behavior and leave `medium`/`large` as 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.title` but add our own view that tries to 1:1 mimic
native title view.
##### Ensuring correct subview order in RTL
For `small` header, we need to make sure that leading subview and title
view is added in correct order.
For `medium`/`large` header, the situation is more complicated.
`dummyView` is always added last, after our subviews. This causes
incorrect ordering. In order to fix this, we need to make sure that
`dummyView` is created and attached (by forcing `measure`). Then we
manually change the order of the subviews (moving `dummyView` to 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.
https://github.com/user-attachments/assets/30da0d3e-30f5-4f10-8a2d-910adcaffecb
In the future we might research whether we can optimize this to prevent
unnecessary rebuilds.
##### Background subview collapse mode
Currently, `background` subview supports only 2 of 3 available
`collapseMode`s: `off` and `parallax`. As we wanted to allow using
`flex: 1` inside the `background` subview, we need to set the size of
the `HeaderConfig` component & use absolute fill on the subview so that
it matches `AppBarLayout`. This however is problematic for `pin`
collapse mode. If subview takes all the space, it behaves exactly like
`off` collapse mode - there is no additional space between the bottom
edge of the header subview and bottom edge of
`AppBarLayout`/`CollapsingToolbarLayout` so 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.
https://github.com/user-attachments/assets/7d688874-c060-402f-a631-7cdcb225a310
We should research whether we can cache the scroll offset between
rebuilds.
##### `hitSlop`
`hitSlop` works correctly now but in the context of native hierarchy.
When in collapsing toolbar you click below `MaterialToolbar` even though
the `hitSlop` range would be enough, the touch won't be registered.
Additionally, collapsing header uses `dummyView` that does not display
any text when header is expanded but it will prevent touches which might
be surprising for users.
We should consider supporting `hitSlop` in full `HeaderConfig` area.
##### Margins for header items
Margins between subviews are inconsistent due to differences in layout.
| `small` | `large` |
| --- | --- |
| <img width="1080" height="2220" alt="margin_small"
src="https://github.com/user-attachments/assets/902eaf5b-4ace-4a00-a74f-3a30b2df7173"
/> | <img width="1080" height="2220" alt="margin_large"
src="https://github.com/user-attachments/assets/28f4bbb7-ac10-4e2f-9abb-18ab4fe828a2"
/> |
We 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 `fitsSystemWindow` on `AppBarLayout` to 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
`requestLayout` in `StackContainer` instead of `post` (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 `small` header.
##### 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.
https://github.com/user-attachments/assets/5531e385-8f44-4932-bf68-a0c4225b57d0
We should check whether this is a native bug or not.
## Changes
### JS
- refactored file structure to match convention from Tabs
- added shared `StackHeaderConfig` component with Android base
implementation and skeleton for iOS
- added Android-only `StackHeaderSubview` component
- added Fabric specs to new components
- updated `react-native.config.js`
- adjusted `StackContainer` to accept header configuration as a prop and
render header config component if the prop is non-null
- fixed debug logs to avoid infinite recursive calls
### Native
- added `detachFromCurrentParent` `View` extension
- extracted `ShadowStateProxy` from `StackScreen` to allow re-use
between components
- moved header-related files to separate package
- added native implementation of `StackHeaderConfig` and
`StackHeaderSubview`
- added `OnHeaderConfigAttachListener`, `OnHeaderConfigChangeListener`
interfaces
- added logic to handle subviews in the header
- adjusted layout flow to ensure that `requestLayout` works
### C++
- added custom shadow nodes for `StackHeaderConfig` and
`StackHeaderSubview`
- ensured that RTL doesn't change absolute positioning for
`HeaderSubview`
## Visual documentation
| General |
| --- |
| <video
src="https://github.com/user-attachments/assets/82ce9856-1342-40b2-8812-0aebbfa8d952"
/> |
| Small opaque | Small translucent |
| --- | --- |
| <video
src="https://github.com/user-attachments/assets/fd644c78-fc47-4d4d-8e92-86c2a5733f63"
/> | <video
src="https://github.com/user-attachments/assets/520c0e3d-321e-4aed-a415-38ade883ace9"
/> |
| Large opaque | Large translucent |
| --- | --- |
| <video
src="https://github.com/user-attachments/assets/afeb6720-cd60-4f11-8549-7908856186f1"
/> | <video
src="https://github.com/user-attachments/assets/e999198c-82cd-4be8-916f-1832ad14bd84"
/> |
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
- [x] Included code example that can be used to test this change.
- [x] For visual changes, included screenshots / GIFs / recordings
documenting the change.
- [x] For API changes, updated relevant public types.
- [x] Ensured that CI passes1 parent 29b2a2b commit 6b51e5e
71 files changed
Lines changed: 2239 additions & 355 deletions
File tree
- android/src/main
- java/com/swmansion/rnscreens
- ext
- gamma
- common
- stack
- header
- config
- subview
- host
- screen
- header
- configuration
- jni
- apps/src
- tests/single-feature-tests/stack-v5
- test-stack-subviews-android
- common/cpp/react/renderer/components/rnscreens
- src
- components/gamma/stack
- header
- android
- host
- screen
- fabric/gamma/stack
Some content is hidden
Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 4 additions & 0 deletions
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
8 | 8 | | |
9 | 9 | | |
10 | 10 | | |
| 11 | + | |
| 12 | + | |
11 | 13 | | |
12 | 14 | | |
13 | 15 | | |
| |||
55 | 57 | | |
56 | 58 | | |
57 | 59 | | |
| 60 | + | |
| 61 | + | |
58 | 62 | | |
59 | 63 | | |
60 | 64 | | |
| |||
Lines changed: 4 additions & 0 deletions
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
52 | 52 | | |
53 | 53 | | |
54 | 54 | | |
| 55 | + | |
| 56 | + | |
| 57 | + | |
| 58 | + | |
Lines changed: 58 additions & 0 deletions
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
| 32 | + | |
| 33 | + | |
| 34 | + | |
| 35 | + | |
| 36 | + | |
| 37 | + | |
| 38 | + | |
| 39 | + | |
| 40 | + | |
| 41 | + | |
| 42 | + | |
| 43 | + | |
| 44 | + | |
| 45 | + | |
| 46 | + | |
| 47 | + | |
| 48 | + | |
| 49 | + | |
| 50 | + | |
| 51 | + | |
| 52 | + | |
| 53 | + | |
| 54 | + | |
| 55 | + | |
| 56 | + | |
| 57 | + | |
| 58 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
1 | | - | |
| 1 | + | |
2 | 2 | | |
3 | 3 | | |
4 | 4 | | |
| |||
10 | 10 | | |
11 | 11 | | |
12 | 12 | | |
13 | | - | |
14 | 13 | | |
15 | 14 | | |
16 | | - | |
| 15 | + | |
17 | 16 | | |
18 | 17 | | |
19 | | - | |
| 18 | + | |
20 | 19 | | |
21 | 20 | | |
22 | 21 | | |
| |||
36 | 35 | | |
37 | 36 | | |
38 | 37 | | |
39 | | - | |
| 38 | + | |
40 | 39 | | |
41 | 40 | | |
42 | 41 | | |
43 | 42 | | |
44 | 43 | | |
45 | | - | |
46 | | - | |
| 44 | + | |
47 | 45 | | |
48 | 46 | | |
49 | 47 | | |
| |||
56 | 54 | | |
57 | 55 | | |
58 | 56 | | |
59 | | - | |
60 | | - | |
61 | | - | |
62 | | - | |
63 | | - | |
64 | | - | |
65 | | - | |
66 | | - | |
67 | | - | |
68 | | - | |
69 | | - | |
| 57 | + | |
| 58 | + | |
70 | 59 | | |
71 | 60 | | |
72 | 61 | | |
| |||
80 | 69 | | |
81 | 70 | | |
82 | 71 | | |
83 | | - | |
| 72 | + | |
84 | 73 | | |
85 | 74 | | |
86 | 75 | | |
87 | | - | |
| 76 | + | |
88 | 77 | | |
89 | | - | |
| 78 | + | |
90 | 79 | | |
91 | 80 | | |
92 | 81 | | |
| |||
97 | 86 | | |
98 | 87 | | |
99 | 88 | | |
100 | | - | |
101 | | - | |
102 | | - | |
| 89 | + | |
| 90 | + | |
103 | 91 | | |
104 | 92 | | |
105 | 93 | | |
106 | 94 | | |
107 | 95 | | |
108 | 96 | | |
| 97 | + | |
| 98 | + | |
| 99 | + | |
| 100 | + | |
| 101 | + | |
| 102 | + | |
109 | 103 | | |
110 | 104 | | |
111 | 105 | | |
112 | 106 | | |
113 | 107 | | |
114 | 108 | | |
115 | 109 | | |
116 | | - | |
117 | | - | |
| 110 | + | |
| 111 | + | |
118 | 112 | | |
119 | | - | |
120 | | - | |
| 113 | + | |
| 114 | + | |
121 | 115 | | |
122 | 116 | | |
123 | 117 | | |
0 commit comments