authorgravatar for me@pixp.ccAlex <me@pixp.cc> 2026-09-16 17:51:20-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-16 17:51:20-07:00
loged88937cf03e65fe5d620f170fd14b1fae6bb2e7
treea3dd31d5bb201f3e4033c81abfe9ad4e7b8a152e
parent892b86da124b6f672622e3002faaa93e6a5bed00
signature Signed by PGP key B5690EEEBB952194

fix(mobile): prevent Android compose FAB animation jitter (#12169)


3 files changed, 87 insertions(+), 0 deletions(-)

apps/mobile/assets/icons/compose.xml created+8
...@@ -0,0 +1,8 @@
1<!-- Tabler Icons edit (MIT), matching AppSymbol's square.and.pencil.
2 Strokes expanded to fills: Expo UI's vector parser does not support strokes. -->
3<vector xmlns:android="http://schemas.android.com/apk/res/android"
4 android:width="24dp" android:height="24dp"
5 android:viewportWidth="24" android:viewportHeight="24">
6 <path android:fillColor="#000000"
7 android:pathData="M7,6 L6,6 C4.3431,6 3,7.3431 3,9 L3,18 C3,19.6569 4.3431,21 6,21 L15,21 C16.6569,21 18,19.6569 18,18 L18,17 C18,16.4477 17.5523,16 17,16 C16.4477,16 16,16.4477 16,17 L16,18 C16,18.5523 15.5523,19 15,19 L6,19 C5.4477,19 5,18.5523 5,18 L5,9 C5,8.4477 5.4477,8 6,8 L7,8 C7.5523,8 8,7.5523 8,7 C8,6.4477 7.5523,6 7,6 Z M21.0921,5.8779 L21.092,5.8778 L20.3849,6.5849 L21.092,7.292 C22.3027,6.0814 22.3027,4.1186 21.092,2.908 C19.8814,1.6973 17.9186,1.6973 16.708,2.908 L8.2942,11.2916 C8.1059,11.4793 8,11.7342 8,12 L8,15 C8,15.5523 8.4477,16 9,16 L12,16 C12.2658,16 12.5207,15.8942 12.7084,15.7058 L21.0934,7.2908 C21.4832,6.8996 21.4821,6.2665 21.0908,5.8766 C20.6996,5.4868 20.0665,5.4879 19.6766,5.8792 L11.2916,14.2942 L12,14 L9,14 L10,15 L10,12 L9.7058,12.7084 L18.1209,4.3235 C18.5518,3.8926 19.2482,3.8926 19.6778,4.3222 C20.1074,4.7518 20.1074,5.4482 19.6778,5.8778 C19.2873,6.2683 19.2873,6.9015 19.6778,7.292 L19.6779,7.2921 C20.0684,7.6826 20.7016,7.6826 21.0921,7.2921 C21.4826,6.9016 21.4826,6.2684 21.0921,5.8779 Z M15.2929,5.7071 L18.2929,8.7071 C18.6834,9.0976 19.3166,9.0976 19.7071,8.7071 C20.0976,8.3166 20.0976,7.6834 19.7071,7.2929 L16.7071,4.2929 C16.3166,3.9024 15.6834,3.9024 15.2929,4.2929 C14.9024,4.6834 14.9024,5.3166 15.2929,5.7071 Z" />
8</vector>
apps/mobile/src/components/MaterialNewThreadButton.android.tsx+4
...@@ -1,10 +1,14 @@...@@ -1,10 +1,14 @@
1import type { ComponentProps } from "react";1import type { ComponentProps } from "react";
2import { MaterialFloatingActionButton } from "./MaterialFloatingActionButton.android";2import { MaterialFloatingActionButton } from "./MaterialFloatingActionButton.android";
3import { MaterialScrollComposeButton } from "./MaterialScrollComposeButton.android";
3import type { MaterialNewThreadButton as SharedMaterialNewThreadButton } from "./MaterialNewThreadButton.shared";4import type { MaterialNewThreadButton as SharedMaterialNewThreadButton } from "./MaterialNewThreadButton.shared";
45
5export function MaterialNewThreadButton(6export function MaterialNewThreadButton(
6 props: ComponentProps<typeof SharedMaterialNewThreadButton>,7 props: ComponentProps<typeof SharedMaterialNewThreadButton>,
7) {8) {
9 if (props.extended && props.expanded !== undefined) {
10 return <MaterialScrollComposeButton {...props} expanded={props.expanded} />;
11 }
8 return (12 return (
9 <MaterialFloatingActionButton13 <MaterialFloatingActionButton
10 {...props}14 {...props}
apps/mobile/src/components/MaterialScrollComposeButton.android.tsx created+75
...@@ -0,0 +1,75 @@
1import { Box, ExtendedFloatingActionButton, Host, Icon, Text } from "@expo/ui/jetpack-compose";
2import { fillMaxWidth, onSizeChanged, size } from "@expo/ui/jetpack-compose/modifiers";
3import { useCallback, useState } from "react";
4import { Pressable, View, type StyleProp, type ViewStyle } from "react-native";
5import { useAppearancePreferences } from "../features/settings/appearance/AppearancePreferencesProvider";
6import { useScaledTextRole } from "../features/settings/appearance/useScaledTextRole";
7
8/** Keep the animated width and icon positioning entirely inside Compose, not Yoga. */
9export function MaterialScrollComposeButton(props: {
10 readonly expanded: boolean;
11 readonly onPress: () => void;
12 readonly className?: string;
13 readonly style?: StyleProp<ViewStyle>;
14}) {
15 const { themeAppearance, themeVariables: colors } = useAppearancePreferences();
16 const typography = useScaledTextRole("footnote");
17 const [expandedWidth, setExpandedWidth] = useState(56);
18 const rememberWidth = useCallback(({ width }: { width: number }) => {
19 setExpandedWidth((previous) => Math.max(previous, width));
20 }, []);
21 return (
22 <View pointerEvents="box-none" className={props.className} style={[props.style, { left: 20 }]}>
23 <View pointerEvents="none" importantForAccessibility="no-hide-descendants">
24 <Host
25 matchContents={{ vertical: true }}
26 colorScheme={themeAppearance}
27 ignoreSafeAreaKeyboardInsets
28 style={{ width: "100%" }}
29 >
30 <Box modifiers={[fillMaxWidth()]} contentAlignment="centerEnd">
31 <ExtendedFloatingActionButton
32 expanded={props.expanded}
33 containerColor={colors["--color-primary"]}
34 modifiers={[onSizeChanged(rememberWidth)]}
35 >
36 <ExtendedFloatingActionButton.Icon>
37 <Box modifiers={[size(24, 24)]}>
38 <Icon
39 source={require("../../assets/icons/compose.xml")}
40 size={24}
41 tint={colors["--color-primary-foreground"]}
42 />
43 </Box>
44 </ExtendedFloatingActionButton.Icon>
45 <ExtendedFloatingActionButton.Text>
46 <Text
47 color={colors["--color-primary-foreground"]}
48 style={{ ...typography, fontWeight: "500" }}
49 >
50 New thread
51 </Text>
52 </ExtendedFloatingActionButton.Text>
53 </ExtendedFloatingActionButton>
54 </Box>
55 </Host>
56 </View>
57 {/* The visual host is wider than the button; only this target intercepts list touches. */}
58 <Pressable
59 onPress={props.onPress}
60 accessibilityRole="button"
61 accessibilityLabel="New thread"
62 android_ripple={{ foreground: true }}
63 style={{
64 position: "absolute",
65 right: 0,
66 top: 0,
67 bottom: 0,
68 width: props.expanded ? expandedWidth : 56,
69 borderRadius: 16,
70 overflow: "hidden",
71 }}
72 />
73 </View>
74 );
75}