Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 51 additions & 4 deletions FlipcashUI/Sources/FlipcashUI/Chat/BubbleBackgroundView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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

Expand All @@ -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)
Expand Down
34 changes: 34 additions & 0 deletions FlipcashUI/Sources/FlipcashUI/Chat/ChatScreenViewController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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()
Expand All @@ -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
Expand Down
19 changes: 18 additions & 1 deletion FlipcashUI/Sources/FlipcashUI/Chat/ChatViewController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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?
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
}
}
Expand Down
Loading
Loading