| ... | @@ -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"; |
| 11 | import Animated, { | 12 | import 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); |
| 69 | | 73 | |
| 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 | })); |
| 80 | | 84 | |
| 81 | // The label swaps between connection, syncing, compacting, and working while | 85 | // Animate an in-flow sizer so native glass receives real layout updates. |
| 82 | // the capsule stays mounted. A layout transition on the capsule would move | 86 | // 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 pill | 87 | // animated capsule constrains the incoming text to each intermediate width |
| 84 | // reads as shifting sideways. Instead an in-flow sizer animates to the | 88 | // 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 others | 120 | // 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 and | 123 | // The host stays centered on the capsule, but its measurement constraint |
| 127 | // justify, so each label row lands centered on the capsule without measuring | 124 | // 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; |
| 137 | | 146 | |
| ... | @@ -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 | } |
| 313 | | 328 | |
| 314 | // Rows are absolute with no insets, so the capsule centers them on itself and an | 329 | // Absolute rows cross-fade around the same center without affecting each other. |
| 315 | // exiting row fading out never shifts the incoming one. | | |
| 316 | function StatusLabelRow(props: { | 330 | function 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.View | 340 | <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} |