From 984927cf75bcc695a6c7639504d894f1956d6481 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Thu, 3 Sep 2026 14:21:14 -0400 Subject: [PATCH 1/5] fix(chat): run the held blur to the bottom of the screen The blur stopped at the top of the composer, so the bar drew over sharp transcript: its background is a gradient that clears at its own top edge and its controls are glass, both of which sampled unblurred bubbles. Held, the blur now moves out of the navigation controller's view and into the composer's superview, just below the bar, and fills that view. The transcript stays soft to the bottom of the screen; the composer draws over the blur instead of beside it; and the navigation bar still wins, because the screen the blur moved into already sits under it. The clip the floated copy lives in keeps the old job of stopping at the bar's top edge, so the copy can't slide under the composer. --- .../FlipcashUI/Chat/MessageBackdrop.swift | 71 +++++++++++-------- 1 file changed, 42 insertions(+), 29 deletions(-) diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/MessageBackdrop.swift b/FlipcashUI/Sources/FlipcashUI/Chat/MessageBackdrop.swift index a94f64afe..a62e28be8 100644 --- a/FlipcashUI/Sources/FlipcashUI/Chat/MessageBackdrop.swift +++ b/FlipcashUI/Sources/FlipcashUI/Chat/MessageBackdrop.swift @@ -17,11 +17,11 @@ import UIKit /// the menu itself in a container above the window's root, so the platter and the lift stay sharp. /// /// 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, -/// 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. +/// which is what keeps the transcript from flashing back to legible between the two states. Held, it +/// slides down the hierarchy to just under the composer, so the transcript stays soft to the bottom +/// of the screen while the composer and the navigation bar — the two pieces of chrome an edit needs +/// sharp — draw over it; and it carries a detached copy of the edited bubble above itself and takes +/// the taps that land outside it. @MainActor final class MessageBackdrop { @@ -47,10 +47,10 @@ final class MessageBackdrop { /// 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. + /// Holds the floated copy and clips it to the composer bar, 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. + /// The composer bar the floated copy 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 @@ -75,21 +75,23 @@ final class MessageBackdrop { } } - /// 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. + /// Keeps the blur up after the menu that raised it goes, and starts taking taps. Nothing + /// sharpens on the way into an edit: only the blur's z-order changes, dropping just below `bar` + /// in the bar's own superview. That leaves it above the transcript, so the transcript stays + /// soft; below the composer, so the composer's own chrome stays sharp; and below the navigation + /// bar, since the screen it moves into already sits under it — the back button stays legible and + /// tappable. Sitting behind the composer rather than stopping at its top edge is what lets it run + /// to the bottom of the screen: the bar's background is a gradient that clears at its own top and + /// its controls are glass, so a blur that stopped short would show a band of sharp transcript + /// through them. 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 } + guard let blur = effectView, let host = blur.superview, let barHost = bar.superview else { return } isHeld = true clearance = 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. + barHost.insertSubview(blur, belowSubview: bar) + // The frame is driven by the layout pass 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 @@ -106,7 +108,13 @@ final class MessageBackdrop { let clip = UIView() clip.clipsToBounds = true clip.isUserInteractionEnabled = false - host.insertSubview(clip, aboveSubview: blur) + // Stays in the host the menu blurred, above the whole screen the blur has moved into, so the + // floated copy is the one thing over the composer — but still under the navigation bar. + if let navigationBar, navigationBar.superview === host { + host.insertSubview(clip, belowSubview: navigationBar) + } else { + host.addSubview(clip) + } spotlightClip = clip layoutHeld() @@ -116,22 +124,27 @@ final class MessageBackdrop { 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. + /// Re-measures a held blur, and the clip the floated copy lives in, which stops at the composer + /// bar as it 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)) - spotlightClip?.frame = blur.frame + guard isHeld, let blur = effectView, let bar = clearance else { return } + if let blurHost = blur.superview { + blur.frame = blurHost.bounds + } + if let clip = spotlightClip, let clipHost = clip.superview { + let barTop = bar.convert(bar.bounds, to: clipHost).minY + clip.frame = CGRect(x: 0, y: 0, width: clipHost.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. + /// Floats `bubble` — a detached copy of the edited message — above a held blur, at `frame` in the + /// coordinates of the host the blur was presented over. /// /// 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. It goes in the clip rather than straight into the host, so - /// it stops where the blur does instead of covering the composer. + /// it stops at the composer instead of covering it. func setSpotlight(_ bubble: UIView, at frame: CGRect) { guard isHeld, let clip = spotlightClip else { return } spotlight?.removeFromSuperview() From dcb24389124157e99c715faf653a02b7dae3abe4 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Thu, 3 Sep 2026 14:21:14 -0400 Subject: [PATCH 2/5] fix(chat): keep the floated copy from being clipped away mid-reflow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The floated copy appeared, vanished for about a second, then animated back in from the wrong place. The clip's bottom edge tracks the composer bar, which rises on its own animation as the keyboard returns, so for a beat the copy's last reported frame sat below an edge that had already moved up — and `clipsToBounds` cut the whole thing away rather than trimming it. Clamp the frame's origin so the copy's bottom stays inside the clip. It hugs the composer for that beat instead of disappearing, and snaps to its true position on the next reflow. --- .../FlipcashUI/Chat/MessageBackdrop.swift | 20 +++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/MessageBackdrop.swift b/FlipcashUI/Sources/FlipcashUI/Chat/MessageBackdrop.swift index a62e28be8..b2ef2b2cb 100644 --- a/FlipcashUI/Sources/FlipcashUI/Chat/MessageBackdrop.swift +++ b/FlipcashUI/Sources/FlipcashUI/Chat/MessageBackdrop.swift @@ -148,7 +148,7 @@ final class MessageBackdrop { func setSpotlight(_ bubble: UIView, at frame: CGRect) { guard isHeld, let clip = spotlightClip else { return } spotlight?.removeFromSuperview() - bubble.frame = frame + bubble.frame = clampedToClip(frame) bubble.isUserInteractionEnabled = false clip.addSubview(bubble) spotlight = bubble @@ -160,7 +160,23 @@ final class MessageBackdrop { /// Moves the floated copy as the keyboard and the bar reflow the transcript underneath it. A /// no-op when nothing is floating, so a layout pass before the copy exists is harmless. func moveSpotlight(to frame: CGRect) { - spotlight?.frame = frame + spotlight?.frame = clampedToClip(frame) + } + + /// Keeps a spotlight frame's bottom edge inside the clip instead of letting `clipsToBounds` cut + /// the whole copy away. The clip's own bottom edge tracks the composer bar live, and the bar + /// moves on its own animation as the keyboard returns after an edit is chosen — a beat where the + /// transcript is still settling into its post-edit scroll position can put the copy's last known + /// frame below the clip's already-shrunk bottom edge, which reads as the message disappearing + /// rather than merely trailing the reflow. Clamping keeps it in view, hugging the composer, until + /// the next reflow reports its true position. + private func clampedToClip(_ frame: CGRect) -> CGRect { + guard let clipHeight = spotlightClip?.bounds.height else { return frame } + let maxY = clipHeight - frame.height + guard maxY >= 0 else { return frame } + var result = frame + result.origin.y = min(frame.origin.y, maxY) + return result } /// Fades the blur out with the menu and takes it off screen once it has gone. A held blur From 981c7cdabcab293e831d359cbff039fbf210a37b Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Thu, 3 Sep 2026 14:21:14 -0400 Subject: [PATCH 3/5] fix(chat): hold off swipe-back while an edit is open MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An edit owns the whole screen — the blur takes every tap outside the message and the composer is the only way out — but the pop gesture stayed live, so a swipe carried the screen away underneath it. Worse, the blur and the floated copy are hosted by the navigation stack rather than by this screen, so they survived the pop: they stayed on whatever screen it landed on, eating taps, with nothing left to dismiss them. Suspend every enabled pop pan on the navigation view for the length of an edit and restore exactly those, so one that was already off stays off. Every pan, not just `interactivePopGestureRecognizer`: a sheet-hosted stack carries a second one, and it is the untouched twin that pops (`EdgeOnlySwipeBack` gates both for the same reason). `viewWillDisappear` also ends the edit, as a backstop for any other way off the screen. --- .../Chat/ChatScreenViewController.swift | 34 +++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/ChatScreenViewController.swift b/FlipcashUI/Sources/FlipcashUI/Chat/ChatScreenViewController.swift index 6d4a7b337..174b46021 100644 --- a/FlipcashUI/Sources/FlipcashUI/Chat/ChatScreenViewController.swift +++ b/FlipcashUI/Sources/FlipcashUI/Chat/ChatScreenViewController.swift @@ -59,6 +59,9 @@ public final class ChatScreenViewController: UIViewController { private static let spotlightAttempts = 8 /// Whether a measured bar height has landed yet — the first one is applied without animation. private var didMeasureBar = false + /// The pop gestures switched off for the length of an edit, kept so only those are switched back + /// on and one that was already off stays off. + private var suspendedPopGestures: [UIGestureRecognizer] = [] /// - Parameters: /// - bar: pinned to the bottom of the view; rides the keyboard. @@ -189,6 +192,7 @@ public final class ChatScreenViewController: UIViewController { editedStableID = stableID backdrop.present(over: contextMenuBackdropHost, animator: nil) backdrop.hold(clearing: bar, under: hostNavigationController?.navigationBar) + setPopGesturesSuspended(true) transcript.afterContextMenu { [weak self] in self?.spotlightAttemptsRemaining = Self.spotlightAttempts self?.refreshEditSpotlight() @@ -200,9 +204,39 @@ public final class ChatScreenViewController: UIViewController { guard editedStableID != nil else { return } editedStableID = nil spotlightAttemptsRemaining = 0 + setPopGesturesSuspended(false) backdrop.release() } + /// Ends an edit the screen is leaving in — a backstop for any way off this screen that isn't the + /// edit's own. The blur and the floated copy are hosted by the navigation stack rather than by + /// this screen, so they outlive a pop that leaves an edit open: they stay on whatever screen the + /// pop lands on, taking its taps, with nothing left to dismiss them. + public override func viewWillDisappear(_ animated: Bool) { + super.viewWillDisappear(animated) + endEditSpotlight() + } + + /// Suspends swipe-to-go-back for the length of an edit, and puts back exactly what it suspended. + /// + /// An edit owns the whole screen — the blur takes every tap outside the message, and the composer + /// is the only way out — so leaving the pop gesture live let a swipe carry the screen away from + /// underneath it. A sheet-hosted stack carries a second pop recognizer alongside + /// `interactivePopGestureRecognizer`, and it is the untouched twin that pops (see + /// `EdgeOnlySwipeBack`), so every pop pan on the navigation view is suspended. + private func setPopGesturesSuspended(_ suspended: Bool) { + guard suspended else { + suspendedPopGestures.forEach { $0.isEnabled = true } + suspendedPopGestures = [] + return + } + guard suspendedPopGestures.isEmpty, let navigation = hostNavigationController else { return } + let pops = (navigation.view.gestureRecognizers ?? []) + .filter { $0 is UIPanGestureRecognizer && $0.isEnabled } + pops.forEach { $0.isEnabled = false } + suspendedPopGestures = pops + } + /// Puts the edited message's copy where its row now sits — floating it the first time, and /// re-framing it on every reflow after that, since the copy lives outside the transcript and /// doesn't follow the cell on its own. A row scrolled out of the transcript leaves the copy at From 7ca87996a3bc86778a41a385225b29c4cf3b5178 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Thu, 3 Sep 2026 14:21:15 -0400 Subject: [PATCH 4/5] fix(chat): soften the held blur toward Android's MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The edit backdrop read as near-black: a full-strength material stacked on a 0.48 dim chosen to match the context menu's own dimming exactly. Android frosts the same screen with a regular Haze material at a 25dp radius and no scrim at all, which is much lighter, and that is the look we want. `UIBlurEffect` exposes no radius — every style is the same radius under a different tint — so the effect goes on through a `UIViewPropertyAnimator` left paused at 0.4, which is the only handle on its strength. The animator has to be retained and stopped by hand, since one that deallocates while active traps. With the effect only partly applied, the fades move from `effect` to `alpha`. The dim drops to 0.2. The transcript now reads as soft shapes rather than flat colour. The consequence is that an edit is lighter than the menu it comes from, so there is a slight brightening as the menu gives way — the menu's dimming is UIKit's and can't be lightened to meet it. --- .../FlipcashUI/Chat/MessageBackdrop.swift | 48 +++++++++++++++---- 1 file changed, 38 insertions(+), 10 deletions(-) diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/MessageBackdrop.swift b/FlipcashUI/Sources/FlipcashUI/Chat/MessageBackdrop.swift index b2ef2b2cb..6c64f6ea4 100644 --- a/FlipcashUI/Sources/FlipcashUI/Chat/MessageBackdrop.swift +++ b/FlipcashUI/Sources/FlipcashUI/Chat/MessageBackdrop.swift @@ -29,9 +29,18 @@ final class MessageBackdrop { 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 + /// with the menu — measured on the same patch of empty transcript, rgb 23 under the menu against + /// 44 after it. Set well below the value that matches the menu exactly: matching it left an edit + /// as dark as the menu, which is heavier than Android's frosting of the same screen, and the + /// menu's own dimming isn't ours to lighten to meet it. + private static let heldDimAlpha: CGFloat = 0.2 + + /// How much of the material's blur is used. A `UIBlurEffect` has no radius to set — every style + /// is the same radius under a different tint — so the effect is applied through an animator that + /// is paused part-way, which is the only handle on its strength. At full strength the transcript + /// smears into flat colour; Android frosts the same screen at a 25dp radius and reads far softer, + /// and this is matched to that. + private static let blurFraction: CGFloat = 0.4 /// 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 @@ -43,6 +52,10 @@ final class MessageBackdrop { private let effect = UIBlurEffect(style: .systemUltraThinMaterialDark) private var effectView: UIVisualEffectView? + /// Holds the blur at `blurFraction`. Never played out — it is a dial, not an animation — but it + /// has to be kept alive and stopped by hand, since a property animator left active when it + /// deallocates traps. + private var blurStrength: UIViewPropertyAnimator? 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. @@ -64,10 +77,18 @@ final class MessageBackdrop { blur.isUserInteractionEnabled = false blur.frame = host.bounds blur.autoresizingMask = [.flexibleWidth, .flexibleHeight] + blur.alpha = 0 host.addSubview(blur) effectView = blur - let fadeIn = { blur.effect = self.effect } + // Applied part-way and left there, so the material never reaches full strength. Its duration + // is never played out; the fade is the view's own alpha, which is also what a partly-applied + // effect leaves available to animate. + let strength = UIViewPropertyAnimator(duration: 1, curve: .linear) { blur.effect = self.effect } + strength.fractionComplete = Self.blurFraction + blurStrength = strength + + let fadeIn = { blur.alpha = 1 } if let animator { animator.addAnimations(fadeIn) } else { @@ -184,15 +205,19 @@ final class MessageBackdrop { func dismiss(animator: UIContextMenuInteractionAnimating?) { guard !isHeld, let blur = effectView else { return } effectView = nil + let strength = blurStrength + blurStrength = nil - let fadeOut = { blur.effect = nil } + let fadeOut = { blur.alpha = 0 } + let takeDown = { + strength?.stopAnimation(true) + blur.removeFromSuperview() + } if let animator { animator.addAnimations(fadeOut) - animator.addCompletion { blur.removeFromSuperview() } + animator.addCompletion(takeDown) } else { - UIView.animate(withDuration: Self.fallbackDuration, animations: fadeOut) { _ in - blur.removeFromSuperview() - } + UIView.animate(withDuration: Self.fallbackDuration, animations: fadeOut) { _ in takeDown() } } } @@ -209,11 +234,14 @@ final class MessageBackdrop { spotlightClip = nil let dim = self.dim self.dim = nil + let strength = blurStrength + blurStrength = nil UIView.animate(withDuration: Self.fallbackDuration) { - blur.effect = nil + blur.alpha = 0 bubble?.alpha = 0 dim?.alpha = 0 } completion: { _ in + strength?.stopAnimation(true) blur.removeFromSuperview() clip?.removeFromSuperview() } From fb84a8d59a72149bc91ceaf320c7d49427b49206 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Thu, 3 Sep 2026 14:21:15 -0400 Subject: [PATCH 5/5] fix(chat): keep a lifted message looking like itself MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A bubble's fill was translucent white, so it took the colour of whatever sat behind it — and that is not always the transcript. The context menu dims what it covers and an edit blurs it, and both showed straight through: the same message measured rgb 43 in the transcript, 34 under the menu and 57 in an edit. It now carries its own ground, an opaque base with the wash composited over it in its own layer, and reads 43 in all three. Neither lifted state cast a shadow either. A UITargetedPreview built with a clear background casts nothing, with or without shadowPath, so the dimmed transcript sat flat at rgb 17 right up to the lifted bubble's edge. The elevation is now set explicitly and shared by the menu's lift and the edit's floated copy, and taken off again when the menu goes so a recycled cell can't carry it back into the transcript. --- .../Chat/BubbleBackgroundView.swift | 55 +++++++++++++++++-- .../FlipcashUI/Chat/ChatViewController.swift | 19 ++++++- 2 files changed, 69 insertions(+), 5 deletions(-) diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/BubbleBackgroundView.swift b/FlipcashUI/Sources/FlipcashUI/Chat/BubbleBackgroundView.swift index 2358a8780..2705aee21 100644 --- a/FlipcashUI/Sources/FlipcashUI/Chat/BubbleBackgroundView.swift +++ b/FlipcashUI/Sources/FlipcashUI/Chat/BubbleBackgroundView.swift @@ -9,8 +9,16 @@ import UIKit import SwiftUI -/// The shared chrome behind every chat bubble and cash card: a white-opacity fill with a hairline -/// border and a continuous, per-corner rounded shape. A same-sender run flattens the inner corners +/// The shared chrome behind every chat bubble and cash card: a white-opacity wash over the +/// conversation background, with a hairline border and a continuous, per-corner rounded shape. +/// +/// The wash is composited here, over an opaque base, rather than left as a translucent +/// `backgroundColor`. A translucent bubble takes the colour of whatever happens to be behind it, and +/// that is not always the transcript: a context menu dims what it covers and an edit blurs it, and +/// both showed straight through, leaving one message reading three different ways. Carrying its own +/// ground, it renders the same in all three. +/// +/// A same-sender run flattens the inner corners /// from 12 to 4, which UIKit's `cornerCurve`/`maskedCorners` can't express, so the path is taken /// straight from SwiftUI's `UnevenRoundedRectangle(.continuous)` (pure geometry, no hosted SwiftUI /// views) and drawn into a `CAShapeLayer`. @@ -21,6 +29,7 @@ final class BubbleBackgroundView: UIView { static let groupedRadius: CGFloat = 4 private let shapeMask = CAShapeLayer() + private let washLayer = CALayer() private let borderLayer = CAShapeLayer() private var radii = RectangleCornerRadii(topLeading: baseRadius, bottomLeading: baseRadius, bottomTrailing: baseRadius, topTrailing: baseRadius) /// The message this chrome currently draws, so a radii change can be told apart from a recycled @@ -33,6 +42,11 @@ final class BubbleBackgroundView: UIView { override init(frame: CGRect) { super.init(frame: frame) layer.mask = shapeMask + backgroundColor = UIColor(Color.backgroundMain) + // Resized in `layoutSubviews`, where an implicit animation would drag a block of solid + // colour behind the bubble's own frame change. + washLayer.actions = ["position": NSNull(), "bounds": NSNull()] + layer.addSublayer(washLayer) borderLayer.fillColor = UIColor.clear.cgColor borderLayer.strokeColor = UIColor.white.withAlphaComponent(0.03).cgColor borderLayer.lineWidth = 1 @@ -47,7 +61,7 @@ final class BubbleBackgroundView: UIView { /// recycled view taking a new row, and any caller that passes no identity all snap, which is what /// keeps a reused cell from animating in someone else's shape. func apply(fill: UIColor, radii: RectangleCornerRadii, identity: String? = nil) { - backgroundColor = fill + washLayer.backgroundColor = fill.cgColor pendingCornerMorph = identity != nil && identity == self.identity && radii != self.radii self.identity = identity self.radii = radii @@ -65,6 +79,7 @@ final class BubbleBackgroundView: UIView { let previous = shapeMask.path let path = UnevenRoundedRectangle(cornerRadii: radii, style: .continuous).path(in: bounds).cgPath shapeMask.path = path + washLayer.frame = bounds borderLayer.path = path borderLayer.frame = bounds @@ -78,7 +93,39 @@ final class BubbleBackgroundView: UIView { } } - /// White-opacity fill for a sender. Designed for the app's dark conversation background. + /// The elevation a bubble sits at once it has been lifted out of the transcript. + /// + /// Set by hand rather than left to UIKit. A `UITargetedPreview` built with a clear background + /// casts nothing — with or without `shadowPath` — so the menu arrived with the lifted message + /// flat against the transcript, measured at rgb 17 right up to its edge on all sides. Owning the + /// values here also means the menu's lift and the edit that follows it share one shadow rather + /// than one of them guessing at a system default the other inherited. + private static let liftShadowOpacity: Float = 0.65 + private static let liftShadowRadius: CGFloat = 20 + private static let liftShadowOffset = CGSize(width: 0, height: 10) + + /// Raises `view` to the lifted plane. `shape` is the bubble's own path, so the shadow follows a + /// flattened grouped corner instead of falling back to the view's square bounds. + /// + /// Applied to the view *hosting* the chrome, never to this view: its layer is masked to the + /// bubble shape, and a mask clips a shadow as readily as it clips a sublayer. + static func raise(_ view: UIView, shape: UIBezierPath?) { + view.layer.shadowColor = UIColor.black.cgColor + view.layer.shadowOpacity = liftShadowOpacity + view.layer.shadowRadius = liftShadowRadius + view.layer.shadowOffset = liftShadowOffset + view.layer.shadowPath = shape?.cgPath + } + + /// Returns `view` to the transcript's plane. Must run for every `raise`, including on the way out + /// of a menu that was dismissed rather than acted on — the lifted bubble is a live cell subview, + /// and a recycled cell that kept the shadow would cast it in the transcript. + static func lower(_ view: UIView) { + view.layer.shadowOpacity = 0 + view.layer.shadowPath = nil + } + + /// White-opacity wash for a sender, composited over the conversation background by `apply`. static func fill(isFromSelf: Bool) -> UIColor { isFromSelf ? UIColor.white.withAlphaComponent(0.08) diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/ChatViewController.swift b/FlipcashUI/Sources/FlipcashUI/Chat/ChatViewController.swift index 52dfc0c1e..46e9b1cdb 100644 --- a/FlipcashUI/Sources/FlipcashUI/Chat/ChatViewController.swift +++ b/FlipcashUI/Sources/FlipcashUI/Chat/ChatViewController.swift @@ -87,6 +87,9 @@ public final class ChatViewController: UICollectionViewController { /// value (see `freezeInset`): the keyboard's space stays reserved, so nothing moves — and the /// keyboard sliding back on dismiss restores everything to exactly where it was, matching iMessage. private var isShowingContextMenu = false + /// The bubble a context menu has raised, held so the lift's elevation comes off the same view when + /// the menu goes. Weak: the cell it belongs to can be recycled out from under the menu. + private weak var liftedBubble: UIView? /// The inset state captured when the menu opened, restored when it closes. private var savedInsetBehavior: UIScrollView.ContentInsetAdjustmentBehavior? private var savedContentInset: UIEdgeInsets? @@ -622,6 +625,10 @@ extension ChatViewController { // suppressed (no stray scroll); then drop the flag and apply any held update. restoreInset() isShowingContextMenu = false + if let liftedBubble { + BubbleBackgroundView.lower(liftedBubble) + self.liftedBubble = nil + } if let inset = pendingBottomInset { pendingBottomInset = nil setBottomInset(inset) @@ -663,7 +670,12 @@ extension ChatViewController { // 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) + guard let copy = bubble.snapshotView(afterScreenUpdates: true) else { return nil } + // The snapshot renders the bubble's bounds, so the lift's shadow — drawn outside them — isn't + // in it. Re-applied here, at the same values the menu used, so the message doesn't drop back + // onto the transcript's plane the moment the menu that raised it goes. + BubbleBackgroundView.raise(copy, shape: cell.liftPreviewMaskingPath) + return copy } /// Where that row's bubble currently sits, in `space`'s coordinates, or `nil` when it is not on @@ -694,6 +706,11 @@ extension ChatViewController { let parameters = UIPreviewParameters() parameters.visiblePath = cell.liftPreviewMaskingPath parameters.backgroundColor = .clear + // The lift's elevation, put on the bubble itself because the preview won't carry one: a clear + // background casts nothing, `shadowPath` or not. Taken off again in `willEndContextMenu`'s + // completion — this is a live cell subview, not a copy. + liftedBubble = cell.liftPreviewView + BubbleBackgroundView.raise(cell.liftPreviewView, shape: cell.liftPreviewMaskingPath) return UITargetedPreview(view: cell.liftPreviewView, parameters: parameters) } }