1import { Box, ExtendedFloatingActionButton, Host, Icon, Text } from "@expo/ui/jetpack-compose";
2import {
3 defaultMinSize,
4 fillMaxWidth,
5 graphicsLayer,
6 height,
7 onSizeChanged,
8 size,
9} from "@expo/ui/jetpack-compose/modifiers";
10import { useCallback, useState } from "react";
11import { Pressable, View, type StyleProp, type ViewStyle } from "react-native";
12import { useAndroidControlSizing } from "./useAndroidControlSizing";
13import { useAppearancePreferences } from "../features/settings/appearance/AppearancePreferencesProvider";
14import { resolveScaledTextRole } from "../lib/appearancePreferences";
15
16/** Keep the animated width and icon positioning entirely inside Compose, not Yoga. */
17export function MaterialScrollComposeButton(props: {
18 readonly expanded: boolean;
19 readonly onPress: () => void;
20 readonly className?: string;
21 readonly style?: StyleProp<ViewStyle>;
22}) {
23 const { appearance, themeAppearance, themeVariables: colors } = useAppearancePreferences();
24 const typography = resolveScaledTextRole("footnote", appearance.baseFontSize);
25 const { iconSize, fabSize } = useAndroidControlSizing();
26 // Scale the native 56dp minimum; keep text and icons at their requested sizes.
27 const nativeSize = Math.max(56, fabSize);
28 const scale = fabSize / nativeSize;
29 const nativeIconSize = Math.round(iconSize / scale);
30 const [buttonWidth, setButtonWidth] = useState(nativeSize);
31 const rememberWidth = useCallback(({ width }: { width: number }) => {
32 setButtonWidth((previous) => Math.max(previous, width));
33 }, []);
34 return (
35 <View
36 pointerEvents="box-none"
37 className={props.className}
38 style={[props.style, { left: 20, height: fabSize }]}
39 >
40 <View pointerEvents="none" importantForAccessibility="no-hide-descendants">
41 <Host
42 matchContents={{ vertical: true }}
43 colorScheme={themeAppearance}
44 ignoreSafeAreaKeyboardInsets
45 style={{ width: "100%" }}
46 >
47 <Box modifiers={[fillMaxWidth()]} contentAlignment="centerEnd">
48 <ExtendedFloatingActionButton
49 expanded={props.expanded}
50 containerColor={colors["--color-primary"]}
51 modifiers={[
52 defaultMinSize({ minWidth: nativeSize }),
53 height(nativeSize),
54 graphicsLayer({
55 scaleX: scale,
56 scaleY: scale,
57 transformOriginX: 1,
58 transformOriginY: 0,
59 }),
60 onSizeChanged(rememberWidth),
61 ]}
62 >
63 <ExtendedFloatingActionButton.Icon>
64 <Box modifiers={[size(nativeIconSize, nativeIconSize)]}>
65 <Icon
66 source={require("../../assets/icons/compose.xml")}
67 size={nativeIconSize}
68 tint={colors["--color-primary-foreground"]}
69 />
70 </Box>
71 </ExtendedFloatingActionButton.Icon>
72 <ExtendedFloatingActionButton.Text>
73 <Text
74 color={colors["--color-primary-foreground"]}
75 style={{
76 fontSize: typography.fontSize / scale,
77 lineHeight: typography.lineHeight / scale,
78 fontWeight: "500",
79 }}
80 >
81 New thread
82 </Text>
83 </ExtendedFloatingActionButton.Text>
84 </ExtendedFloatingActionButton>
85 </Box>
86 </Host>
87 </View>
88 {/* The visual host is wider than the button; only this target intercepts list touches. */}
89 <Pressable
90 onPress={props.onPress}
91 accessibilityRole="button"
92 accessibilityLabel="New thread"
93 android_ripple={{ foreground: true }}
94 style={{
95 position: "absolute",
96 right: 0,
97 top: 0,
98 bottom: 0,
99 // Release the label area as soon as collapse starts, before native measurements arrive.
100 width: props.expanded ? buttonWidth * scale : fabSize,
101 borderRadius: 16 * scale,
102 overflow: "hidden",
103 }}
104 />
105 </View>
106 );
107}