Resize landscape split on compose focus - #147
Conversation
Keep a single landscape HStack in both states so SwiftUI animates the split resize instead of inserting/removing views. While the input is focused (iPhone landscape only, gated on verticalSizeClass == .compact): - translated-output card clamps to ~180pt, input column takes priority and grows to fill - output text caps at 3 lines (fits iPhone SE landscape above keyboard) - compact swap button overlays the collapsed output card so direction reversal stays one tap without dismissing the keyboard - Translate button uses the `translate` SF glyph in its compact form and stays visually primary; VoiceOver label remains "Translate" - Table Mode drops out of the focused action row (Translate + mic only) - resize animates 0.25s ease-out keyed on focus, disabled under Reduce Motion Adds "Speak"/"Type"/"Swap languages" to all 13 locales (the first two were already used via .localized() but missing from the string files). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FkRuJrdYaxQVeLiKNhLmgf
🛠️ Tuist Run Report 🛠️Tests 🧪
Builds 🔨
|
The compose-focus split introduced in this PR sized its two landscape columns with an exactWidth(_:) helper — a @ViewBuilder "if let width { frame(width:) } else { self }". That produces _ConditionalContent, whose two branches have different structural identity. Inside the landscape GeometryReader and under the chained safeAreaPadding insets, rotating the device drove the SwiftUI layout engine into an unbounded InsetViewLayout.sizeThatFits / _FlexFrameLayout size re-proposal loop: _UIApplicationFlushCATransaction never returned and the main thread was pinned, freezing the UI. Drive the column widths with plain .frame(width:) ternaries instead (nil is a valid no-op width) so the view identity is stable across the focus toggle, and keep the fixed-width .frame inside the flexible one to match the ordering the portrait / pre-PR landscape layout already used. Remove the now-unused helper. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FkRuJrdYaxQVeLiKNhLmgf
|
Closing this. The focus-driven landscape split could not be made stable. What went wrong, in order:
Why not keep patching: every fix was "remove one Salvaged: the missing-localization bug this work uncovered is real and pre-existing on Dropped: the single- If revisited: the underlying UX problem is real — in iPhone landscape the keyboard takes roughly half of ~375pt, and the read-only output card eats most of the width while composing. But restructuring the row on focus is the wrong lever. Anything that changes view structure in that subtree risks both the layout non-convergence and the |
Summary
In iPhone landscape the software keyboard covers roughly half of the ~375pt screen height. While the user is typing, the large read-only translated-output card keeps eating most of the width for a result nobody is looking at, squeezing the input into a narrow strip.
This makes focus change the split of the existing landscape layout instead of the layout itself:
HStackwith the same two children in both states — focus only flips frame/priority values, so SwiftUI animates a resize rather than an insert/remove.translateSF Symbol in its compact form and keeps the accent gradient so it stays visually primary; VoiceOver label remains "Translate".Gated to
verticalSizeClass == .compact— iPhone landscape only. iPad (regular in both orientations) and iPhone portrait are untouched.Why not the previous approach
This supersedes the closed #146, which built a second, disjoint landscape layout for the focused state. A design review rejected that: the two arrangements shared no view identity (so the animation was an insert/remove fighting the keyboard's own animation), the language picker and swap became unreachable while typing, the 6-line "Preview" column and 3-button rail both overflowed iPhone SE landscape, and the icon-only
arrow.rightread as "next", not "translate".Localization
"Swap languages"added to all 13 locales for the new control."Speak"and"Type"were already called through.localized()but had no entry in any string file — added properly, which means the visible Type/Speak mode-selector toggle is now localized instead of English-only.Not done
.submitLabel(.send)+onSubmitwas scoped but skipped:TranslationInputViewwraps a multi-lineTextEditor, where Return inserts a newline andsubmitLabeldoes not apply. Forcing a single-line field would break multi-line composition.Rotation hang found in review, fixed in
7f9e9e0The first cut of this PR froze the app on rotation into landscape. Root cause was a helper this PR introduced:
if let/else selfcompiles to_ConditionalContent, whose two branches have different structural view identity. Sitting inside the landscapeGeometryReaderunder the chained.safeAreaPaddinginsets, that node re-materialised its child view list (DynamicViewList.updateValue) on every size query, soInsetViewLayout.primaryMinimumnever converged. Asampleof the frozen process showed the main thread pinned in_UIApplicationFlushCATransaction→InsetLayoutComputer.Engine.sizeThatFitsrecursing hundreds of frames deep, for 1285 of ~2189 samples. The rotation's CATransaction never commits, so the UI dies.Fix: use plain
.frame(width:)—nilis a valid no-op width, so it is always one_FrameLayoutnode and structural identity stays stable. The helper is deleted, and the fixed-width frame now sits inside the flexible one on the input column, matching the ordering the portrait path already relies on. Everything else in the PR is unchanged — this is not a back-out.mainwas tested over 4+ rotation cycles and does not exhibit this, so #143 / #144 / #145 are not implicated.Test plan
mise x -- tuist build→ succeededmise x -- tuist test --no-selective-testing→ 10/10TalktransTestspass. (Note: plaintuist testreports "no tests to run" from selective-test caching, and the XCTest counter prints "Executed 0 tests" because these are Swift Testing@Testcases — neither means the suite is empty.)sampletaken after each step, runaway-layout frame count in parentheses:Still worth a human look before merge
iPhone SE landscape specifically — it is the tightest case (~155pt usable above the keyboard). Confirm the 3-line cap and the Translate + mic row clear the keyboard, and check the swap button does not collide with the 3 lines of text on the collapsed card.
User flow
flowchart TD A[Landscape TranslationScreen<br/>two-panel, unfocused] -->|tap input field| B{verticalSizeClass<br/>== .compact?} B -->|no · iPad| A B -->|yes · iPhone landscape| C[Focus on<br/>resize 0.25s ease-out] C --> D[Output card clamps to 180pt<br/>text capped to 3 lines<br/>compact swap button appears] C --> E[Input column takes priority<br/>grows to fill] C --> F[Action row: Translate + mic<br/>Table Mode hidden] D -->|tap swap| G[Direction reversed<br/>keyboard stays up] G --> D E -->|tap background| H[Dismiss keyboard] F -->|tap Translate| I[Dismiss keyboard → translate] H --> A I --> A