Skip to content

Resize landscape split on compose focus - #147

Closed
2sem wants to merge 2 commits into
mainfrom
improve/landscape-focus-resize
Closed

Resize landscape split on compose focus#147
2sem wants to merge 2 commits into
mainfrom
improve/landscape-focus-resize

Conversation

@2sem

@2sem 2sem commented Aug 31, 2026

Copy link
Copy Markdown
Owner

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:

  • One HStack with the same two children in both states — focus only flips frame/priority values, so SwiftUI animates a resize rather than an insert/remove.
  • Focused: output card clamps to 180pt, input column takes priority and fills the rest. Unfocused: today's values, unchanged.
  • Output text caps at 3 lines while focused (a 6-line block does not fit above the keyboard on iPhone SE landscape).
  • A compact swap button overlays the collapsed output card, so reversing translation direction stays one tap without dismissing the keyboard.
  • The Translate button uses the translate SF Symbol in its compact form and keeps the accent gradient so it stays visually primary; VoiceOver label remains "Translate".
  • Table Mode drops out of the focused action row (Translate + mic only) — "show the other person" is the wrong action mid-compose, and the 3-button rail overflows SE landscape anyway.
  • Resize animates 0.25s ease-out to match the keyboard curve, disabled under Reduce Motion.

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.right read 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) + onSubmit was scoped but skipped: TranslationInputView wraps a multi-line TextEditor, where Return inserts a newline and submitLabel does not apply. Forcing a single-line field would break multi-line composition.

Rotation hang found in review, fixed in 7f9e9e0

The first cut of this PR froze the app on rotation into landscape. Root cause was a helper this PR introduced:

@ViewBuilder func exactWidth(_ width: CGFloat?) -> some View {
    if let width { frame(width: width) } else { self }
}

if let / else self compiles to _ConditionalContent, whose two branches have different structural view identity. Sitting inside the landscape GeometryReader under the chained .safeAreaPadding insets, that node re-materialised its child view list (DynamicViewList.updateValue) on every size query, so InsetViewLayout.primaryMinimum never converged. A sample of the frozen process showed the main thread pinned in _UIApplicationFlushCATransactionInsetLayoutComputer.Engine.sizeThatFits recursing hundreds of frames deep, for 1285 of ~2189 samples. The rotation's CATransaction never commits, so the UI dies.

Fix: use plain .frame(width:)nil is a valid no-op width, so it is always one _FrameLayout node 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.

main was tested over 4+ rotation cycles and does not exhibit this, so #143 / #144 / #145 are not implicated.

Test plan

  • mise x -- tuist build → succeeded
  • mise x -- tuist test --no-selective-testing → 10/10 TalktransTests pass. (Note: plain tuist test reports "no tests to run" from selective-test caching, and the XCTest counter prints "Executed 0 tests" because these are Swift Testing @Test cases — neither means the suite is empty.)
  • Simulator (iPhone 17 Pro, iOS 26.5), sample taken after each step, runaway-layout frame count in parentheses:
    • launch directly into landscape — renders correctly
    • portrait → landscape (the repro) — no freeze (0)
    • landscape → portrait — clean (0)
    • 4 rapid portrait⇄landscape cycles — clean (0)
    • landscape, focus input → collapsed output card + swap button, expanded input, compact translate icon, Table Mode dropped — as designed (3 samples / 2395, max nesting ~8)
    • focused in landscape → rotate to portrait — clean (0)
    • focus → unfocus in landscape — clean (0)
    • portrait focus/unfocus — unchanged

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
Loading

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

tuist Bot commented Aug 31, 2026

Copy link
Copy Markdown

🛠️ Tuist Run Report 🛠️

Tests 🧪

Scheme Status Cache hit rate Tests Skipped Ran Commit
talktrans-Workspace 0 % 10 0 10 4fe27ac11

Builds 🔨

Scheme Status Duration Commit
talktrans-Workspace 2m 7s 4fe27ac11

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
@2sem

2sem commented Aug 31, 2026

Copy link
Copy Markdown
Owner Author

Closing this. The focus-driven landscape split could not be made stable.

What went wrong, in order:

  1. Rotation hang. Caused by a @ViewBuilder if let width { frame(width:) } else { self } helper — _ConditionalContent with differing branch identity, inside the landscape GeometryReader under chained .safeAreaPadding. It re-materialised its child view list on every size query, so InsetViewLayout.primaryMinimum never converged. Main thread pinned in _UIApplicationFlushCATransaction, 1285 of ~2189 samples.
  2. Fixed in 7f9e9e0 by switching to plain .frame(width:). Verified clean on simulator across 7 rotation/focus scenarios.
  3. Keyboard stops appearing after 2–3 focus/unfocus cycles in landscape — @FocusState desyncing from the responder as the row rebuilds.
  4. The rotation freeze still reproduces on a real device, despite the simulator being clean.

Why not keep patching: every fix was "remove one _ConditionalContent, find another." The approach requires SwiftUI to re-solve a layout nested in a GeometryReader under chained safe-area insets whose structure varies with focus, and the layout engine does not converge. That is the approach, not a bug in it. Simulator verification also proved unreliable here, which makes iterating expensive and untrustworthy.

Salvaged: the missing-localization bug this work uncovered is real and pre-existing on main"Speak", "Type" and "Swap languages" are referenced in the UI but absent from every Localizable.strings, so the visible Type/Speak toggle renders in English for all non-English users. Extracted to #148, strings only, no layout changes.

Dropped: the single-HStack split, compactSwapButton, the 3-line output cap, and the translate SF Symbol (its only caller was the compact focused button).

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 @FocusState desync. Worth testing on a physical device from the first commit, not at review time.

@2sem 2sem closed this Aug 31, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant