| 1 | import { Box, ExtendedFloatingActionButton, Host, Icon, Text } from "@expo/ui/jetpack-compose"; |
| 2 | import { |
| 3 | defaultMinSize, |
| 4 | fillMaxWidth, |
| 5 | graphicsLayer, |
| 6 | height, |
| 7 | onSizeChanged, |
| 8 | size, |
| 9 | } from "@expo/ui/jetpack-compose/modifiers"; |
| 10 | import { useCallback, useState } from "react"; |
| 11 | import { Pressable, View, type StyleProp, type ViewStyle } from "react-native"; |
| 12 | import { useAndroidControlSizing } from "./useAndroidControlSizing"; |
| 13 | import { useAppearancePreferences } from "../features/settings/appearance/AppearancePreferencesProvider"; |
| 14 | import { resolveScaledTextRole } from "../lib/appearancePreferences"; |
| 15 | |
| 16 | /** Keep the animated width and icon positioning entirely inside Compose, not Yoga. */ |
| 17 | export 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 | } |