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/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 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) } } diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/MessageBackdrop.swift b/FlipcashUI/Sources/FlipcashUI/Chat/MessageBackdrop.swift index a94f64afe..6c64f6ea4 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 { @@ -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,14 +52,18 @@ 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. 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 @@ -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 { @@ -75,21 +96,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 +129,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,26 +145,31 @@ 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() - bubble.frame = frame + bubble.frame = clampedToClip(frame) bubble.isUserInteractionEnabled = false clip.addSubview(bubble) spotlight = bubble @@ -147,7 +181,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 @@ -155,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() } } } @@ -180,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() }