From 6e8b278230aaeaf7bb768ed53a675a6db5073092 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 2 Sep 2026 20:37:16 -0400 Subject: [PATCH] feat(chat): match the edit chrome to the context menu An edit raised its blur over the chat screen alone, while the menu that preceded it blurred the whole navigation stack. The transcript sharpened for a frame on the way in and the navigation bar never went soft at all. The held blur now stays over the same host the menu covered and changes only its z-order and its bottom edge: under the navigation bar, so the back button stays legible and tappable, and stopping at the top of the composer, the one piece of chrome an edit needs sharp. The title and avatar fade out for the duration, so the bar reads as empty chrome over the blur, and the back button ends the edit rather than popping the chat. --- .../Conversation/ConversationScreen.swift | 18 +++++++++ .../Chat/ChatScreenViewController.swift | 27 +++++++++---- .../FlipcashUI/Chat/MessageBackdrop.swift | 39 ++++++++++++++----- 3 files changed, 67 insertions(+), 17 deletions(-) diff --git a/Flipcash/Core/Screens/Conversation/ConversationScreen.swift b/Flipcash/Core/Screens/Conversation/ConversationScreen.swift index f176a6a2d..f0d3c8561 100644 --- a/Flipcash/Core/Screens/Conversation/ConversationScreen.swift +++ b/Flipcash/Core/Screens/Conversation/ConversationScreen.swift @@ -230,7 +230,22 @@ struct ConversationScreen: View { .background(Color.backgroundMain) .navigationTitle("") .toolbarTitleDisplayMode(.inline) + // An edit blurs the whole screen behind the message being edited, navigation bar included, + // so the back button is the only thing up there worth keeping legible — and it backs out of + // the edit rather than out of the chat. + .navigationBarBackButtonHidden(composer.isEditing) .toolbar { + if composer.isEditing { + ToolbarItem(placement: .topBarLeading) { + Button { + composer.endEditing() + } label: { + Image(systemName: "chevron.backward") + .foregroundStyle(Color.textMain) + } + .accessibilityLabel("Stop editing") + } + } ToolbarItem(placement: .principal) { ConversationTitleItem( title: title, @@ -242,6 +257,9 @@ struct ConversationScreen: View { onTap: titleTapAction, opensProfile: profileTapAction != nil ) + .opacity(composer.isEditing ? 0 : 1) + .allowsHitTesting(!composer.isEditing) + .animation(.easeInOut(duration: 0.2), value: composer.isEditing) } } // Fetch the tip counterpart's avatar for the title and profile card. diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/ChatScreenViewController.swift b/FlipcashUI/Sources/FlipcashUI/Chat/ChatScreenViewController.swift index 33261846a..994e51a60 100644 --- a/FlipcashUI/Sources/FlipcashUI/Chat/ChatScreenViewController.swift +++ b/FlipcashUI/Sources/FlipcashUI/Chat/ChatScreenViewController.swift @@ -181,7 +181,7 @@ public final class ChatScreenViewController: UIViewController { public func beginEditSpotlight(for stableID: String) { editedStableID = stableID backdrop.present(over: contextMenuBackdropHost, animator: nil) - backdrop.hold(under: bar) + backdrop.hold(clearing: bar, under: hostNavigationController?.navigationBar) transcript.afterContextMenu { [weak self] in self?.refreshEditSpotlight() } } @@ -197,8 +197,13 @@ public final class ChatScreenViewController: UIViewController { /// doesn't follow the cell on its own. A row scrolled out of the transcript leaves the copy at /// its last frame rather than dropping it, so the message stays on screen for the whole edit. private func refreshEditSpotlight() { + // Measured in the backdrop's own host, which is the navigation stack rather than this + // screen whenever there is one to be in. guard let editedStableID, - let frame = transcript.bubbleFrame(forStableID: editedStableID, in: view) else { return } + let frame = transcript.bubbleFrame( + forStableID: editedStableID, + in: contextMenuBackdropHost + ) else { return } if backdrop.hasSpotlight { backdrop.moveSpotlight(to: frame) } else if let bubble = transcript.bubbleSnapshot(forStableID: editedStableID) { @@ -206,16 +211,21 @@ public final class ChatScreenViewController: UIViewController { } } - /// The view the context-menu blur covers: the navigation stack when this screen is inside one, - /// so the navigation bar goes soft with the transcript and the composer, and this screen's own - /// view otherwise. - private var contextMenuBackdropHost: UIView { + /// The navigation stack this screen is inside, if any — the SwiftUI hosting controllers this + /// screen is wrapped in sit between the two, so it is reached by walking the parent chain. + private var hostNavigationController: UINavigationController? { var ancestor = parent while let current = ancestor { - if let navigation = current as? UINavigationController { return navigation.view } + if let navigation = current as? UINavigationController { return navigation } ancestor = current.parent } - return view + return nil + } + + /// The view the blur covers: the navigation stack when this screen is inside one, so the + /// navigation bar goes soft with the transcript, and this screen's own view otherwise. + private var contextMenuBackdropHost: UIView { + hostNavigationController?.view ?? view } /// Adds a hosted bar pinned to the view's width and bottom; returns the height constraint @@ -343,6 +353,7 @@ public final class ChatScreenViewController: UIViewController { // has to stay opaque for; it holds until just above the title and clears over the tail. topFadeHeightConstraint.constant = view.safeAreaInsets.top + Self.topFadeTail topFade.opaqueLength = max(view.safeAreaInsets.top - 12, 0) + backdrop.layoutHeld() refreshEditSpotlight() // Reserve only the bar's own height. On-device the system already grows the collection // view's adjusted content inset by the keyboard when it's up, so adding the keyboard here diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/MessageBackdrop.swift b/FlipcashUI/Sources/FlipcashUI/Chat/MessageBackdrop.swift index e73f32ecd..cf15cbdc9 100644 --- a/FlipcashUI/Sources/FlipcashUI/Chat/MessageBackdrop.swift +++ b/FlipcashUI/Sources/FlipcashUI/Chat/MessageBackdrop.swift @@ -18,8 +18,10 @@ import UIKit /// /// Choosing Edit holds the same blur past the menu rather than fading it and raising a second one, /// which is what keeps the transcript from flashing back to legible between the two states. Held, -/// the blur moves below the composer bar so the field stays sharp and usable, carries a detached -/// copy of the edited bubble above itself, and takes the taps that land outside it. +/// it stays over the same host, so an edit is as soft as the menu was rather than sparing the +/// navigation bar; it slides under that bar, so the back button stays legible above it; it stops at +/// the top of the composer, the one piece of chrome an edit needs sharp; and it carries a detached +/// copy of the edited bubble above itself and takes the taps that land outside it. @MainActor final class MessageBackdrop { @@ -37,6 +39,8 @@ final class MessageBackdrop { private let effect = UIBlurEffect(style: .systemUltraThinMaterialDark) private var effectView: UIVisualEffectView? private var spotlight: UIView? + /// The composer bar a held blur stops short of, re-measured on every layout pass. + private weak var clearance: UIView? /// Fades the blur in over `host`, riding `animator` so it lands with the menu. Presenting twice /// is a no-op: the display callback fires once for the lift and again for the menu. @@ -60,21 +64,37 @@ final class MessageBackdrop { } } - /// Keeps the blur up after the menu that raised it goes, moving it under `bar` so the composer - /// stays sharp, and starts taking taps. The message itself is floated separately, by - /// `setSpotlight`, once the menu has finished putting its lifted preview back. - func hold(under bar: UIView) { - guard let blur = effectView, let host = bar.superview else { return } + /// Keeps the blur up after the menu that raised it goes, and starts taking taps. It stays over + /// the host the menu blurred, so nothing sharpens on the way into an edit, and only its z-order + /// and its bottom edge change: under `navigationBar`, so the back button stays legible and + /// tappable, and stopping at the top of `bar`, so the composer does too. The message itself is + /// floated separately, by `setSpotlight`, once the menu has finished putting its lifted preview + /// back. + func hold(clearing bar: UIView, under navigationBar: UIView?) { + guard let blur = effectView, let host = blur.superview else { return } isHeld = true + clearance = bar - blur.frame = host.bounds - host.insertSubview(blur, belowSubview: bar) + if let navigationBar, navigationBar.superview === host { + host.insertSubview(blur, belowSubview: navigationBar) + } + // The frame is driven by the bar from here on, so the host can no longer resize it. + blur.autoresizingMask = [] + layoutHeld() blur.isUserInteractionEnabled = true let tap = UITapGestureRecognizer(target: self, action: #selector(handleTap)) blur.addGestureRecognizer(tap) } + /// Re-measures a held blur against the composer bar, which rises and falls with the keyboard. + /// A no-op when nothing is held, so a layout pass outside an edit is harmless. + func layoutHeld() { + guard isHeld, let blur = effectView, let host = blur.superview, let bar = clearance else { return } + let barTop = bar.convert(bar.bounds, to: host).minY + blur.frame = CGRect(x: 0, y: 0, width: host.bounds.width, height: max(barTop, 0)) + } + /// Floats `bubble` — a detached copy of the edited message — above a held blur, at `frame` in /// the blur's own coordinates. /// @@ -121,6 +141,7 @@ final class MessageBackdrop { guard isHeld, let blur = effectView else { return } isHeld = false effectView = nil + clearance = nil let bubble = spotlight spotlight = nil