From ae986da12065149ef59e7e89f2cc86bad91ce828 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Mon, 27 Jul 2026 17:41:20 -0400 Subject: [PATCH] fix(tips): raise the post-tip keyboard from UIKit instead of @FocusState MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The post-tip open (#524) set the composer's SwiftUI @FocusState on appear. That updates SwiftUI's focus state — the caret and the isComposing bar-morph — but never presents the system keyboard, because the composer is hosted inside a nested UIHostingController bridged through UIKit (ChatScreenRepresentable → ChatScreenViewController). A programmatic @FocusState doesn't cross that hosting boundary; only a real becomeFirstResponder does. Taps worked because a tap is a real UIKit responder event. Bumping the .task delay would never have helped — every working focus-on-appear in the app is a pure-SwiftUI screen. Raise the keyboard from the UIKit layer that already owns keyboard behavior: ChatScreenViewController.viewDidAppear (the point the push transition has settled) calls becomeFirstResponder() on the composer field, guarded so it's a strict once-per-open. Removes the @FocusState/.task focus hack and its plumbing through ConversationBottomBar/ConversationComposer. The isComposing morph still works — when the keyboard rises SwiftUI syncs @FocusState and the existing onChange(of: isFocused) fires. --- .../ChatScreenRepresentable.swift | 7 +++-- .../Conversation/ConversationBottomBar.swift | 21 ++----------- .../Chat/ChatScreenViewController.swift | 30 +++++++++++++++++++ 3 files changed, 36 insertions(+), 22 deletions(-) diff --git a/Flipcash/Core/Screens/Conversation/ChatScreenRepresentable.swift b/Flipcash/Core/Screens/Conversation/ChatScreenRepresentable.swift index 1cd72b0e..f978f05f 100644 --- a/Flipcash/Core/Screens/Conversation/ChatScreenRepresentable.swift +++ b/Flipcash/Core/Screens/Conversation/ChatScreenRepresentable.swift @@ -39,8 +39,9 @@ struct ChatScreenRepresentable: UIViewControllerRepresentable { let onSendCash: () -> Void let conversationController: ConversationController let barModel: ConversationBarModel - /// Focus the composer when the bar first appears (post-tip open). One-shot: - /// the composer requests focus in its `.task`, which runs once on appear. + /// Raise the keyboard when the screen first appears (post-tip open). The UIKit screen focuses + /// the composer field in `viewDidAppear` — a hosted SwiftUI `@FocusState` never presents the + /// keyboard across the hosting boundary. let focusOnAppear: Bool /// Whether this is a tip DM — the send button then stays minimized. let isTipDm: Bool @@ -49,6 +50,7 @@ struct ChatScreenRepresentable: UIViewControllerRepresentable { let barHost = UIHostingController(rootView: bar(coordinator: context.coordinator)) barHost.view.backgroundColor = .clear let screen = ChatScreenViewController(bar: barHost.view, barController: barHost) + screen.focusesComposerOnAppear = focusOnAppear screen.onReachTop = onReachTop screen.onRetry = onRetry screen.onCashCardTap = onCashCardTap @@ -102,7 +104,6 @@ struct ChatScreenRepresentable: UIViewControllerRepresentable { symbol: symbol, onSendCash: onSendCash, model: barModel, - focusOnAppear: focusOnAppear, isTipDm: isTipDm ) .environment(conversationController) diff --git a/Flipcash/Core/Screens/Conversation/ConversationBottomBar.swift b/Flipcash/Core/Screens/Conversation/ConversationBottomBar.swift index f1feefe7..ae546b3d 100644 --- a/Flipcash/Core/Screens/Conversation/ConversationBottomBar.swift +++ b/Flipcash/Core/Screens/Conversation/ConversationBottomBar.swift @@ -46,8 +46,6 @@ struct ConversationBottomBar: View { let symbol: String let onSendCash: () -> Void let model: ConversationBarModel - /// Focus the composer on first appear (post-tip open); false everywhere else. - var focusOnAppear: Bool = false /// Tip chats always show the compact symbol-only send button; ordinary /// chats expand to "Send €" at rest and collapse only while composing. var isTipDm: Bool = false @@ -64,12 +62,8 @@ struct ConversationBottomBar: View { ) } if chatExists { - ConversationComposer( - conversationID: conversationID, - model: model, - focusOnAppear: focusOnAppear - ) - .transition(.opacity) + ConversationComposer(conversationID: conversationID, model: model) + .transition(.opacity) } } .padding(.horizontal, 12) @@ -90,8 +84,6 @@ struct ConversationComposer: View { let conversationID: ConversationID? @Bindable var model: ConversationBarModel - /// Raise the keyboard on open (post-tip chats only). One-shot via `.task`. - var focusOnAppear: Bool = false @Environment(ConversationController.self) private var conversationController @FocusState private var isFocused: Bool @@ -133,15 +125,6 @@ struct ConversationComposer: View { return field .glassBackground(cornerRadius: BarMetrics.cornerRadius) - // Post-tip open: raise the keyboard once the composer appears. Requested - // after a short delay so the push transition has settled — focus asked - // mid-transition is dropped and the keyboard never rises. `.task` runs - // once on appear and is cancelled on disappear. - .task { - guard focusOnAppear else { return } - try? await Task.delay(milliseconds: 350) - isFocused = true - } // Focus is the single source of `isComposing` — the button morph and the // screen's interactive-dismiss gate both key off it. Losing focus // (keyboard swiped down) ends composing. diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/ChatScreenViewController.swift b/FlipcashUI/Sources/FlipcashUI/Chat/ChatScreenViewController.swift index b71a1578..4b7fad83 100644 --- a/FlipcashUI/Sources/FlipcashUI/Chat/ChatScreenViewController.swift +++ b/FlipcashUI/Sources/FlipcashUI/Chat/ChatScreenViewController.swift @@ -30,6 +30,13 @@ public final class ChatScreenViewController: UIViewController { /// lets the composer overflow below its frame, under the keyboard. private var barHeightConstraint: NSLayoutConstraint! + /// Raise the keyboard once the screen has finished appearing (post-tip open). Driven from + /// UIKit rather than a SwiftUI `@FocusState`: a hosted composer's programmatic focus updates + /// SwiftUI's focus state but never presents the keyboard across the hosting boundary — only a + /// real `becomeFirstResponder` does. + public var focusesComposerOnAppear = false + private var didFocusComposer = false + /// - Parameters: /// - bar: pinned to the keyboard layout guide; rides the keyboard. /// - barController: the view controller owning the bar, when hosted (e.g. a @@ -119,6 +126,17 @@ public final class ChatScreenViewController: UIViewController { host.setContentScrollView(transcript.collectionView, for: .top) } + public override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + // Raise the keyboard once, after the push transition settles — the composer's field is now + // in the key window, so `becomeFirstResponder` presents the keyboard (a hosted SwiftUI + // `@FocusState` set programmatically does not). One-shot: guarded so a later re-appear + // (app foregrounding) doesn't force the keyboard back up. + guard focusesComposerOnAppear, !didFocusComposer else { return } + didFocusComposer = true + bar.firstTextInputResponder?.becomeFirstResponder() + } + /// Set the bar's height to its measured SwiftUI content height. public func setBarHeight(_ height: CGFloat) { guard barHeightConstraint != nil, barHeightConstraint.constant != height else { return } @@ -140,4 +158,16 @@ public final class ChatScreenViewController: UIViewController { transcript.setBottomInset(bar.frame.height) } } + +private extension UIView { + /// The first descendant text-input view that can become first responder — the composer's + /// field, wherever SwiftUI nests it inside the hosted bar. + var firstTextInputResponder: UIView? { + if (self is UITextField || self is UITextView), canBecomeFirstResponder { return self } + for subview in subviews { + if let responder = subview.firstTextInputResponder { return responder } + } + return nil + } +} #endif