diff --git a/Flipcash/Core/Screens/Conversation/ConversationScreen.swift b/Flipcash/Core/Screens/Conversation/ConversationScreen.swift index f0d3c8561..10a4a4772 100644 --- a/Flipcash/Core/Screens/Conversation/ConversationScreen.swift +++ b/Flipcash/Core/Screens/Conversation/ConversationScreen.swift @@ -230,9 +230,10 @@ 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. + // The edit blur slides under the navigation bar, so the bar stays sharp through an edit. + // What it holds changes: the counterpart's name and avatar go, since the edit is about one + // message rather than the person, and the back button backs out of the edit rather than the + // chat — leaving the chevron alone in the bar. .navigationBarBackButtonHidden(composer.isEditing) .toolbar { if composer.isEditing { @@ -245,21 +246,19 @@ struct ConversationScreen: View { } .accessibilityLabel("Stop editing") } - } - ToolbarItem(placement: .principal) { - ConversationTitleItem( - title: title, - contact: contact, - conversationID: conversationID, - imageData: contact?.imageData ?? sessionContainer.tipAvatars.data(for: tipCounterpart?.userID), - blurhash: tipCounterpart?.profilePicture?.thumbnailBlurhash, - width: max(navBarWidth - Self.titleSideInset * 2, 0), - onTap: titleTapAction, - opensProfile: profileTapAction != nil - ) - .opacity(composer.isEditing ? 0 : 1) - .allowsHitTesting(!composer.isEditing) - .animation(.easeInOut(duration: 0.2), value: composer.isEditing) + } else { + ToolbarItem(placement: .principal) { + ConversationTitleItem( + title: title, + contact: contact, + conversationID: conversationID, + imageData: contact?.imageData ?? sessionContainer.tipAvatars.data(for: tipCounterpart?.userID), + blurhash: tipCounterpart?.profilePicture?.thumbnailBlurhash, + width: max(navBarWidth - Self.titleSideInset * 2, 0), + onTap: titleTapAction, + opensProfile: profileTapAction != nil + ) + } } } // 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 994e51a60..6d4a7b337 100644 --- a/FlipcashUI/Sources/FlipcashUI/Chat/ChatScreenViewController.swift +++ b/FlipcashUI/Sources/FlipcashUI/Chat/ChatScreenViewController.swift @@ -50,6 +50,13 @@ public final class ChatScreenViewController: UIViewController { private let backdrop = MessageBackdrop() /// The row floated above a held blur, while an edit is open on it. private var editedStableID: String? + /// Deferred attempts left at floating the edited message's copy. The menu's dismissal + /// completion lands while UIKit is still putting the row's own bubble back, so the first + /// attempt usually has nothing to copy; retrying over the next few runloop turns catches it + /// without waiting on a layout pass or a scroll that may never come. + private var spotlightAttemptsRemaining = 0 + /// How many of those attempts a single edit gets. + private static let spotlightAttempts = 8 /// Whether a measured bar height has landed yet — the first one is applied without animation. private var didMeasureBar = false @@ -182,13 +189,17 @@ public final class ChatScreenViewController: UIViewController { editedStableID = stableID backdrop.present(over: contextMenuBackdropHost, animator: nil) backdrop.hold(clearing: bar, under: hostNavigationController?.navigationBar) - transcript.afterContextMenu { [weak self] in self?.refreshEditSpotlight() } + transcript.afterContextMenu { [weak self] in + self?.spotlightAttemptsRemaining = Self.spotlightAttempts + self?.refreshEditSpotlight() + } } /// Takes the blur down once the edit is over, however it ended. public func endEditSpotlight() { guard editedStableID != nil else { return } editedStableID = nil + spotlightAttemptsRemaining = 0 backdrop.release() } @@ -197,18 +208,19 @@ 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() { + guard let editedStableID else { return } // 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: contextMenuBackdropHost - ) else { return } - if backdrop.hasSpotlight { - backdrop.moveSpotlight(to: frame) - } else if let bubble = transcript.bubbleSnapshot(forStableID: editedStableID) { - backdrop.setSpotlight(bubble, at: frame) + if let frame = transcript.bubbleFrame(forStableID: editedStableID, in: contextMenuBackdropHost) { + if backdrop.hasSpotlight { + backdrop.moveSpotlight(to: frame) + } else if let bubble = transcript.bubbleSnapshot(forStableID: editedStableID) { + backdrop.setSpotlight(bubble, at: frame) + } } + guard !backdrop.hasSpotlight, spotlightAttemptsRemaining > 0 else { return } + spotlightAttemptsRemaining -= 1 + Task { @MainActor [weak self] in self?.refreshEditSpotlight() } } /// The navigation stack this screen is inside, if any — the SwiftUI hosting controllers this diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/ChatViewController.swift b/FlipcashUI/Sources/FlipcashUI/Chat/ChatViewController.swift index eec08e458..52dfc0c1e 100644 --- a/FlipcashUI/Sources/FlipcashUI/Chat/ChatViewController.swift +++ b/FlipcashUI/Sources/FlipcashUI/Chat/ChatViewController.swift @@ -657,7 +657,13 @@ extension ChatViewController { /// blur, because a `UIVisualEffectView` does not reliably honour a layer mask. func bubbleSnapshot(forStableID stableID: String) -> UIView? { guard let cell = bubbleCell(forStableID: stableID) else { return nil } - return cell.liftPreviewView.snapshotView(afterScreenUpdates: true) + let bubble = cell.liftPreviewView + // UIKit hides the row's own bubble for as long as its lifted preview is on screen and + // unhides it as the dismissal lands. Snapshotting in that window returns a view that is + // blank rather than nil, which would float an empty copy and never be retried — so report + // "not yet" and let the caller ask again. + guard !bubble.isHidden, bubble.alpha > 0, !bubble.bounds.isEmpty else { return nil } + return bubble.snapshotView(afterScreenUpdates: true) } /// Where that row's bubble currently sits, in `space`'s coordinates, or `nil` when it is not on diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/MessageBackdrop.swift b/FlipcashUI/Sources/FlipcashUI/Chat/MessageBackdrop.swift index cf15cbdc9..a94f64afe 100644 --- a/FlipcashUI/Sources/FlipcashUI/Chat/MessageBackdrop.swift +++ b/FlipcashUI/Sources/FlipcashUI/Chat/MessageBackdrop.swift @@ -28,6 +28,11 @@ final class MessageBackdrop { /// Matches the fade UIKit uses for its own dimming when no animator is supplied. private static let fallbackDuration: TimeInterval = 0.2 + /// Stands in for the dimming UIKit lays over the screen while a context menu is up, which goes + /// with the menu. Without it a held blur reads about twice as light as the one the menu had — + /// measured on the same patch of empty transcript, rgb 23 under the menu against 44 after it. + private static let heldDimAlpha: CGFloat = 0.48 + /// Called when the held blur is tapped — the way out of an edit, as tapping outside the message /// is in WhatsApp. Never fires while a context menu owns the screen: the menu's own container /// sits above the blur and takes those taps. @@ -39,6 +44,12 @@ final class MessageBackdrop { private let effect = UIBlurEffect(style: .systemUltraThinMaterialDark) private var effectView: UIVisualEffectView? private var spotlight: UIView? + /// Replaces the menu's dimming once the menu is gone. Lives inside the blur, so it is clipped + /// and framed with it and sits under the floated copy. + private var dim: UIView? + /// Holds the floated copy and clips it to the blur, so a copy of a row that has scrolled past + /// either edge can't draw over the composer or the navigation bar. + private var spotlightClip: UIView? /// The composer bar a held blur stops short of, re-measured on every layout pass. private weak var clearance: UIView? @@ -80,6 +91,24 @@ final class MessageBackdrop { } // The frame is driven by the bar from here on, so the host can no longer resize it. blur.autoresizingMask = [] + + // Fade the stand-in dim in now, while the menu is still up: its own dimming fades out with + // the dismissal, so the two cross and the screen never brightens between the states. + let dim = UIView(frame: blur.bounds) + dim.backgroundColor = .black + dim.alpha = 0 + dim.autoresizingMask = [.flexibleWidth, .flexibleHeight] + dim.isUserInteractionEnabled = false + blur.contentView.addSubview(dim) + self.dim = dim + UIView.animate(withDuration: Self.fallbackDuration) { dim.alpha = Self.heldDimAlpha } + + let clip = UIView() + clip.clipsToBounds = true + clip.isUserInteractionEnabled = false + host.insertSubview(clip, aboveSubview: blur) + spotlightClip = clip + layoutHeld() blur.isUserInteractionEnabled = true @@ -93,6 +122,7 @@ final class MessageBackdrop { 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)) + spotlightClip?.frame = blur.frame } /// Floats `bubble` — a detached copy of the edited message — above a held blur, at `frame` in @@ -100,13 +130,14 @@ final class MessageBackdrop { /// /// A copy rather than a hole cut in the blur: a `UIVisualEffectView` renders its backdrop /// through a private layer that ignores `layer.mask`, and the real bubble can't be raised out of - /// the collection view that owns it. + /// the collection view that owns it. It goes in the clip rather than straight into the host, so + /// it stops where the blur does instead of covering the composer. func setSpotlight(_ bubble: UIView, at frame: CGRect) { - guard let blur = effectView, isHeld, let host = blur.superview else { return } + guard isHeld, let clip = spotlightClip else { return } spotlight?.removeFromSuperview() bubble.frame = frame bubble.isUserInteractionEnabled = false - host.insertSubview(bubble, aboveSubview: blur) + clip.addSubview(bubble) spotlight = bubble } @@ -145,12 +176,17 @@ final class MessageBackdrop { let bubble = spotlight spotlight = nil + let clip = spotlightClip + spotlightClip = nil + let dim = self.dim + self.dim = nil UIView.animate(withDuration: Self.fallbackDuration) { blur.effect = nil bubble?.alpha = 0 + dim?.alpha = 0 } completion: { _ in blur.removeFromSuperview() - bubble?.removeFromSuperview() + clip?.removeFromSuperview() } }