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 {
66 type LayoutChangeEvent,
77 Pressable,
88 Text as SystemText,
9 useWindowDimensions,
910 View,
1011} from "react-native";
1112import Animated, {
......@@ -65,6 +66,9 @@ export function FloatingWorkingControl(props: {
6566 readonly showScrollToEnd: boolean;
6667 readonly onScrollToEnd: () => void;
6768}) {
69 const { width: windowWidth } = useWindowDimensions();
70 const [overlayWidth, setOverlayWidth] = useState(windowWidth);
71 const labelWidth = Math.max(0, Math.min(overlayWidth, windowWidth) - CONTROL_HEIGHT - 16);
6872 const separationProgress = useSharedValue(props.showScrollToEnd ? 1 : 0);
6973
7074 useEffect(() => {
......@@ -78,17 +82,10 @@ export function FloatingWorkingControl(props: {
7882 opacity: separationProgress.value,
7983 }));
8084
81 // The label swaps between connection, syncing, compacting, and working while
82 // the capsule stays mounted. A layout transition on the capsule would move
83 // its left edge, and labels laid out from that edge slide with it, so the pill
84 // reads as shifting sideways. Instead an in-flow sizer animates to the
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.
85 // Animate an in-flow sizer so native glass receives real layout updates.
86 // Measure labels in a separate, fixed-width host: measuring against the
87 // animated capsule constrains the incoming text to each intermediate width
88 // and repeatedly retargets the animation as it grows.
9289 const capsuleWidth = useSharedValue<number | null>(null);
9390 const measuredWidthRef = useRef<number | null>(null);
9491 const handleLabelLayout = (event: LayoutChangeEvent) => {
......@@ -123,15 +120,27 @@ export function FloatingWorkingControl(props: {
123120 // Only the connection label is a button (tap to reconnect); the others
124121 // pass touches through to the feed like before.
125122 const statusInteractive = props.status?.kind === "connection";
126 // Yoga centers an absolute child that has no insets on its parent's align and
127 // justify, so each label row lands centered on the capsule without measuring
128 // itself, and the capsule clips whatever a wider label overhangs while it
129 // catches up.
123 // The host stays centered on the capsule, but its measurement constraint
124 // comes from the overlay, independent of the capsule's current width.
130125 const statusContent =
131126 props.status !== null ? (
132127 <>
133128 <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>
135144 </>
136145 ) : null;
137146
......@@ -140,6 +149,7 @@ export function FloatingWorkingControl(props: {
140149 pointerEvents="box-none"
141150 className="absolute left-0 right-0 z-20 items-center"
142151 style={{ top: -CONTROL_OVERLAY_OFFSET }}
152 onLayout={(event) => setOverlayWidth(event.nativeEvent.layout.width)}
143153 entering={NATIVE_LIQUID_GLASS_SUPPORTED ? undefined : CONTROL_ENTERING}
144154 exiting={NATIVE_LIQUID_GLASS_SUPPORTED ? undefined : CONTROL_EXITING}
145155 >
......@@ -252,7 +262,9 @@ function FloatingStatusLabel(props: {
252262 onLayout={props.onLayout}
253263 >
254264 <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>
256268 </StatusLabelRow>
257269 );
258270 }
......@@ -274,7 +286,10 @@ function FloatingStatusLabel(props: {
274286 ) : (
275287 <View className="h-2 w-2 rounded-full bg-red-500" />
276288 )}
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 >
278293 {props.status.label}
279294 </Text>
280295 </StatusLabelRow>
......@@ -311,8 +326,7 @@ function FloatingStatusLabel(props: {
311326 );
312327}
313328
314// Rows are absolute with no insets, so the capsule centers them on itself and an
315// exiting row fading out never shifts the incoming one.
329// Absolute rows cross-fade around the same center without affecting each other.
316330function StatusLabelRow(props: {
317331 readonly accessibilityLabel: string;
318332 readonly accessibilityRole?: "button";
......@@ -324,7 +338,7 @@ function StatusLabelRow(props: {
324338 const rowClassName = `h-11 flex-row items-center px-4 ${props.className ?? ""}`;
325339 return (
326340 <Animated.View
327 className="absolute"
341 className="absolute max-w-full"
328342 entering={LABEL_ENTERING}
329343 exiting={LABEL_EXITING}
330344 onLayout={props.onLayout}