Skip to content
Closed
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
111 changes: 92 additions & 19 deletions FlipcashUI/Sources/FlipcashUI/Chat/ChatScreenViewController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -264,6 +264,8 @@ public final class ChatScreenViewController: UIViewController {
private var reactionStripWindow: UIWindow?
/// The side the live strip grows from, so it collapses back the same way.
private var stripHugsTrailing = false
/// Whether the live strip sits below its bubble, so it collapses back toward it.
private var stripSitsBelowBubble = false
/// The raised copy of a double-tapped bubble and the frame it returns to, while the strip is up
/// without a menu.
private var stripOnlyLift: (copy: UIView, home: CGRect)?
Expand Down Expand Up @@ -297,7 +299,6 @@ public final class ChatScreenViewController: UIViewController {
handOffComposerFocusAroundContextMenu()
backdrop.onTap = { [weak self] in self?.onCancelEdit?() }
transcript.onScroll = { [weak self] in self?.refreshEditSpotlight() }
transcript.liftReservesStripRoom = { [weak self] message in self?.stripEntries(for: message) != nil }
transcript.onBubbleDoubleTap = { [weak self] message in self?.presentStripOnly(for: message) }
}

Expand Down Expand Up @@ -337,9 +338,8 @@ public final class ChatScreenViewController: UIViewController {
return entries
}

/// Adds the reaction strip above the just-lifted bubble, in a window above the context menu's own
/// container — see `reactionStripWindow`. The lift already reserved the strip's room above the
/// bubble (see `ChatViewController.liftReservesStripRoom`), so UIKit has put the menu clear of it.
/// Adds the reaction strip beside the just-lifted bubble, on the side the menu isn't, in a window
/// above the context menu's own container — see `reactionStripWindow`.
private func presentReactionStrip() {
guard let message = transcript.contextMenuMessage, let entries = stripEntries(for: message),
let scene = view.window?.windowScene,
Expand All @@ -349,11 +349,26 @@ public final class ChatScreenViewController: UIViewController {
let bubble = view.convert(bubbleFrame, to: nil)
let strip = installStrip(entries: entries, for: message, in: host, besides: bubble)

// The lifted copy carries its final frame from the menu's first layout pass, before its
// spring has played out, so reading it one turn later lets the strip arrive with the menu.
DispatchQueue.main.async { [weak self, weak strip] in
guard let self, let strip, reactionStrip === strip else { return }
placeStrip(strip, above: transcript.liftedBubbleFrame() ?? bubble)
// UIKit picks the menu's side, and not only by the room left below the bubble, so the strip
// takes whichever side the menu didn't. The menu's views carry their final frames from the
// first layout pass, before its spring has played out, so reading them one turn later lets
// the strip arrive alongside the menu rather than after it.
let titles = Set(message.actions.map(\.title))
DispatchQueue.main.async { [weak self, weak strip, weak window] in
guard let self, let strip, let window, reactionStrip === strip else { return }
let menu = Self.contextMenuFrame(in: scene, excluding: [view, window], titles: titles)
// UIKit may slide the lifted bubble away from its row to make room for the menu, so the
// strip goes by where the lifted bubble sits rather than where the row does.
let lifted = Self.liftedPreviewFrame(in: scene, excluding: [view, window], size: bubble.size) ?? bubble
let menuIsAbove = menu.map { $0.midY < lifted.midY } ?? false
// The same gap UIKit left on the menu's side, so the bubble sits centered between the two.
let menuGap = menu.map { menuIsAbove ? lifted.minY - $0.maxY : $0.minY - lifted.maxY }
let gap = menuGap.flatMap { (0...40).contains($0) ? $0 : nil } ?? Self.fallbackStripGap
if menuIsAbove {
placeStrip(strip, below: lifted, gap: gap)
} else {
placeStrip(strip, above: lifted, gap: gap)
}
}
strip.onSelect = { [weak self] emoji in
self?.transcript.dismissContextMenu()
Expand Down Expand Up @@ -478,30 +493,88 @@ public final class ChatScreenViewController: UIViewController {
return strip
}

/// Sits `strip` `ReactionStripView.bubbleGap` above `lifted` (window coordinates), no higher than
/// the safe area allows, and grows it out of the bubble's corner.
private func placeStrip(_ strip: ReactionStripView, above lifted: CGRect) {
/// Sits `strip` `gap` above `lifted` (window coordinates), no higher than the safe area allows,
/// and grows it out of the bubble's corner.
private func placeStrip(_ strip: ReactionStripView, above lifted: CGRect, gap: CGFloat = ReactionStripView.bubbleGap) {
guard let host = strip.superview, let window = host.window else { return }
let highest = window.safeAreaInsets.top + Self.stripTopMargin
let top = max(highest, lifted.minY - ReactionStripView.bubbleGap - ReactionStripView.height)
let top = max(highest, lifted.minY - gap - ReactionStripView.height)
strip.topAnchor.constraint(equalTo: host.topAnchor, constant: top).isActive = true
host.layoutIfNeeded()
strip.transform = Self.collapsedStripTransform(strip.bounds.size, towardTrailing: stripHugsTrailing)
reveal(strip, sittingBelow: false)
}

/// Sits `strip` `gap` below `lifted` (window coordinates), for a bubble the menu sits above.
private func placeStrip(_ strip: ReactionStripView, below lifted: CGRect, gap: CGFloat) {
guard let host = strip.superview else { return }
strip.topAnchor.constraint(equalTo: host.topAnchor, constant: lifted.maxY + gap).isActive = true
reveal(strip, sittingBelow: true)
}

/// Grows a placed strip out of the bubble's corner.
private func reveal(_ strip: ReactionStripView, sittingBelow: Bool) {
strip.superview?.layoutIfNeeded()
stripSitsBelowBubble = sittingBelow
strip.transform = Self.collapsedStripTransform(strip.bounds.size, towardTrailing: stripHugsTrailing, towardTop: sittingBelow)
strip.revealEntries(fromTrailing: stripHugsTrailing)
UIView.animate(withDuration: 0.4, delay: 0, usingSpringWithDamping: 0.78, initialSpringVelocity: 0) {
strip.transform = .identity
strip.alpha = 1
}
}

/// The strip shrunk into its bottom corner on the bubble's side, so it grows out of the bubble.
private static func collapsedStripTransform(_ size: CGSize, towardTrailing: Bool) -> CGAffineTransform {
/// The strip shrunk into the corner nearest the bubble's side, so it grows out of the bubble.
private static func collapsedStripTransform(_ size: CGSize, towardTrailing: Bool, towardTop: Bool) -> CGAffineTransform {
let scale: CGFloat = 0.3
let dx = size.width * (1 - scale) / 2 * (towardTrailing ? 1 : -1)
let dy = size.height * (1 - scale) / 2
let dy = size.height * (1 - scale) / 2 * (towardTop ? -1 : 1)
return CGAffineTransform(translationX: dx, y: dy).scaledBy(x: scale, y: scale)
}

/// The strip's gap from the bubble when the menu's own gap can't be measured.
private static let fallbackStripGap: CGFloat = 16

/// Where the presented context menu's platter sits on screen, or `nil` if it isn't found. UIKit
/// exposes no frame for the menu, but its rows are plain `UILabel`s carrying the action titles,
/// and the platter is the outermost view around them that is still narrower than the screen.
/// `excluded` subtrees are skipped, so a message whose text matches an action title can't be
/// mistaken for the menu.
private static func contextMenuFrame(in scene: UIWindowScene, excluding excluded: [UIView], titles: Set<String>) -> CGRect? {
var labels: CGRect?
var platter: CGRect?
var pending: [UIView] = scene.windows
while let next = pending.popLast() {
guard !excluded.contains(where: { $0 === next }) else { continue }
if let label = next as? UILabel, let text = label.text, titles.contains(text), !label.isHidden {
let rect = label.convert(label.bounds, to: nil)
labels = labels.map { $0.union(rect) } ?? rect
if platter == nil, let screen = next.window?.bounds {
var outer: UIView = label
while let parent = outer.superview, parent.bounds.width < screen.width * 0.95 {
outer = parent
}
platter = outer.convert(outer.bounds, to: nil)
}
}
pending.append(contentsOf: next.subviews)
}
guard let labels else { return nil }
return platter.map { $0.union(labels) } ?? labels
}

/// Where UIKit put the lifted copy of the bubble, found as the view in the menu's container
/// that matches the bubble's size, or `nil` if none does.
private static func liftedPreviewFrame(in scene: UIWindowScene, excluding excluded: [UIView], size: CGSize) -> CGRect? {
var pending: [UIView] = scene.windows
while let next = pending.popLast() {
guard !excluded.contains(where: { $0 === next }) else { continue }
if !next.isHidden, abs(next.bounds.width - size.width) < 1, abs(next.bounds.height - size.height) < 1 {
return next.convert(next.bounds, to: nil)
}
pending.append(contentsOf: next.subviews)
}
return nil
}

/// The strip's distance from the screen's sides.
private static let stripSideMargin: CGFloat = 16
/// The least room the strip keeps below the status bar.
Expand All @@ -513,7 +586,7 @@ public final class ChatScreenViewController: UIViewController {
reactionStrip = nil
let window = reactionStripWindow
reactionStripWindow = nil
let collapsed = Self.collapsedStripTransform(strip.bounds.size, towardTrailing: stripHugsTrailing)
let collapsed = Self.collapsedStripTransform(strip.bounds.size, towardTrailing: stripHugsTrailing, towardTop: stripSitsBelowBubble)
let collapse = {
strip.transform = collapsed
strip.alpha = 0
Expand Down
67 changes: 1 addition & 66 deletions FlipcashUI/Sources/FlipcashUI/Chat/ChatViewController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -240,16 +240,10 @@ public final class ChatViewController: UICollectionViewController {
/// of the time.
private(set) var contextMenuMessage: ChatMessage?

/// Whether a long-pressed row should lift with room above it for the reaction strip. The screen
/// sets this, since it owns what the strip offers.
var liftReservesStripRoom: ((ChatMessage) -> Bool)?
/// Fired when a bubble that can take a reaction is double-tapped, to present the strip on its own.
var onBubbleDoubleTap: ((ChatMessage) -> Void)?
private weak var bubbleDoubleTap: UITapGestureRecognizer?
/// The preview a lift with strip room hands UIKit: a copy of the bubble at the bottom of a clear
/// box that also holds the strip's room. Kept so the dismissal flies the same box home.
private var liftContainer: UIView?
/// The copy inside `liftContainer`, or the one a strip-only lift handed out.
/// The copy a strip-only lift handed out.
private weak var liftStandIn: UIView?
/// The row's own bubble, hidden while a copy stands in for it. UIKit hides the source of a
/// preview it lifts itself, but not one it is handed a detached copy for.
Expand Down Expand Up @@ -1184,7 +1178,6 @@ extension ChatViewController {
private func resumeAfterLift() {
hiddenLiftSource?.alpha = 1
hiddenLiftSource = nil
liftContainer = nil
liftStandIn = nil
// Restore the inset while the flag is still set, so the behavior switch's inset change is
// suppressed (no stray scroll); then drop the flag and apply any held update.
Expand Down Expand Up @@ -1232,27 +1225,6 @@ extension ChatViewController {
resumeAfterLift()
}

/// Where the lifted bubble sits on screen, in window coordinates, or `nil` when none is up. UIKit
/// draws the lift through a portal of the box, so the box's own frame says nothing about where it
/// landed; the platter is found instead as the view outside the transcript that matches the box's
/// size, and the bubble is its bottom part.
func liftedBubbleFrame() -> CGRect? {
guard let container = liftContainer, let copy = liftStandIn,
let scene = collectionView.window?.windowScene else { return nil }
let size = container.bounds.size
var pending: [UIView] = scene.windows
while let next = pending.popLast() {
guard next !== container, next !== collectionView else { continue }
if !next.isHidden, abs(next.bounds.width - size.width) < 1, abs(next.bounds.height - size.height) < 1 {
let platter = next.convert(next.bounds, to: nil)
let height = platter.height * copy.frame.height / size.height
return CGRect(x: platter.minX, y: platter.maxY - height, width: platter.width, height: height)
}
pending.append(contentsOf: next.subviews)
}
return nil
}

/// A raised copy of `cell`'s bubble.
private func liftCopy(of cell: BubbleCarrying) -> UIView? {
let bubble = cell.liftPreviewView
Expand Down Expand Up @@ -1359,10 +1331,6 @@ extension ChatViewController {
// An empty path turns off UIKit's own preview shadow, which on iOS 27 traces the view's square
// bounds and outlasts the dismissal. The bubble-shaped shadow below is the only one.
parameters.shadowPath = UIBezierPath()
if let message = message(at: IndexPath(item: item, section: section)), liftReservesStripRoom?(message) == true,
let preview = stripRoomPreview(for: cell, parameters: parameters, raising: raising) {
return preview
}
// 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.
Expand All @@ -1372,39 +1340,6 @@ extension ChatViewController {
}
return UITargetedPreview(view: cell.liftPreviewView, parameters: parameters)
}

/// The lift for a row the strip will sit above: the bubble's copy at the bottom of a clear box
/// `ReactionStripView.headroom` taller than the bubble, centered so the copy starts over the row.
/// UIKit keeps the whole box on screen, which moves a bubble near the top down far enough for the
/// strip, and puts the menu clear of the box. The dismissal reuses the box raised with the lift.
private func stripRoomPreview(for cell: BubbleCarrying, parameters: UIPreviewParameters, raising: Bool) -> UITargetedPreview? {
let bubble = cell.liftPreviewView
let headroom = ReactionStripView.headroom
let container: UIView
if !raising, let existing = liftContainer {
container = existing
} else {
guard let copy = liftCopy(of: cell) else { return nil }
container = UIView(frame: CGRect(x: 0, y: 0, width: bubble.bounds.width, height: bubble.bounds.height + headroom))
container.backgroundColor = .clear
copy.frame.origin = CGPoint(x: 0, y: headroom)
container.addSubview(copy)
liftContainer = container
liftStandIn = copy
liftedBubble = copy
hideLiftSource(bubble)
}
let shape = cell.liftPreviewMaskingPath.map { $0.copy() as! UIBezierPath }
?? UIBezierPath(rect: bubble.bounds)
shape.apply(CGAffineTransform(translationX: 0, y: headroom))
// UIKit sizes the lifted platter to this path's bounds, so the clear headroom goes in too;
// without it the platter is the bubble alone and the menu can cover the strip.
shape.append(UIBezierPath(rect: CGRect(x: 0, y: 0, width: bubble.bounds.width, height: headroom)))
parameters.visiblePath = shape
let frame = collectionView.convert(bubble.bounds, from: bubble)
let target = UIPreviewTarget(container: collectionView, center: CGPoint(x: frame.midX, y: frame.midY - headroom / 2))
return UITargetedPreview(view: container, parameters: parameters, target: target)
}
}

#Preview("Transcript") {
Expand Down
Loading