Skip to content

Commit dc84b09

Browse files
authored
feat(mobile): add One UI navigation preference on Android (#25758)
1 parent 36d4d71 commit dc84b09

20 files changed

Lines changed: 511 additions & 63 deletions

‎apps/mobile/modules/t3-native-controls/android/src/main/java/expo/modules/t3nativecontrols/T3WorkspaceNavigationModule.kt‎

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import androidx.compose.foundation.layout.WindowInsets
1414
import androidx.compose.foundation.layout.fillMaxHeight
1515
import androidx.compose.foundation.layout.fillMaxSize
1616
import androidx.compose.foundation.layout.height
17+
import androidx.compose.foundation.layout.size
1718
import androidx.compose.foundation.layout.padding
1819
import androidx.compose.foundation.layout.width
1920
import androidx.compose.foundation.selection.selectable
@@ -71,6 +72,11 @@ class T3WorkspaceNavigationModule : Module() {
7172
Prop("selectedTab") { view: T3WorkspaceNavigationView, value: String ->
7273
view.selectedTab = value
7374
}
75+
Prop("floating") { view: T3WorkspaceNavigationView, value: Boolean ->
76+
view.floating = value
77+
view.clipToPadding = !value
78+
view.clipChildren = !value
79+
}
7480
Prop("rail") { view: T3WorkspaceNavigationView, value: Boolean -> view.rail = value }
7581
Prop("dark") { view: T3WorkspaceNavigationView, value: Boolean -> view.dark = value }
7682
Prop("textScale") { view: T3WorkspaceNavigationView, value: Float -> view.textScale = value }
@@ -123,6 +129,7 @@ class T3WorkspaceNavigationView(
123129
var tabs by mutableStateOf(listOf("threads", "pullRequests", "usage", "settings"))
124130
var selectedTab by mutableStateOf("threads")
125131
var rail by mutableStateOf(false)
132+
var floating by mutableStateOf(false)
126133
var dark by mutableStateOf(false)
127134
var textScale by mutableStateOf(1f)
128135
var newEnabled by mutableStateOf(false)
@@ -161,13 +168,16 @@ class T3WorkspaceNavigationView(
161168
init {
162169
addView(
163170
ComposeView(context).apply {
171+
clipChildren = false
164172
setViewCompositionStrategy(
165173
ViewCompositionStrategy.DisposeOnDetachedFromWindowOrReleasedFromPool
166174
)
167175
setContent {
168176
MaterialTheme(colorScheme = if (dark) darkColorScheme() else lightColorScheme()) {
169177
if (rail) {
170178
rail()
179+
} else if (floating) {
180+
floatingTabs()
171181
} else {
172182
NavigationBar(
173183
modifier = Modifier.fillMaxSize(),
@@ -199,6 +209,48 @@ class T3WorkspaceNavigationView(
199209
)
200210
}
201211

212+
@Composable
213+
private fun floatingTabs() {
214+
Surface(
215+
modifier = Modifier.fillMaxSize().padding(8.dp),
216+
shape = CircleShape,
217+
color = Color(containerColor),
218+
shadowElevation = 6.dp,
219+
) {
220+
Row(
221+
modifier = Modifier.fillMaxSize().selectableGroup(),
222+
verticalAlignment = Alignment.CenterVertically,
223+
) {
224+
tabs.forEach { tab ->
225+
val selected = selectedTab == tab
226+
val tint = Color(if (selected) selectedColor else unselectedColor)
227+
Box(
228+
modifier = Modifier
229+
.weight(1f)
230+
.fillMaxHeight()
231+
.clip(CircleShape)
232+
.selectable(
233+
selected = selected,
234+
role = Role.Tab,
235+
onClick = { onTabSelected(mapOf("tab" to tab)) },
236+
)
237+
.semantics { contentDescription = expandedTabTitle(tab) }
238+
.padding(4.dp)
239+
.background(if (selected) Color(indicatorColor) else Color.Transparent, CircleShape),
240+
contentAlignment = Alignment.Center,
241+
) {
242+
Icon(
243+
tabIconPainter(tab),
244+
contentDescription = null,
245+
modifier = Modifier.size((24 * textScale).dp),
246+
tint = tint,
247+
)
248+
}
249+
}
250+
}
251+
}
252+
}
253+
202254
@Composable
203255
private fun rail() {
204256
// One rail surface widens in place over the list. Material's ModalWideNavigationRail

‎apps/mobile/src/features/home/AndroidHomeFab.android.tsx‎

Lines changed: 45 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { useCallback, useRef, useState, type ComponentProps } from "react";
1+
import { useCallback, useMemo, useRef, useState, type ComponentProps } from "react";
22
import {
33
useWindowDimensions,
44
View,
@@ -14,15 +14,41 @@ import {
1414
import { useWorkspaceState } from "../../state/workspace";
1515
import { FabScrollContext } from "./FabScrollContext";
1616
import { updateFabScroll } from "./fab-scroll";
17-
import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider";
17+
import { useAndroidControlSizing } from "../../components/ui/useAndroidControlSizing";
18+
import { useEnabledNavigationTabs } from "../workspace/navigation-tabs";
19+
import { WorkspaceTabsContext, useWorkspaceTabs } from "../workspace/WorkspaceTabsContext";
20+
import { FLOATING_TABS, floatingTabsMetrics } from "../workspace/floating-workspace-tabs";
21+
import {
22+
useAppearancePreferences,
23+
useAndroidFloatingTabs,
24+
} from "../settings/appearance/AppearancePreferencesProvider";
1825

1926
export function AndroidHomeFabLayout(props: ComponentProps<typeof SharedAndroidHomeFabLayout>) {
27+
const floatingTabsEnabled = useAndroidFloatingTabs();
2028
const insets = useSafeAreaInsets();
2129
const { appearance } = useAppearancePreferences();
2230
const { fontScale } = useWindowDimensions();
2331
const [layoutWidth, setLayoutWidth] = useState<number | null>(null);
2432
const { state } = useWorkspaceState();
2533
const [expanded, setExpanded] = useState(true);
34+
const { scale, fabSize } = useAndroidControlSizing();
35+
const tabs = useEnabledNavigationTabs();
36+
const floating = floatingTabsMetrics(
37+
tabs.length + 1,
38+
scale,
39+
fabSize,
40+
props.paneWidth ?? layoutWidth ?? undefined,
41+
);
42+
const workspace = useWorkspaceTabs();
43+
const bottomClearance =
44+
insets.bottom +
45+
FLOATING_TABS.bottom +
46+
FLOATING_TABS.edge +
47+
Math.max(floating.height, floating.fabOffset + fabSize);
48+
const context = useMemo(
49+
() => (floatingTabsEnabled && workspace ? { ...workspace, bottomClearance } : workspace),
50+
[floatingTabsEnabled, workspace, bottomClearance],
51+
);
2652
const scrollState = useRef({ anchor: 0, expanded: true });
2753
const onScroll = useCallback((event: NativeSyntheticEvent<NativeScrollEvent>) => {
2854
const { contentOffset, contentSize, layoutMeasurement } = event.nativeEvent;
@@ -36,19 +62,30 @@ export function AndroidHomeFabLayout(props: ComponentProps<typeof SharedAndroidH
3662
}, []);
3763

3864
// Remount only the FAB when its font or pane changes to clear the cached expanded width.
39-
// The split-view sidebar has no FAB: the navigation rail carries New thread. On phones it
40-
// keeps Material's 16dp corner margin; the bottom bar already consumes the gesture inset.
65+
// Floating navigation shares this row; Material split views put New thread in the rail.
4166
return (
4267
<View className="flex-1" onLayout={(event) => setLayoutWidth(event.nativeEvent.layout.width)}>
43-
<FabScrollContext value={onScroll}>{props.children}</FabScrollContext>
44-
{!props.disabled && !props.sidebar && state.hasConnections && layoutWidth !== null ? (
68+
<WorkspaceTabsContext value={context}>
69+
<FabScrollContext value={onScroll}>{props.children}</FabScrollContext>
70+
</WorkspaceTabsContext>
71+
{!props.disabled &&
72+
(!props.sidebar || floatingTabsEnabled) &&
73+
state.hasConnections &&
74+
layoutWidth !== null ? (
4575
<NewThreadButton
4676
key={`${appearance.baseFontSize}:${fontScale}:${layoutWidth}`}
4777
extended
48-
expanded={expanded}
78+
expanded={floatingTabsEnabled ? false : expanded}
4979
onPress={props.onStartNewTask}
5080
className="absolute"
51-
style={androidHomeFabPosition(insets.bottom, props.sidebar)}
81+
style={
82+
floatingTabsEnabled
83+
? {
84+
right: FLOATING_TABS.edge,
85+
bottom: insets.bottom + FLOATING_TABS.bottom + floating.fabOffset,
86+
}
87+
: androidHomeFabPosition(insets.bottom, props.sidebar)
88+
}
5289
/>
5390
) : null}
5491
</View>

‎apps/mobile/src/features/home/AndroidHomeFab.shared.tsx‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ export function AndroidHomeFabLayout(props: {
55
readonly children: ReactNode;
66
readonly sidebar?: boolean;
77
readonly disabled?: boolean;
8+
readonly paneWidth?: number;
89
}) {
910
return <>{props.children}</>;
1011
}

‎apps/mobile/src/features/home/ThreadUndoNotice.tsx‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { useAndroidFloatingTabs } from "../settings/appearance/AppearancePreferencesProvider";
12
import { useState } from "react";
23
import { useKeyboardState } from "react-native-keyboard-controller";
34
import { Platform, View } from "react-native";
@@ -18,6 +19,7 @@ import {
1819
} from "./thread-undo-notice";
1920

2021
export function ThreadUndoNotice(props: { readonly visible: boolean }) {
22+
const floatingTabsEnabled = useAndroidFloatingTabs();
2123
const notice = useThreadUndoNotice();
2224
const insets = useSafeAreaInsets();
2325
const columnMetrics = useNativeColumnLayoutMetrics();
@@ -64,7 +66,9 @@ export function ThreadUndoNotice(props: { readonly visible: boolean }) {
6466
columnMetrics?.safeArea.bottom ?? 0,
6567
(workspace?.bottomClearance ?? 0) - workspacePadding,
6668
) + 8
67-
: insets.bottom + 12;
69+
: floatingTabsEnabled && keyboardHeight === 0
70+
? Math.max(insets.bottom + 12, workspace?.bottomClearance ?? 0)
71+
: insets.bottom + 12;
6872
return (
6973
<View
7074
key={instance.key}

‎apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx‎

Lines changed: 80 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { useAndroidFloatingTabs } from "../settings/appearance/AppearancePreferencesProvider";
12
import { SafeAreaInsetsContext, useSafeAreaInsets } from "react-native-safe-area-context";
23
import { AdaptiveWorkspaceContext, useAdaptiveWorkspaceLayout } from "./adaptive-workspace-context";
34
import { threadSelectionDeferral } from "./deferred-navigation";
@@ -47,9 +48,13 @@ import {
4748
parseActiveThreadPath,
4849
useHardwareKeyboardCommand,
4950
} from "../keyboard/hardwareKeyboardCommands";
51+
import { useAndroidControlSizing } from "../../components/ui/useAndroidControlSizing";
52+
import { useEnabledNavigationTabs } from "../workspace/navigation-tabs";
53+
import { FLOATING_TABS, floatingTabsMetrics } from "../workspace/floating-workspace-tabs";
5054
import { AndroidHomeFabLayout } from "../home/AndroidHomeFab";
5155
import {
5256
AndroidWorkspaceNavigation,
57+
FloatingWorkspaceTabs,
5358
ANDROID_WORKSPACE_RAIL_WIDTH,
5459
} from "../workspace/AndroidWorkspaceNavigation";
5560
import { AndroidWorkspaceSidebar } from "../workspace/AndroidWorkspaceSidebar";
@@ -168,6 +173,7 @@ function AdaptiveWorkspaceLayoutContent(
168173
readonly savedThreadListFilters?: ThreadListFilters | undefined;
169174
},
170175
) {
176+
const floatingTabsEnabled = useAndroidFloatingTabs();
171177
const projectGroupingMode = props.projectGroupingMode;
172178
const nativeWorkspace = use(NativeWorkspaceModeContext);
173179
const windowDimensions = useWindowDimensions();
@@ -244,7 +250,11 @@ function AdaptiveWorkspaceLayoutContent(
244250
const showPrimarySidebar =
245251
!workspacePage &&
246252
(primarySidebarPinned || (preferredSidebarVisible && !composerKeyboardActive));
247-
const shouldRenderNavigationRail = Platform.OS === "android" && baseLayout.usesSplitView;
253+
const shouldRenderNavigationRail =
254+
Platform.OS === "android" && baseLayout.usesSplitView && !floatingTabsEnabled;
255+
const enabledTabs = useEnabledNavigationTabs();
256+
const { scale, fabSize } = useAndroidControlSizing();
257+
const floatingTabs = floatingTabsMetrics(enabledTabs.length + 1, scale, fabSize);
248258
const navigationRailWidth =
249259
shouldRenderNavigationRail && (showPrimarySidebar || workspacePage)
250260
? ANDROID_WORKSPACE_RAIL_WIDTH
@@ -255,16 +265,31 @@ function AdaptiveWorkspaceLayoutContent(
255265
setHinge(next);
256266
setWindowHinge(nextWindow);
257267
};
258-
const layout = useMemo(
259-
() =>
260-
alignListPaneToHinge({
261-
layout: baseLayout,
262-
hinge,
263-
viewportWidth,
264-
leadingWidth: navigationRailWidth,
265-
}),
266-
[baseLayout, hinge, navigationRailWidth, viewportWidth],
267-
);
268+
const layout = useMemo(() => {
269+
const aligned = alignListPaneToHinge({
270+
layout: baseLayout,
271+
hinge,
272+
viewportWidth,
273+
leadingWidth: navigationRailWidth,
274+
});
275+
if (
276+
!floatingTabsEnabled ||
277+
!aligned.usesSplitView ||
278+
aligned.listPaneGap !== undefined ||
279+
aligned.listPaneWidth === null ||
280+
aligned.listPaneWidth >= floatingTabs.row ||
281+
viewportWidth - floatingTabs.row < 320
282+
)
283+
return aligned;
284+
return { ...aligned, listPaneWidth: floatingTabs.row };
285+
}, [
286+
baseLayout,
287+
hinge,
288+
navigationRailWidth,
289+
viewportWidth,
290+
floatingTabs.row,
291+
floatingTabsEnabled,
292+
]);
268293
const width = viewportWidth - navigationRailWidth;
269294
// The sidebar stays mounted under a workspace page, so its list is ready on the way back.
270295
const shouldRenderPrimarySidebar = layout.usesSplitView;
@@ -733,7 +758,10 @@ function AdaptiveWorkspaceLayoutContent(
733758
}));
734759
const sidebarAnimatedStyle = useAnimatedStyle(() => ({
735760
// Fading a UIKit glass ancestor can leave remounted header effects missing.
736-
opacity: Platform.OS === "ios" ? 1 : Math.min(1, renderedSidebarWidth.value / 80),
761+
opacity:
762+
Platform.OS === "ios" || floatingTabsEnabled
763+
? 1
764+
: Math.min(1, renderedSidebarWidth.value / 80),
737765
width: renderedSidebarWidth.value,
738766
}));
739767

@@ -829,12 +857,12 @@ function AdaptiveWorkspaceLayoutContent(
829857
{shouldRenderPrimarySidebar && layout.listPaneWidth !== null ? (
830858
<Animated.View
831859
className="self-stretch overflow-hidden"
832-
accessibilityElementsHidden={!panes.primarySidebarVisible}
860+
accessibilityElementsHidden={workspacePage || !panes.primarySidebarVisible}
833861
collapsable={false}
834862
importantForAccessibility={
835-
panes.primarySidebarVisible ? "auto" : "no-hide-descendants"
863+
!workspacePage && panes.primarySidebarVisible ? "auto" : "no-hide-descendants"
836864
}
837-
pointerEvents={panes.primarySidebarVisible ? "auto" : "none"}
865+
pointerEvents={!workspacePage && panes.primarySidebarVisible ? "auto" : "none"}
838866
// Under a workspace page the sidebar leaves the row but keeps its size, so its
839867
// lists never lay out at zero size and are ready, unchanged, on the way back. The
840868
// content pane, laid out after it, covers it, in the same mount as the page.
@@ -857,7 +885,12 @@ function AdaptiveWorkspaceLayoutContent(
857885
onRequestVisibility={revealPrimarySidebar}
858886
selectedPullRequestKey={props.selectedPullRequestKey ?? null}
859887
>
860-
<AndroidHomeFabLayout sidebar onStartNewTask={handleStartNewTask}>
888+
<AndroidHomeFabLayout
889+
sidebar
890+
disabled={workspacePage}
891+
paneWidth={layout.listPaneWidth}
892+
onStartNewTask={handleStartNewTask}
893+
>
861894
<ThreadNavigationSidebar
862895
width={layout.listPaneWidth}
863896
visible={panes.primarySidebarVisible}
@@ -876,6 +909,14 @@ function AdaptiveWorkspaceLayoutContent(
876909
</AndroidHomeFabLayout>
877910
</AndroidWorkspaceSidebar>
878911
</RenderErrorBoundary>
912+
{/* Keep the pill under the content throughout the sidebar's clipping animation. */}
913+
{floatingTabsEnabled && !workspacePage ? (
914+
<FloatingWorkspaceTabs
915+
paneWidth={layout.listPaneWidth}
916+
onNewThread={handleStartNewTask}
917+
onSelect={revealPrimarySidebar}
918+
/>
919+
) : null}
879920
</Animated.View>
880921
</Animated.View>
881922
) : null}
@@ -913,14 +954,35 @@ function AdaptiveWorkspaceLayoutContent(
913954
{/* Compact Settings keeps the bottom navigation under all of its pages. */}
914955
<SafeAreaInsetsContext
915956
value={
916-
compactAndroidSettings ? { ...safeAreaInsets, bottom: 0 } : safeAreaInsets
957+
floatingTabsEnabled &&
958+
(compactAndroidSettings || (layout.usesSplitView && workspacePage))
959+
? {
960+
...safeAreaInsets,
961+
bottom:
962+
safeAreaInsets.bottom +
963+
FLOATING_TABS.bottom +
964+
FLOATING_TABS.edge +
965+
floatingTabs.height,
966+
}
967+
: compactAndroidSettings
968+
? { ...safeAreaInsets, bottom: 0 }
969+
: safeAreaInsets
917970
}
918971
>
919972
<View className="flex-1">
920973
<SettingsWorkspaceLayout>{props.children}</SettingsWorkspaceLayout>
921974
</View>
922975
</SafeAreaInsetsContext>
923-
{compactAndroidSettings ? (
976+
{floatingTabsEnabled &&
977+
(compactAndroidSettings || (layout.usesSplitView && workspacePage)) ? (
978+
<FloatingWorkspaceTabs
979+
paneWidth={
980+
layout.usesSplitView ? (layout.listPaneWidth ?? undefined) : undefined
981+
}
982+
fillWidth={compactAndroidSettings}
983+
onNewThread={handleStartNewTask}
984+
/>
985+
) : compactAndroidSettings ? (
924986
<AndroidWorkspaceNavigation onNewThread={handleStartNewTask} />
925987
) : null}
926988
</WorkspaceSettledContentWidthContext>

0 commit comments

Comments
 (0)