Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -2,29 +2,41 @@ package expo.modules.t3nativecontrols

import android.content.Context
import android.view.ViewGroup
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.selection.selectable
import androidx.compose.foundation.selection.selectableGroup
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.Icon
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.NavigationBar
import androidx.compose.material3.NavigationBarItem
import androidx.compose.material3.NavigationBarItemDefaults
import androidx.compose.material3.NavigationRail
import androidx.compose.material3.NavigationRailItem
import androidx.compose.material3.NavigationRailItemDefaults
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.ComposeView
import androidx.compose.ui.platform.ViewCompositionStrategy
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import expo.modules.kotlin.AppContext
import expo.modules.kotlin.modules.Module
Expand All @@ -42,6 +54,7 @@ class T3WorkspaceNavigationModule : Module() {
Prop("rail") { view: T3WorkspaceNavigationView, value: Boolean -> view.rail = value }
Prop("dark") { view: T3WorkspaceNavigationView, value: Boolean -> view.dark = value }
Prop("textScale") { view: T3WorkspaceNavigationView, value: Float -> view.textScale = value }
Prop("iconSize") { view: T3WorkspaceNavigationView, value: Float -> view.iconSize = value }
Prop("containerColor") { view: T3WorkspaceNavigationView, value: Int ->
view.containerColor = value
}
Expand All @@ -59,7 +72,7 @@ class T3WorkspaceNavigationModule : Module() {
}
}

/** One Material3 selection surface moves from below the list to its leading edge. */
/** Compact windows use an icon pill; expanded windows retain the Material navigation rail. */
class T3WorkspaceNavigationView(
context: Context,
appContext: AppContext
Expand All @@ -72,6 +85,7 @@ class T3WorkspaceNavigationView(
var rail by mutableStateOf(false)
var dark by mutableStateOf(false)
var textScale by mutableStateOf(1f)
var iconSize by mutableStateOf(24f)
var containerColor by mutableStateOf(0)
var selectedColor by mutableStateOf(0)
var indicatorColor by mutableStateOf(0)
Expand Down Expand Up @@ -100,8 +114,12 @@ class T3WorkspaceNavigationView(
}

init {
clipChildren = false
clipToPadding = false
addView(
ComposeView(context).apply {
clipChildren = false
clipToPadding = false
setViewCompositionStrategy(
ViewCompositionStrategy.DisposeOnDetachedFromWindowOrReleasedFromPool
)
Expand Down Expand Up @@ -136,29 +154,43 @@ class T3WorkspaceNavigationView(
Spacer(Modifier.weight(1f))
}
} else {
NavigationBar(
Surface(
modifier = Modifier.fillMaxSize(),
containerColor = Color(containerColor),
tonalElevation = androidx.compose.ui.unit.Dp(0f),
windowInsets = WindowInsets(0),
shape = CircleShape,
color = Color(containerColor),
// Match the resting elevation of the Compose New thread FAB.
shadowElevation = 6.dp,
) {
tabs.forEach { tab ->
NavigationBarItem(
selected = selectedTab == tab,
onClick = { onTabSelected(mapOf("tab" to tab)) },
icon = { tabIcon(tab) },
label = {
val title = if (tab == "threads") "Chats" else "Pull requests"
Text(title, fontSize = (12 * textScale).sp)
},
colors = NavigationBarItemDefaults.colors(
selectedIconColor = Color(selectedColor),
selectedTextColor = Color(selectedColor),
indicatorColor = Color(indicatorColor),
unselectedIconColor = Color(unselectedColor),
unselectedTextColor = Color(unselectedColor),
),
)
Row(
modifier = Modifier.fillMaxSize().selectableGroup(),
verticalAlignment = Alignment.CenterVertically,
) {
tabs.forEach { tab ->
val selected = selectedTab == tab
Box(
modifier = Modifier
.weight(1f)
.fillMaxHeight()
.clip(CircleShape)
.selectable(
selected = selected,
role = Role.Tab,
onClick = { onTabSelected(mapOf("tab" to tab)) },
)
.padding(4.dp)
.background(
if (selected) Color(indicatorColor) else Color.Transparent,
CircleShape,
),
contentAlignment = Alignment.Center,
) {
tabIcon(
tab,
Color(if (selected) selectedColor else unselectedColor),
Modifier.size(iconSize.dp),
)
}
}
}
}
}
Expand All @@ -170,7 +202,11 @@ class T3WorkspaceNavigationView(
}

@Composable
private fun tabIcon(tab: String) {
private fun tabIcon(
tab: String,
tint: Color = LocalContentColor.current,
modifier: Modifier = Modifier
) {
val drawable = if (tab == "threads") {
R.drawable.t3_chats
} else {
Expand All @@ -179,6 +215,8 @@ class T3WorkspaceNavigationView(
Icon(
painter = painterResource(drawable),
contentDescription = if (tab == "threads") "Chats" else "Pull requests",
modifier = modifier,
tint = tint,
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ export function MaterialNewThreadButton(props: {
readonly onPress: () => void;
readonly extended?: boolean;
readonly expanded?: boolean;
readonly maxWidth?: number;
readonly className?: string;
readonly style?: StyleProp<ViewStyle>;
}) {
Expand Down
42 changes: 20 additions & 22 deletions apps/mobile/src/components/MaterialScrollComposeButton.android.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ import { Box, ExtendedFloatingActionButton, Host, Icon, Text } from "@expo/ui/je
import {
defaultMinSize,
fillMaxWidth,
graphicsLayer,
height,
onSizeChanged,
size,
Expand All @@ -16,26 +15,31 @@ import { resolveScaledTextRole } from "../lib/appearancePreferences";
/** Keep the animated width and icon positioning entirely inside Compose, not Yoga. */
export function MaterialScrollComposeButton(props: {
readonly expanded: boolean;
readonly maxWidth?: number;
readonly onPress: () => void;
readonly className?: string;
readonly style?: StyleProp<ViewStyle>;
}) {
const { appearance, themeAppearance, themeVariables: colors } = useAppearancePreferences();
const typography = resolveScaledTextRole("footnote", appearance.baseFontSize);
const { iconSize, fabSize } = useAndroidControlSizing();
// Scale the native 56dp minimum; keep text and icons at their requested sizes.
const nativeSize = Math.max(56, fabSize);
const scale = fabSize / nativeSize;
const nativeIconSize = Math.round(iconSize / scale);
const [buttonWidth, setButtonWidth] = useState(nativeSize);
const [buttonWidth, setButtonWidth] = useState(fabSize);
// Treat measurements at the width limit as overflow, allowing for native pixel rounding.
const expanded =
props.expanded &&
(props.maxWidth === undefined || Math.ceil(buttonWidth) < Math.floor(props.maxWidth));
const rememberWidth = useCallback(({ width }: { width: number }) => {
setButtonWidth((previous) => Math.max(previous, width));
}, []);
return (
<View
pointerEvents="box-none"
className={props.className}
style={[props.style, { left: 20, height: fabSize }]}
style={[
props.style,
{ height: fabSize },
props.maxWidth === undefined ? { left: 20 } : { width: Math.max(fabSize, props.maxWidth) },
]}
>
<View pointerEvents="none" importantForAccessibility="no-hide-descendants">
<Host
Expand All @@ -46,25 +50,19 @@ export function MaterialScrollComposeButton(props: {
>
<Box modifiers={[fillMaxWidth()]} contentAlignment="centerEnd">
<ExtendedFloatingActionButton
expanded={props.expanded}
expanded={expanded}
containerColor={colors["--color-primary"]}
modifiers={[
defaultMinSize({ minWidth: nativeSize }),
height(nativeSize),
graphicsLayer({
scaleX: scale,
scaleY: scale,
transformOriginX: 1,
transformOriginY: 0,
}),
defaultMinSize({ minWidth: fabSize }),
height(fabSize),
onSizeChanged(rememberWidth),
]}
>
<ExtendedFloatingActionButton.Icon>
<Box modifiers={[size(nativeIconSize, nativeIconSize)]}>
<Box modifiers={[size(iconSize, iconSize)]}>
<Icon
source={require("../../assets/icons/compose.xml")}
size={nativeIconSize}
size={iconSize}
tint={colors["--color-primary-foreground"]}
/>
</Box>
Expand All @@ -73,8 +71,8 @@ export function MaterialScrollComposeButton(props: {
<Text
color={colors["--color-primary-foreground"]}
style={{
fontSize: typography.fontSize / scale,
lineHeight: typography.lineHeight / scale,
fontSize: typography.fontSize,
lineHeight: typography.lineHeight,
fontWeight: "500",
}}
>
Expand All @@ -97,8 +95,8 @@ export function MaterialScrollComposeButton(props: {
top: 0,
bottom: 0,
// Release the label area as soon as collapse starts, before native measurements arrive.
width: props.expanded ? buttonWidth * scale : fabSize,
borderRadius: 16 * scale,
width: expanded ? buttonWidth : fabSize,
borderRadius: 16,
overflow: "hidden",
}}
/>
Expand Down
14 changes: 14 additions & 0 deletions apps/mobile/src/features/home/AndroidHomeFab.android.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,16 +7,21 @@ import {
} from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { MaterialNewThreadButton } from "../../components/MaterialNewThreadButton";
import { useAndroidControlSizing } from "../../components/useAndroidControlSizing";
import type { AndroidHomeFabLayout as SharedAndroidHomeFabLayout } from "./AndroidHomeFab.shared";
import { useWorkspaceState } from "../../state/workspace";
import { MaterialFabScrollContext } from "./MaterialFabScrollContext";
import { updateMaterialFabScroll } from "./material-fab-scroll";
import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider";
import { useWorkspaceTabs } from "../workspace/WorkspaceTabsContext";
import { ANDROID_WORKSPACE_DOCK } from "../workspace/workspace-tabs-state";

export function AndroidHomeFabLayout(props: ComponentProps<typeof SharedAndroidHomeFabLayout>) {
const insets = useSafeAreaInsets();
const { appearance } = useAppearancePreferences();
const { fontScale } = useWindowDimensions();
const { scale } = useAndroidControlSizing();
const workspace = useWorkspaceTabs();
const [layoutWidth, setLayoutWidth] = useState<number | null>(null);
const { state } = useWorkspaceState();
const [expanded, setExpanded] = useState(true);
Expand All @@ -41,6 +46,15 @@ export function AndroidHomeFabLayout(props: ComponentProps<typeof SharedAndroidH
key={`${appearance.baseFontSize}:${fontScale}:${layoutWidth}`}
extended
expanded={expanded}
maxWidth={
workspace && !props.sidebar
? layoutWidth -
Math.max(insets.left, ANDROID_WORKSPACE_DOCK.edge) -
ANDROID_WORKSPACE_DOCK.tabsWidth * Math.max(1, scale) -
ANDROID_WORKSPACE_DOCK.gap -
20
: undefined
}
onPress={props.onStartNewTask}
className="absolute right-5"
style={{
Expand Down
7 changes: 5 additions & 2 deletions apps/mobile/src/features/home/HomeScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -942,7 +942,10 @@ export function HomeScreen(props: HomeScreenProps) {
Platform.OS === "android" && "overflow-hidden rounded-t-[28px]",
)}
style={{
paddingBottom: Platform.OS === "ios" ? iosBottomClearance : Math.max(insets.bottom, 24),
paddingBottom:
Platform.OS === "ios"
? iosBottomClearance
: Math.max(insets.bottom, workspace?.bottomClearance ?? 0, 24),
paddingTop: NATIVE_LIQUID_GLASS_SUPPORTED ? insets.top + 72 : 0,
paddingLeft: 32 + (contentSideInsets?.left ?? 0),
paddingRight: 32 + (contentSideInsets?.right ?? 0),
Expand Down Expand Up @@ -1030,7 +1033,7 @@ export function HomeScreen(props: HomeScreenProps) {
<View className="flex-1 bg-header">
<View
className="flex-1 items-center justify-center overflow-hidden rounded-t-[28px] bg-screen px-4"
style={{ paddingBottom: insets.bottom }}
style={{ paddingBottom: Math.max(insets.bottom, workspace?.bottomClearance ?? 0) }}
>
{v2ListEmpty}
</View>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import { useAndroidControlSizing } from "../../components/useAndroidControlSizin
import { androidWorkspaceNavigationColors } from "./android-workspace-navigation-colors";
import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider";
import { useWorkspaceTabs } from "./WorkspaceTabsContext";
import type { WorkspaceTab } from "./workspace-tabs-state";
import { ANDROID_WORKSPACE_DOCK, type WorkspaceTab } from "./workspace-tabs-state";

export const ANDROID_WORKSPACE_RAIL_WIDTH = 80;

Expand All @@ -24,6 +24,7 @@ interface NativeNavigationProps extends ViewProps {
readonly selectedTab: WorkspaceTab;
readonly dark: boolean;
readonly textScale: number;
readonly iconSize: number;
readonly containerColor: number;
readonly selectedColor: number;
readonly indicatorColor: number;
Expand All @@ -47,7 +48,7 @@ export function AndroidWorkspaceNavigation(props: {
const insets = useSafeAreaInsets();
const keyboardVisible = useKeyboardState((state) => state.isVisible);
const { themeAppearance, themeVariables: colors } = useAppearancePreferences();
const { scale } = useAndroidControlSizing();
const { scale, iconSize, fabSize } = useAndroidControlSizing();
const rail = Boolean(props.rail);
const { containerColor, indicatorColor, selectedColor, unselectedColor } =
androidWorkspaceNavigationColors(colors, rail);
Expand All @@ -64,14 +65,15 @@ export function AndroidWorkspaceNavigation(props: {
paddingTop: insets.top,
paddingBottom: insets.bottom,
}
: { paddingBottom: insets.bottom }),
: { borderRadius: 999 }),
}}
>
<NativeNavigation
rail={rail}
selectedTab={workspace.state.selectedTab}
dark={themeAppearance === "dark"}
textScale={scale}
iconSize={iconSize}
containerColor={nativeColor(containerColor)}
selectedColor={nativeColor(selectedColor)}
indicatorColor={nativeColor(indicatorColor)}
Expand All @@ -87,7 +89,14 @@ export function AndroidWorkspaceNavigation(props: {
}
props.onSelect?.();
}}
style={rail ? { flex: 1 } : { height: 80 * Math.max(1, scale) }}
style={
rail
? { flex: 1 }
: {
width: ANDROID_WORKSPACE_DOCK.tabsWidth * Math.max(1, scale),
height: fabSize,
}
}
/>
</View>
);
Expand Down
Loading
Loading