authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-09-07 02:14:28-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-07 02:14:28-07:00
logb248f5ad566a8cd3ea2d7d678c1e8aa49c748879
tree527926b5c58193394b3777cde40ea9dedc853533
parent71297974c666b0db50a3e3b1a861742f2cdd4d7d
signature Signed by PGP key B5690EEEBB952194

fix(mobile): smooth composer status pill resizing (#10484)


1 files changed, 35 insertions(+), 21 deletions(-)

apps/mobile/src/features/threads/floating-working-control.tsx+35-21
...@@ -6,6 +6,7 @@ import {...@@ -6,6 +6,7 @@ import {
6 type LayoutChangeEvent,6 type LayoutChangeEvent,
7 Pressable,7 Pressable,
8 Text as SystemText,8 Text as SystemText,
9 useWindowDimensions,
9 View,10 View,
10} from "react-native";11} from "react-native";
11import Animated, {12import Animated, {
...@@ -65,6 +66,9 @@ export function FloatingWorkingControl(props: {...@@ -65,6 +66,9 @@ export function FloatingWorkingControl(props: {
65 readonly showScrollToEnd: boolean;66 readonly showScrollToEnd: boolean;
66 readonly onScrollToEnd: () => void;67 readonly onScrollToEnd: () => void;
67}) {68}) {
69 const { width: windowWidth } = useWindowDimensions();
70 const [overlayWidth, setOverlayWidth] = useState(windowWidth);
71 const labelWidth = Math.max(0, Math.min(overlayWidth, windowWidth) - CONTROL_HEIGHT - 16);
68 const separationProgress = useSharedValue(props.showScrollToEnd ? 1 : 0);72 const separationProgress = useSharedValue(props.showScrollToEnd ? 1 : 0);
6973
70 useEffect(() => {74 useEffect(() => {
...@@ -78,17 +82,10 @@ export function FloatingWorkingControl(props: {...@@ -78,17 +82,10 @@ export function FloatingWorkingControl(props: {
78 opacity: separationProgress.value,82 opacity: separationProgress.value,
79 }));83 }));
8084
81 // The label swaps between connection, syncing, compacting, and working while85 // Animate an in-flow sizer so native glass receives real layout updates.
82 // the capsule stays mounted. A layout transition on the capsule would move86 // Measure labels in a separate, fixed-width host: measuring against the
83 // its left edge, and labels laid out from that edge slide with it, so the pill87 // animated capsule constrains the incoming text to each intermediate width
84 // reads as shifting sideways. Instead an in-flow sizer animates to the88 // and repeatedly retargets the animation as it grows.
85 // measured label width and the capsule takes its size from that, while the
86 // labels sit centered on top. The row re-centers as the capsule grows, so its
87 // midpoint never moves and the text underneath stays put.
88 //
89 // The sizer has to carry the width rather than the capsule itself: the native
90 // glass view only picks up a size from a real layout pass, so an animated
91 // width set straight on it leaves the glass stuck at its mounted size.
92 const capsuleWidth = useSharedValue<number | null>(null);89 const capsuleWidth = useSharedValue<number | null>(null);
93 const measuredWidthRef = useRef<number | null>(null);90 const measuredWidthRef = useRef<number | null>(null);
94 const handleLabelLayout = (event: LayoutChangeEvent) => {91 const handleLabelLayout = (event: LayoutChangeEvent) => {
...@@ -123,15 +120,27 @@ export function FloatingWorkingControl(props: {...@@ -123,15 +120,27 @@ export function FloatingWorkingControl(props: {
123 // Only the connection label is a button (tap to reconnect); the others120 // Only the connection label is a button (tap to reconnect); the others
124 // pass touches through to the feed like before.121 // pass touches through to the feed like before.
125 const statusInteractive = props.status?.kind === "connection";122 const statusInteractive = props.status?.kind === "connection";
126 // Yoga centers an absolute child that has no insets on its parent's align and123 // The host stays centered on the capsule, but its measurement constraint
127 // justify, so each label row lands centered on the capsule without measuring124 // comes from the overlay, independent of the capsule's current width.
128 // itself, and the capsule clips whatever a wider label overhangs while it
129 // catches up.
130 const statusContent =125 const statusContent =
131 props.status !== null ? (126 props.status !== null ? (
132 <>127 <>
133 <Animated.View className="h-11" style={capsuleSizerStyle} />128 <Animated.View className="h-11" style={capsuleSizerStyle} />
134 <FloatingStatusLabel status={props.status} onLayout={handleLabelLayout} />129 <View
130 pointerEvents="box-none"
131 className="absolute h-11 items-center justify-center"
132 style={{ width: labelWidth }}
133 >
134 <FloatingStatusLabel
135 key={
136 props.status.kind === "working" || props.status.kind === "compacting"
137 ? props.status.kind
138 : `${props.status.kind}:${props.status.label}`
139 }
140 status={props.status}
141 onLayout={handleLabelLayout}
142 />
143 </View>
135 </>144 </>
136 ) : null;145 ) : null;
137146
...@@ -140,6 +149,7 @@ export function FloatingWorkingControl(props: {...@@ -140,6 +149,7 @@ export function FloatingWorkingControl(props: {
140 pointerEvents="box-none"149 pointerEvents="box-none"
141 className="absolute left-0 right-0 z-20 items-center"150 className="absolute left-0 right-0 z-20 items-center"
142 style={{ top: -CONTROL_OVERLAY_OFFSET }}151 style={{ top: -CONTROL_OVERLAY_OFFSET }}
152 onLayout={(event) => setOverlayWidth(event.nativeEvent.layout.width)}
143 entering={NATIVE_LIQUID_GLASS_SUPPORTED ? undefined : CONTROL_ENTERING}153 entering={NATIVE_LIQUID_GLASS_SUPPORTED ? undefined : CONTROL_ENTERING}
144 exiting={NATIVE_LIQUID_GLASS_SUPPORTED ? undefined : CONTROL_EXITING}154 exiting={NATIVE_LIQUID_GLASS_SUPPORTED ? undefined : CONTROL_EXITING}
145 >155 >
...@@ -252,7 +262,9 @@ function FloatingStatusLabel(props: {...@@ -252,7 +262,9 @@ function FloatingStatusLabel(props: {
252 onLayout={props.onLayout}262 onLayout={props.onLayout}
253 >263 >
254 <ActivityIndicator size="small" colorClassName="accent-icon-muted" />264 <ActivityIndicator size="small" colorClassName="accent-icon-muted" />
255 <Text className="font-t3-medium text-xs text-foreground">{props.status.label}</Text>265 <Text className="shrink font-t3-medium text-xs text-foreground" numberOfLines={1}>
266 {props.status.label}
267 </Text>
256 </StatusLabelRow>268 </StatusLabelRow>
257 );269 );
258 }270 }
...@@ -274,7 +286,10 @@ function FloatingStatusLabel(props: {...@@ -274,7 +286,10 @@ function FloatingStatusLabel(props: {
274 ) : (286 ) : (
275 <View className="h-2 w-2 rounded-full bg-red-500" />287 <View className="h-2 w-2 rounded-full bg-red-500" />
276 )}288 )}
277 <Text className="max-w-[260px] font-t3-medium text-xs text-foreground" numberOfLines={1}>289 <Text
290 className="max-w-[260px] shrink font-t3-medium text-xs text-foreground"
291 numberOfLines={1}
292 >
278 {props.status.label}293 {props.status.label}
279 </Text>294 </Text>
280 </StatusLabelRow>295 </StatusLabelRow>
...@@ -311,8 +326,7 @@ function FloatingStatusLabel(props: {...@@ -311,8 +326,7 @@ function FloatingStatusLabel(props: {
311 );326 );
312}327}
313328
314// Rows are absolute with no insets, so the capsule centers them on itself and an329// Absolute rows cross-fade around the same center without affecting each other.
315// exiting row fading out never shifts the incoming one.
316function StatusLabelRow(props: {330function StatusLabelRow(props: {
317 readonly accessibilityLabel: string;331 readonly accessibilityLabel: string;
318 readonly accessibilityRole?: "button";332 readonly accessibilityRole?: "button";
...@@ -324,7 +338,7 @@ function StatusLabelRow(props: {...@@ -324,7 +338,7 @@ function StatusLabelRow(props: {
324 const rowClassName = `h-11 flex-row items-center px-4 ${props.className ?? ""}`;338 const rowClassName = `h-11 flex-row items-center px-4 ${props.className ?? ""}`;
325 return (339 return (
326 <Animated.View340 <Animated.View
327 className="absolute"341 className="absolute max-w-full"
328 entering={LABEL_ENTERING}342 entering={LABEL_ENTERING}
329 exiting={LABEL_EXITING}343 exiting={LABEL_EXITING}
330 onLayout={props.onLayout}344 onLayout={props.onLayout}