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