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