Skip to content

Commit 9ac3ef0

Browse files
juliusmarmingeJulius Marminge
authored andcommitted
fix(mobile): stabilize iOS header item transitions (#8607)
Co-authored-by: Julius Marminge <julius@mac.lan> (cherry picked from commit 4669eab8e0e01d07141a2cb49ca21a9e15ae1429)
1 parent 6a31ea8 commit 9ac3ef0

7 files changed

Lines changed: 401 additions & 168 deletions

File tree

apps/mobile/src/components/CompactBrandTitle.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,9 @@ import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../native/native-glass";
1515
const IOS_NATIVE_LEADING_TITLE_OFFSET = -6;
1616
const IPAD_NATIVE_LEADING_TITLE_OFFSET = 7;
1717

18+
// Branding and connection status share an identity distinct from navigation buttons.
19+
export const BRAND_HEADER_ITEM_IDENTIFIER = "workspace-brand";
20+
1821
/**
1922
* Horizontal correction applied to content rendered in the brand title slot,
2023
* shared with the connection-status swap so both align identically.
@@ -76,6 +79,7 @@ export function renderCompactBrandHeaderItems(): NativeStackHeaderItem[] {
7679
{
7780
element: <CompactBrandTitle nativeLeadingItem />,
7881
hidesSharedBackground: true,
82+
identifier: BRAND_HEADER_ITEM_IDENTIFIER,
7983
type: "custom",
8084
},
8185
];

apps/mobile/src/features/home/WorkspaceConnectionTitle.tsx

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,11 @@ import { ActivityIndicator, Animated, Platform, Pressable, View } from "react-na
77

88
import { SymbolView } from "../../components/AppSymbol";
99
import { AppText as Text } from "../../components/AppText";
10-
import { brandTitleOffset, CompactBrandTitle } from "../../components/CompactBrandTitle";
10+
import {
11+
BRAND_HEADER_ITEM_IDENTIFIER,
12+
brandTitleOffset,
13+
CompactBrandTitle,
14+
} from "../../components/CompactBrandTitle";
1115
import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass";
1216
import { useWorkspaceState } from "../../state/workspace";
1317
import {
@@ -172,6 +176,7 @@ export function getConnectionAwareBrandHeaderOptions(opts: {
172176
/>
173177
),
174178
hidesSharedBackground: true,
179+
identifier: BRAND_HEADER_ITEM_IDENTIFIER,
175180
type: "custom",
176181
},
177182
],
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
# Mobile navigation headers
2+
3+
The iOS Home and thread routes share the root native stack in
4+
[`Stack.tsx`](../../apps/mobile/src/Stack.tsx). Keeping them in one navigation
5+
controller lets UIKit animate the header between routes. The iPad sidebar owns
6+
a separate, single-screen stack; Android uses its own in-flow headers.
7+
8+
On Liquid Glass iOS, Home keeps a transparent native title and renders its brand
9+
as a custom leading item. A custom `headerTitle` previously shortened the scroll
10+
fade and removed the blur behind the logo. Preserve the native title when
11+
changing the brand or its connection-status replacement.
12+
13+
Both brand states use `BRAND_HEADER_ITEM_IDENTIFIER` from
14+
[`CompactBrandTitle.tsx`](../../apps/mobile/src/components/CompactBrandTitle.tsx).
15+
The native-stack and react-native-screens patches forward custom item identifiers
16+
to `UIBarButtonItem.identifier`, just as they do for native buttons and menus.
17+
UIKit otherwise matches transition items using their position and content, so
18+
the brand needs an explicit identity separate from navigation controls. Connection
19+
status changes retain that identity. See [Apple's identifier documentation](https://developer.apple.com/documentation/uikit/uibarbuttonitem/identifier).
20+
21+
The react-native-screens patch caches leading, trailing, and center item groups
22+
independently. A button can belong to only one group: constructing another group
23+
with the same button removes it from the previous one. Unrelated menu updates
24+
must therefore preserve the other groups while UIKit may be animating them.
25+
Each cache includes the owning header config, its item values, and custom native
26+
item identities, so changed content or remounted event emitters still rebuild.
27+
28+
Custom header identifiers require native code generation and a new mobile build;
29+
an over-the-air JavaScript update alone is insufficient. The Android view manager
30+
implements the generated identifier setter as a no-op because this behavior is
31+
specific to iOS 26 and later.

docs/internals/overview.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,7 @@ already dispatch.
132132
## Related
133133

134134
- [Workspace layout](./workspace-layout.md), [Glossary](./glossary.md)
135+
- [Mobile navigation headers](./mobile-navigation.md)
135136
- [Remote environments](./remote.md), [Server updates](./server-updates.md)
136137
- [Resource telemetry](./resource-telemetry.md)
137138
- [Scripts](./scripts.md), [CI gates](./ci.md)
Lines changed: 103 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,30 @@
1+
diff --git a/lib/module/views/NativeStackView.native.js b/lib/module/views/NativeStackView.native.js
2+
index c342e90bffde9ad5044b6167bdde2e19be219270..5d3e4407aa1bb68b873cf164adf5a9f0b487bb0b 100644
3+
--- a/lib/module/views/NativeStackView.native.js
4+
+++ b/lib/module/views/NativeStackView.native.js
5+
@@ -370,6 +370,17 @@ export function NativeStackView({
6+
return /*#__PURE__*/_jsx(SafeAreaProviderCompat, {
7+
children: /*#__PURE__*/_jsx(ScreenStack, {
8+
style: styles.container,
9+
+ onFinishTransitioning: () => {
10+
+ // Surface UIKit's transition-completion callback to every route of
11+
+ // this navigator. Unlike transitionEnd, this also fires when a modal
12+
+ // finishes dismissing — where the presenting screen below receives no
13+
+ // appearance callbacks — and for a gesture-driven dismissal it fires
14+
+ // before the state pop, while the modal route is still the focused
15+
+ // one, so the event must not be targeted at a single route.
16+
+ navigation.emit({
17+
+ type: 'finishTransitioning'
18+
+ });
19+
+ },
20+
children: state.routes.concat(state.preloadedRoutes).map((route, index) => {
21+
const descriptor = descriptors[route.key] ?? preloadedDescriptors[route.key];
22+
const isFocused = state.index === index;
123
diff --git a/lib/module/views/useHeaderConfigProps.js b/lib/module/views/useHeaderConfigProps.js
2-
index 0b75c70b4e0d233ee3b5faaf9cfbc40d4f8ed494..eb174e3fde91a7783f132b3fb16b01175ec19220 100644
24+
index 9c21aa48cb580704aa17e59d9cdadcc612a1e4ff..a1b3a38368c5ef2257570f382e87df5d6f55b11e 100644
325
--- a/lib/module/views/useHeaderConfigProps.js
426
+++ b/lib/module/views/useHeaderConfigProps.js
5-
@@ -19,6 +19,12 @@
27+
@@ -19,6 +19,12 @@ const processBarButtonItems = (items, colors, fonts) => {
628
}
729
return item;
830
}
@@ -15,7 +37,7 @@ index 0b75c70b4e0d233ee3b5faaf9cfbc40d4f8ed494..eb174e3fde91a7783f132b3fb16b0117
1537
if (item.type === 'button' || item.type === 'menu') {
1638
if (item.type === 'menu' && item.menu == null) {
1739
throw new Error(`Menu item must have a 'menu' property defined: ${JSON.stringify(item)}`);
18-
@@ -79,7 +85,7 @@
40+
@@ -79,7 +85,7 @@ const processBarButtonItems = (items, colors, fonts) => {
1941
}
2042
return processedItem;
2143
}
@@ -24,7 +46,7 @@ index 0b75c70b4e0d233ee3b5faaf9cfbc40d4f8ed494..eb174e3fde91a7783f132b3fb16b0117
2446
}).filter(item => item != null);
2547
};
2648
const transformIcon = icon => {
27-
@@ -158,8 +164,12 @@
49+
@@ -158,8 +164,12 @@ export function useHeaderConfigProps({
2850
headerBack,
2951
route,
3052
title,
@@ -38,7 +60,7 @@ index 0b75c70b4e0d233ee3b5faaf9cfbc40d4f8ed494..eb174e3fde91a7783f132b3fb16b0117
3860
}) {
3961
const {
4062
direction
41-
@@ -255,6 +265,10 @@
63+
@@ -255,6 +265,10 @@ export function useHeaderConfigProps({
4264
tintColor,
4365
canGoBack
4466
});
@@ -49,7 +71,7 @@ index 0b75c70b4e0d233ee3b5faaf9cfbc40d4f8ed494..eb174e3fde91a7783f132b3fb16b0117
4971
let rightItems = headerRightItems?.({
5072
tintColor,
5173
canGoBack
52-
@@ -264,6 +278,10 @@
74+
@@ -264,6 +278,10 @@ export function useHeaderConfigProps({
5375
// So we need to reverse them here to match the order
5476
rightItems = [...rightItems].reverse();
5577
}
@@ -60,7 +82,14 @@ index 0b75c70b4e0d233ee3b5faaf9cfbc40d4f8ed494..eb174e3fde91a7783f132b3fb16b0117
6082
const children = /*#__PURE__*/_jsxs(_Fragment, {
6183
children: [Platform.OS === 'ios' ? /*#__PURE__*/_jsxs(_Fragment, {
6284
children: [leftItems ? leftItems.map((item, index) => {
63-
@@ -278,7 +296,15 @@
85+
@@ -272,13 +290,23 @@ export function useHeaderConfigProps({
86+
// eslint-disable-next-line @eslint-react/no-array-index-key
87+
, {
88+
hidesSharedBackground: item.hidesSharedBackground,
89+
+ identifier: item.identifier,
90+
children: item.element
91+
}, index);
92+
}
6493
return null;
6594
}) : headerLeftElement != null ? /*#__PURE__*/_jsx(ScreenStackHeaderLeftView, {
6695
children: headerLeftElement
@@ -69,6 +98,7 @@ index 0b75c70b4e0d233ee3b5faaf9cfbc40d4f8ed494..eb174e3fde91a7783f132b3fb16b0117
6998
+ if (item.type === 'custom') {
7099
+ return /*#__PURE__*/_jsx(ScreenStackHeaderCenterView, {
71100
+ hidesSharedBackground: item.hidesSharedBackground,
101+
+ identifier: item.identifier,
72102
+ children: item.element
73103
+ }, index);
74104
+ }
@@ -77,15 +107,23 @@ index 0b75c70b4e0d233ee3b5faaf9cfbc40d4f8ed494..eb174e3fde91a7783f132b3fb16b0117
77107
children: headerTitleElement
78108
}) : null]
79109
}) : /*#__PURE__*/_jsxs(_Fragment, {
80-
@@ -356,6 +382,7 @@
110+
@@ -321,6 +349,7 @@ export function useHeaderConfigProps({
111+
// eslint-disable-next-line @eslint-react/no-array-index-key
112+
, {
113+
hidesSharedBackground: item.hidesSharedBackground,
114+
+ identifier: item.identifier,
115+
children: item.element
116+
}, index);
117+
}
118+
@@ -356,6 +385,7 @@ export function useHeaderConfigProps({
81119
largeTitleFontWeight,
82120
largeTitleHideShadow: headerLargeTitleShadowVisible === false,
83121
title: titleText,
84122
+ subtitle: headerSubtitle,
85123
titleColor,
86124
titleFontFamily,
87125
titleFontSize,
88-
@@ -364,9 +391,12 @@
126+
@@ -364,6 +394,9 @@ export function useHeaderConfigProps({
89127
disableTopInsetApplication: !headerTopInsetEnabled,
90128
translucent: translucent === true,
91129
children,
@@ -95,30 +133,59 @@ index 0b75c70b4e0d233ee3b5faaf9cfbc40d4f8ed494..eb174e3fde91a7783f132b3fb16b0117
95133
headerLeftBarButtonItems: processBarButtonItems(leftItems, colors, fonts),
96134
headerRightBarButtonItems: processBarButtonItems(rightItems, colors, fonts),
97135
experimental_userInterfaceStyle: dark ? 'dark' : 'light'
98-
};
99-
}
100-
-//# sourceMappingURL=useHeaderConfigProps.js.map
101-
\ No newline at end of file
102-
+//# sourceMappingURL=useHeaderConfigProps.js.map
103-
diff --git a/lib/module/views/NativeStackView.native.js b/lib/module/views/NativeStackView.native.js
104-
index c342e90..5d3e440 100644
105-
--- a/lib/module/views/NativeStackView.native.js
106-
+++ b/lib/module/views/NativeStackView.native.js
107-
@@ -370,6 +370,17 @@ export function NativeStackView({
108-
return /*#__PURE__*/_jsx(SafeAreaProviderCompat, {
109-
children: /*#__PURE__*/_jsx(ScreenStack, {
110-
style: styles.container,
111-
+ onFinishTransitioning: () => {
112-
+ // Surface UIKit's transition-completion callback to every route of
113-
+ // this navigator. Unlike transitionEnd, this also fires when a modal
114-
+ // finishes dismissing — where the presenting screen below receives no
115-
+ // appearance callbacks — and for a gesture-driven dismissal it fires
116-
+ // before the state pop, while the modal route is still the focused
117-
+ // one, so the event must not be targeted at a single route.
118-
+ navigation.emit({
119-
+ type: 'finishTransitioning'
120-
+ });
121-
+ },
122-
children: state.routes.concat(state.preloadedRoutes).map((route, index) => {
123-
const descriptor = descriptors[route.key] ?? preloadedDescriptors[route.key];
124-
const isFocused = state.index === index;
136+
diff --git a/lib/typescript/src/types.d.ts b/lib/typescript/src/types.d.ts
137+
index 2f1351a89f0f2e67e854dc81e392198326b71834..ccf8556911fa1fa17588f06b56edb2151a712bf0 100644
138+
--- a/lib/typescript/src/types.d.ts
139+
+++ b/lib/typescript/src/types.d.ts
140+
@@ -1091,6 +1091,13 @@ export type NativeStackHeaderItemSpacing = {
141+
*/
142+
export type NativeStackHeaderItemCustom = {
143+
type: 'custom';
144+
+ /**
145+
+ * An identifier used to match items across transitions.
146+
+ * Only available from iOS 26.0 and later.
147+
+ *
148+
+ * Read more: https://developer.apple.com/documentation/uikit/uibarbuttonitem/identifier
149+
+ */
150+
+ identifier?: string;
151+
/**
152+
* A React Element to display as the item.
153+
*/
154+
diff --git a/src/types.tsx b/src/types.tsx
155+
index 7488b1cf20afe36b5c585fbfcd369471fdb6ab8d..f4bd78e48c687024ba84e998fac79e9eb16f1a80 100644
156+
--- a/src/types.tsx
157+
+++ b/src/types.tsx
158+
@@ -1156,6 +1156,13 @@ export type NativeStackHeaderItemSpacing = {
159+
*/
160+
export type NativeStackHeaderItemCustom = {
161+
type: 'custom';
162+
+ /**
163+
+ * An identifier used to match items across transitions.
164+
+ * Only available from iOS 26.0 and later.
165+
+ *
166+
+ * Read more: https://developer.apple.com/documentation/uikit/uibarbuttonitem/identifier
167+
+ */
168+
+ identifier?: string;
169+
/**
170+
* A React Element to display as the item.
171+
*/
172+
diff --git a/src/views/useHeaderConfigProps.tsx b/src/views/useHeaderConfigProps.tsx
173+
index 3f3fff405048b3c2250afbe99dd7f35cecb9a6d5..bb062b898cfa77f8c02f951dc9d81b51672d7d0e 100644
174+
--- a/src/views/useHeaderConfigProps.tsx
175+
+++ b/src/views/useHeaderConfigProps.tsx
176+
@@ -397,6 +397,7 @@ export function useHeaderConfigProps({
177+
// eslint-disable-next-line @eslint-react/no-array-index-key
178+
key={index}
179+
hidesSharedBackground={item.hidesSharedBackground}
180+
+ identifier={item.identifier}
181+
>
182+
{item.element}
183+
</ScreenStackHeaderLeftView>
184+
@@ -471,6 +472,7 @@ export function useHeaderConfigProps({
185+
// eslint-disable-next-line @eslint-react/no-array-index-key
186+
key={index}
187+
hidesSharedBackground={item.hidesSharedBackground}
188+
+ identifier={item.identifier}
189+
>
190+
{item.element}
191+
</ScreenStackHeaderRightView>

0 commit comments

Comments
 (0)