From 22c73d96f09fca352ac4a445e98917d97fbbcdd8 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Mon, 28 Sep 2026 18:00:53 -0400 Subject: [PATCH] fix(chat): polish the reaction strip and add double-tap The strip's fixed gradient mask faded both ends at rest, so the first emoji looked disabled before anything had scrolled. The fades now follow the scroll position: the leading one runs 20pt while the row can scroll back, and the trailing one runs 14pt from the middle of the "+" while it can scroll forward, with the row hidden past it. Both grow in over the first 20pt of travel and mirror in RTL. Emoji are centered on their ink bounds rather than the label's line box, the "+" is 40pt, and the end padding is inset + "+" + spacing (52pt, was 74pt). The strip always opens above the bubble. The lift hands UIKit a preview 55 + 16pt taller than the bubble, so UIKit keeps room for the strip on screen and places the menu clear of it; a bubble at the top of the transcript moves down instead of the strip flipping below. Double-tapping a text bubble that can take a reaction presents the strip alone, with no menu. Link cards, cash cards, quote panels and reaction pills keep their instant single tap. --- .../Chat/ChatScreenViewController.swift | 260 ++++++++++-------- .../FlipcashUI/Chat/ChatViewController.swift | 209 ++++++++++++-- .../FlipcashUI/Chat/ReactionStripView.swift | 151 ++++++++-- 3 files changed, 470 insertions(+), 150 deletions(-) diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/ChatScreenViewController.swift b/FlipcashUI/Sources/FlipcashUI/Chat/ChatScreenViewController.swift index a6a4c5601..ea6fbec3e 100644 --- a/FlipcashUI/Sources/FlipcashUI/Chat/ChatScreenViewController.swift +++ b/FlipcashUI/Sources/FlipcashUI/Chat/ChatScreenViewController.swift @@ -262,9 +262,11 @@ public final class ChatScreenViewController: UIViewController { /// `handOffComposerFocusAroundContextMenu`) — the only way for the strip to receive taps while a /// system context menu is on screen. private var reactionStripWindow: UIWindow? - /// The corner the live strip grows from, so it collapses back the same way. + /// The side the live strip grows from, so it collapses back the same way. private var stripHugsTrailing = false - 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)? public override func viewDidLoad() { super.viewDidLoad() @@ -295,6 +297,8 @@ 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) } } /// Blurs the screen behind a context menu, takes the keyboard down for its lifetime, and puts @@ -326,18 +330,117 @@ public final class ChatScreenViewController: UIViewController { } } - /// 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`. No-ops when the message - /// under the menu doesn't offer one (see `ChatMessage.offersReactionStrip`) or the owner hasn't - /// wired `reactionStripEntries`. + /// What the reaction strip offers for `message`, or `nil` when it offers none: the message can't + /// take a reaction (see `ChatMessage.offersReactionStrip`), or the owner supplies no entries. + private func stripEntries(for message: ChatMessage) -> [ReactionStrip.Entry]? { + guard message.offersReactionStrip, let entries = reactionStripEntries?(message), !entries.isEmpty else { return nil } + 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. private func presentReactionStrip() { - guard let message = transcript.contextMenuMessage, message.offersReactionStrip, - let entriesProvider = reactionStripEntries else { return } - let entries = entriesProvider(message) - guard !entries.isEmpty, let scene = view.window?.windowScene, + guard let message = transcript.contextMenuMessage, let entries = stripEntries(for: message), + let scene = view.window?.windowScene, let bubbleFrame = transcript.contextMenuBubbleFrame(in: view) else { return } - let window = StripWindow(windowScene: scene) + let host = installStripWindow(window) + 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) + } + strip.onSelect = { [weak self] emoji in + self?.transcript.dismissContextMenu() + self?.onReactionStripSelect?(message.messageID, emoji) + } + strip.onAdd = { [weak self] in + self?.transcript.dismissContextMenu() + self?.onReactionStripAdd?(message.messageID) + } + reactionStrip = strip + reactionStripWindow = window + } + + /// Presents the reaction strip on its own for a double-tapped bubble, as iMessage does a tapback: + /// the same blur and lift as a long press, with no menu. The bubble moves down if the strip + /// wouldn't fit above it. A tap anywhere off the strip dismisses it. + private func presentStripOnly(for message: ChatMessage) { + guard reactionStrip == nil, let entries = stripEntries(for: message), + let scene = view.window?.windowScene, + let lift = transcript.beginStripLift(for: message, in: view) else { return } + let window = UIWindow(windowScene: scene) + let host = installStripWindow(window) + let home = view.convert(lift.frame, to: nil) + let copy = lift.copy + copy.frame = home + host.view.addSubview(copy) + let strip = installStrip(entries: entries, for: message, in: host, besides: home) + reactionStrip = strip + reactionStripWindow = window + stripOnlyLift = (copy, home) + + let tapOutside = UITapGestureRecognizer(target: self, action: #selector(stripOnlyTappedOutside)) + host.view.addGestureRecognizer(tapOutside) + strip.onSelect = { [weak self] emoji in + self?.onReactionStripSelect?(message.messageID, emoji) + self?.dismissStripOnly() + } + strip.onAdd = { [weak self] in + self?.onReactionStripAdd?(message.messageID) + self?.dismissStripOnly() + } + + backdrop.present(over: contextMenuBackdropHost, animator: nil) + if let responder = bar.firstTextInputResponder { + composerHeldKeyboardUnderMenu = responder.isFirstResponder + _ = responder.resignFirstResponder() + } + + var lifted = home + let top = window.safeAreaInsets.top + Self.stripTopMargin + let room = window.bounds.height - top - window.safeAreaInsets.bottom + // A bubble too tall to fit under the strip stays where it is, and the strip clamps to the top. + if home.minY - ReactionStripView.headroom < top, home.height + ReactionStripView.headroom <= room { + lifted.origin.y = top + ReactionStripView.headroom + } + UIView.animate(withDuration: 0.4, delay: 0, usingSpringWithDamping: 0.82, initialSpringVelocity: 0) { + copy.frame = lifted + } + placeStrip(strip, above: lifted) + } + + @objc private func stripOnlyTappedOutside() { + dismissStripOnly() + } + + /// Flies the double-tap lift home, collapses its strip and blur, and hands the transcript back. + private func dismissStripOnly() { + guard let (copy, home) = stripOnlyLift else { return } + stripOnlyLift = nil + let window = reactionStripWindow + dismissReactionStrip(animator: nil) + backdrop.dismiss(animator: nil) + BubbleBackgroundView.fadeLift(copy, duration: ChatMotion.contextMenuLanding) + UIView.animate(withDuration: ChatMotion.contextMenuLanding, delay: 0, options: .curveEaseInOut) { + copy.frame = home + } completion: { [weak self] _ in + window?.isHidden = true + self?.transcript.endStripLift() + } + if composerHeldKeyboardUnderMenu { + composerHeldKeyboardUnderMenu = false + _ = bar.firstTextInputResponder?.becomeFirstResponder() + } + } + + /// Shows `window` above the context menu's own container, with an empty root to hold the strip. + private func installStripWindow(_ window: UIWindow) -> UIViewController { // Above `_UIContextMenuContainerView`'s own window, which UIKit places just over the key // window — anything at `.normal` or below is covered by the menu and never sees a tap. window.windowLevel = .alert + 1 @@ -347,18 +450,23 @@ public final class ChatScreenViewController: UIViewController { host.view.backgroundColor = .clear window.rootViewController = host window.isHidden = false + return host + } + /// Adds a hidden strip for `message` to `host`, pinned to the side of `bubble` (window + /// coordinates) the bubble hugs. `placeStrip` sets its height and brings it in. + private func installStrip(entries: [ReactionStrip.Entry], for message: ChatMessage, in host: UIViewController, besides bubble: CGRect) -> ReactionStripView { let strip = ReactionStripView() strip.configure(entries: entries) strip.alpha = 0 strip.translatesAutoresizingMaskIntoConstraints = false host.view.addSubview(strip) - let bubble = view.convert(bubbleFrame, to: nil) - let margin: CGFloat = 16 + let margin = Self.stripSideMargin + let bounds = host.view.bounds // Hug the bubble's own side: outgoing bubbles sit on the trailing edge, incoming on the leading. - let hugsTrailing = bubble.midX > window.bounds.midX + let hugsTrailing = bubble.midX > bounds.midX let hug = hugsTrailing - ? strip.trailingAnchor.constraint(equalTo: host.view.leadingAnchor, constant: min(bubble.maxX, window.bounds.width - margin)) + ? strip.trailingAnchor.constraint(equalTo: host.view.leadingAnchor, constant: min(bubble.maxX, bounds.width - margin)) : strip.leadingAnchor.constraint(equalTo: host.view.leadingAnchor, constant: max(bubble.minX, margin)) hug.priority = .defaultHigh NSLayoutConstraint.activate([ @@ -366,103 +474,38 @@ public final class ChatScreenViewController: UIViewController { strip.trailingAnchor.constraint(lessThanOrEqualTo: host.view.trailingAnchor, constant: -margin), hug, ]) - - // 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)) - let place = { [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 copy 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 - (menuIsAbove - ? strip.topAnchor.constraint(equalTo: host.view.topAnchor, constant: lifted.maxY + gap) - : strip.bottomAnchor.constraint(equalTo: host.view.topAnchor, constant: lifted.minY - gap) - ).isActive = true - host.view.layoutIfNeeded() - stripSitsBelowBubble = menuIsAbove - strip.transform = Self.collapsedStripTransform(strip.bounds.size, towardTrailing: hugsTrailing, towardTop: menuIsAbove) - strip.revealEntries(fromTrailing: hugsTrailing) - UIView.animate(withDuration: 0.4, delay: 0, usingSpringWithDamping: 0.78, initialSpringVelocity: 0) { - strip.transform = .identity - strip.alpha = 1 - } - } - DispatchQueue.main.async(execute: place) - - strip.onSelect = { [weak self] emoji in - self?.transcript.dismissContextMenu() - self?.onReactionStripSelect?(message.messageID, emoji) - } - strip.onAdd = { [weak self] in - self?.transcript.dismissContextMenu() - self?.onReactionStripAdd?(message.messageID) - } - - reactionStrip = strip - reactionStripWindow = window stripHugsTrailing = hugsTrailing + 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) { + 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) + strip.topAnchor.constraint(equalTo: host.topAnchor, constant: top).isActive = true + host.layoutIfNeeded() + strip.transform = Self.collapsedStripTransform(strip.bounds.size, towardTrailing: stripHugsTrailing) + 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 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 { + /// 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 { let scale: CGFloat = 0.3 let dx = size.width * (1 - scale) / 2 * (towardTrailing ? 1 : -1) - let dy = size.height * (1 - scale) / 2 * (towardTop ? -1 : 1) + let dy = size.height * (1 - scale) / 2 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) -> 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. + private static let stripTopMargin: CGFloat = 16 /// Collapses the strip back toward the bubble alongside the menu's own dismissal. private func dismissReactionStrip(animator: UIContextMenuInteractionAnimating?) { @@ -470,18 +513,19 @@ public final class ChatScreenViewController: UIViewController { reactionStrip = nil let window = reactionStripWindow reactionStripWindow = nil - let collapsed = Self.collapsedStripTransform(strip.bounds.size, towardTrailing: stripHugsTrailing, towardTop: stripSitsBelowBubble) + let collapsed = Self.collapsedStripTransform(strip.bounds.size, towardTrailing: stripHugsTrailing) let collapse = { strip.transform = collapsed strip.alpha = 0 } - let finish: () -> Void = { window?.isHidden = true } + // A strip-only lift takes its window down once the bubble has landed instead. + let ownsWindow = stripOnlyLift == nil && window is StripWindow + let finish: () -> Void = { if ownsWindow { window?.isHidden = true } } if let animator { animator.addAnimations(collapse) animator.addCompletion(finish) } else { - collapse() - finish() + UIView.animate(withDuration: ChatMotion.contextMenuLanding, animations: collapse) { _ in finish() } } } diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/ChatViewController.swift b/FlipcashUI/Sources/FlipcashUI/Chat/ChatViewController.swift index 9a210786d..75ba2344b 100644 --- a/FlipcashUI/Sources/FlipcashUI/Chat/ChatViewController.swift +++ b/FlipcashUI/Sources/FlipcashUI/Chat/ChatViewController.swift @@ -240,6 +240,21 @@ 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. + 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. + private weak var hiddenLiftSource: UIView? + /// Work handed over by a menu action to run, in order, once the menu has finished dismissing. A /// `becomeFirstResponder` issued from a `UIAction` is rejected while the menu still owns the /// screen, so choosing Edit parks the keyboard-raise here instead. @@ -275,6 +290,17 @@ public final class ChatViewController: UICollectionViewController { dismissKeyboardTap.cancelsTouchesInView = false dismissKeyboardTap.delegate = self collectionView.addGestureRecognizer(dismissKeyboardTap) + // Double-tapping a bubble presents the reaction strip on its own. It only receives touches + // that land on a bubble that can take one (see `doubleTapTarget(at:)`), so the keyboard's + // tap waits on it there and nowhere else: lowering the keyboard between the two taps would + // reflow the transcript and move the second tap onto another row. + let doubleTap = UITapGestureRecognizer(target: self, action: #selector(bubbleDoubleTapped)) + doubleTap.numberOfTapsRequired = 2 + doubleTap.delaysTouchesEnded = false + doubleTap.delegate = self + collectionView.addGestureRecognizer(doubleTap) + dismissKeyboardTap.require(toFail: doubleTap) + bubbleDoubleTap = doubleTap // The adjusted content inset (safe area + the bar inset the owner sets) is how the keyboard // and bar reserve space; ChatLayout reads it for positioning, so let UIKit manage it. collectionView.contentInsetAdjustmentBehavior = .always @@ -569,6 +595,36 @@ public final class ChatViewController: UICollectionViewController { collectionView.window?.endEditing(true) } + // MARK: - Double tap + + @objc private func bubbleDoubleTapped(_ tap: UITapGestureRecognizer) { + guard let message = doubleTapTarget(at: tap.location(in: collectionView)) else { return } + onBubbleDoubleTap?(message) + } + + /// The message whose bubble is at `point` if a double tap there should present the reaction + /// strip. Only text bubbles take one: link cards, cash cards, quote panels and reaction pills + /// keep their instant single tap. + private func doubleTapTarget(at point: CGPoint) -> ChatMessage? { + guard !isUpdating, !isShowingContextMenu, + let indexPath = collectionView.indexPathForItem(at: point), + let message = message(at: indexPath), + message.offersReactionStrip, !message.rendersAsBareLinkCard, + let cell = collectionView.cellForItem(at: indexPath) as? BubbleCarrying else { return nil } + switch message.content { + case .text: break + case .cash, .deleted: return nil + } + let bubble = cell.liftPreviewView + guard bubble.bounds.contains(bubble.convert(point, from: collectionView)) else { return nil } + var view = collectionView.hitTest(point, with: nil) + while let current = view, current !== bubble { + if current is ReactionPillRowView || current is ChatQuotePanelView || current is LinkCardView { return nil } + view = current.superview + } + return message + } + // MARK: - Scrolling /// Open at the newest message once there is content and real bounds. Runs once — ChatLayout @@ -978,6 +1034,13 @@ extension ChatViewController: ChatLayoutDelegate { } extension ChatViewController: UIGestureRecognizerDelegate { + /// Keeps the double tap to bubbles that present the reaction strip, so a tap anywhere else lowers + /// the keyboard without waiting on it. + public func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldReceive touch: UITouch) -> Bool { + guard gestureRecognizer === bubbleDoubleTap else { return true } + return doubleTapTarget(at: touch.location(in: collectionView)) != nil + } + /// Lets the tap-to-dismiss recognizer fire alongside the collection view's own scroll and /// selection recognizers, so lowering the keyboard never pre-empts a cell tap. /// @@ -1103,25 +1166,7 @@ extension ChatViewController { let resume: () -> Void = { [weak self] in guard let self, !resumed, generation == contextMenuGeneration else { return } resumed = true - // 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. - restoreInset() - isShowingContextMenu = false - if let liftedBubble { - BubbleBackgroundView.lower(liftedBubble) - self.liftedBubble = nil - } - if let inset = pendingBottomInset { - pendingBottomInset = nil - setBottomInset(inset) - } - if let pending = deferredItems { - deferredItems = nil - update(items: pending) - } - let held = pendingAfterContextMenu - pendingAfterContextMenu = [] - for work in held { work() } + resumeAfterLift() } if let animator { // UIKit's completion arrives well after the bubble is back in its row (about 0.7s on @@ -1134,6 +1179,95 @@ extension ChatViewController { } } + /// Hands the transcript back once a lift has landed: shows the row's bubble again, releases the + /// inset, and applies whatever was held while the lift was up. + 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. + restoreInset() + isShowingContextMenu = false + if let liftedBubble { + BubbleBackgroundView.lower(liftedBubble) + self.liftedBubble = nil + } + if let inset = pendingBottomInset { + pendingBottomInset = nil + setBottomInset(inset) + } + if let pending = deferredItems { + deferredItems = nil + update(items: pending) + } + let held = pendingAfterContextMenu + pendingAfterContextMenu = [] + for work in held { work() } + } + + /// Lifts the row drawing `message` for the reaction strip alone, with no menu. Holds the + /// transcript still as a context menu does and hides the row's bubble; returns a raised copy of + /// the bubble and where the bubble sits in `space`. Nil when the row is off screen or a lift or + /// update already holds the transcript. End it with `endStripLift()`. + func beginStripLift(for message: ChatMessage, in space: UICoordinateSpace) -> (copy: UIView, frame: CGRect)? { + guard !isShowingContextMenu, !isUpdating, + let item = items.firstIndex(where: { $0.id == message.id }), + let cell = collectionView.cellForItem(at: IndexPath(item: item, section: 0)) as? BubbleCarrying, + let copy = liftCopy(of: cell) else { return nil } + isShowingContextMenu = true + contextMenuGeneration += 1 + freezeInset() + let bubble = cell.liftPreviewView + let frame = space.convert(bubble.bounds, from: bubble) + hideLiftSource(bubble) + liftStandIn = copy + return (copy, frame) + } + + /// Ends a strip-only lift once its copy is back over the row. + func endStripLift() { + guard isShowingContextMenu, contextMenuMessage == nil else { return } + 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 + guard !bubble.bounds.isEmpty, let copy = bubble.snapshotView(afterScreenUpdates: false) else { return nil } + BubbleBackgroundView.raise(copy, shape: cell.liftPreviewMaskingPath) + return copy + } + + private func hideLiftSource(_ bubble: UIView) { + if hiddenLiftSource !== bubble { hiddenLiftSource?.alpha = 1 } + hiddenLiftSource = bubble + // Alpha, not `isHidden`: a hidden arranged subview collapses the row's stack. + bubble.alpha = 0 + } + /// Run `work` once no context menu is on screen — immediately if none is up, otherwise after the /// current one finishes dismissing. public func afterContextMenu(_ work: @escaping () -> Void) { @@ -1225,6 +1359,10 @@ 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. @@ -1234,6 +1372,39 @@ 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") { diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/ReactionStripView.swift b/FlipcashUI/Sources/FlipcashUI/Chat/ReactionStripView.swift index 70b99745f..3e2709b63 100644 --- a/FlipcashUI/Sources/FlipcashUI/Chat/ReactionStripView.swift +++ b/FlipcashUI/Sources/FlipcashUI/Chat/ReactionStripView.swift @@ -9,30 +9,39 @@ import UIKit import FlipcashCore -/// The row of emoji offered above a long-pressed bubble, alongside the existing `UIMenu`, on a -/// Liquid Glass capsule. The emoji scroll sideways under the trailing "+", which stays pinned and -/// fades the row out beneath it. Tapping an emoji toggles the reaction and dismisses the menu; "+" -/// dismisses and opens the picker instead. +/// The row of emoji offered above a long-pressed or double-tapped bubble, on a Liquid Glass capsule. +/// The emoji scroll sideways toward the trailing "+", which stays pinned; each edge fades only while +/// there is more of the row to scroll to on that side. Tapping an emoji toggles the reaction and +/// dismisses the strip; "+" dismisses and opens the picker instead. /// /// A plain view, not a context-menu preview accessory — `UIContextMenuConfiguration` has no slot for /// one, and the menu's container already sits above the window root (see /// `ChatScreenViewController.handOffComposerFocusAroundContextMenu`), so this is added as a sibling /// above that container instead, positioned over the lifted bubble's frame. -final class ReactionStripView: UIView { +final class ReactionStripView: UIView, UIScrollViewDelegate { /// Fired with the tapped emoji. var onSelect: ((String) -> Void)? var onAdd: (() -> Void)? - // Sizes from the strip in node 9779:105563. + // Sizes from the strip in node 9779:105563. Android's `QuickReactionStrip` uses the same values. static let height: CGFloat = 55 static let maxWidth: CGFloat = 313 + /// The space between the strip and the lifted bubble it sits above. + static let bubbleGap: CGFloat = 16 + /// The room a lifted bubble keeps above itself for the strip. + static let headroom: CGFloat = height + bubbleGap private static let itemSize: CGFloat = 40 private static let itemSpacing: CGFloat = 4 private static let inset: CGFloat = 8 - private static let addSize: CGFloat = 38 - /// How far the fade reaches left of the "+" before the row is fully drawn. - private static let fadeLead: CGFloat = 28 + private static let addSize: CGFloat = 40 + /// How far the leading fade reaches in from the edge, and how much scroll either fade takes to + /// grow in, so neither snaps on. + private static let edgeFade: CGFloat = 20 + /// The trailing fade's run, from the middle of the "+" to where its circle is last as tall as an + /// emoji. Past that the row is hidden, so no emoji shows around the circle's curve. + private static let trailingFade: CGFloat = 14 + private static let emojiFont = UIFont.systemFont(ofSize: 28) private let surface: UIView /// Holds the fade mask; on the scroll view itself the mask would ride its moving bounds. @@ -41,6 +50,9 @@ final class ReactionStripView: UIView { private let stack = UIStackView() private let addButton = UIButton(type: .system) private let fade = CAGradientLayer() + /// Set by `configure`, so the next layout pass scrolls the row to its leading end once the + /// content has a size. + private var needsRestingOffset = false override init(frame: CGRect) { if #available(iOS 26, *) { @@ -62,20 +74,19 @@ final class ReactionStripView: UIView { scrollView.showsHorizontalScrollIndicator = false scrollView.alwaysBounceHorizontal = true - // Room to bring the last emoji out from under the "+" and its fade. + scrollView.contentInsetAdjustmentBehavior = .never + scrollView.delegate = self + // Scrolled to the end, the last emoji stops `itemSpacing` short of the "+". + let plusSide = Self.inset + Self.addSize + Self.itemSpacing scrollView.contentInset = UIEdgeInsets( - top: 0, left: Self.inset, bottom: 0, - right: Self.inset + Self.addSize + Self.fadeLead + top: 0, left: isRightToLeft ? plusSide : Self.inset, + bottom: 0, right: isRightToLeft ? Self.inset : plusSide ) scrollView.translatesAutoresizingMaskIntoConstraints = false fadeHost.translatesAutoresizingMaskIntoConstraints = false content.addSubview(fadeHost) fadeHost.addSubview(scrollView) - fade.startPoint = CGPoint(x: 0, y: 0.5) - fade.endPoint = CGPoint(x: 1, y: 0.5) - // Clear at the leading inset too, so scrolled-past emoji don't poke out of the capsule's curve. - fade.colors = [UIColor.clear.cgColor, UIColor.black.cgColor, UIColor.black.cgColor, UIColor.clear.cgColor, UIColor.clear.cgColor] fadeHost.layer.mask = fade stack.axis = .horizontal @@ -145,14 +156,57 @@ final class ReactionStripView: UIView { override func layoutSubviews() { super.layoutSubviews() + if needsRestingOffset { + scrollView.layoutIfNeeded() + if scrollView.contentSize.width > 0 { + needsRestingOffset = false + scrollView.contentOffset.x = isRightToLeft + ? scrollView.contentSize.width + scrollView.contentInset.right - scrollView.bounds.width + : -scrollView.contentInset.left + } + } + updateFade() + } + + func scrollViewDidScroll(_ scrollView: UIScrollView) { + updateFade() + } + + private var isRightToLeft: Bool { + effectiveUserInterfaceLayoutDirection == .rightToLeft + } + + /// Fades each end only while the row can scroll that way, growing in over the first `edgeFade` + /// of travel. The leading fade runs `edgeFade` in from the edge; the trailing one runs + /// `trailingFade` from the middle of the "+", and the rest of the row under the "+" is hidden to + /// the same strength. At rest, with nothing to scroll back to, the leading edge draws solid. + private func updateFade() { let width = fadeHost.bounds.width guard width > 0 else { return } + let offset = scrollView.contentOffset.x + let lowest = -scrollView.contentInset.left + let highest = max(lowest, scrollView.contentSize.width + scrollView.contentInset.right - scrollView.bounds.width) + let back = isRightToLeft ? highest - offset : offset - lowest + let forward = isRightToLeft ? offset - lowest : highest - offset + let leading = min(1, max(0, back / Self.edgeFade)) + let trailing = min(1, max(0, forward / Self.edgeFade)) + let clearFrom = width - Self.inset - Self.addSize / 2 + Self.trailingFade + + // Measured from the leading edge; flipped into the layer's left-to-right space below. + let stops = [0, Self.edgeFade, clearFrom - Self.trailingFade, clearFrom, width].map { min(1, max(0, $0 / width)) } + let alphas = [1 - leading, 1, 1, 1 - trailing, 1 - trailing] CATransaction.begin() CATransaction.setDisableActions(true) fade.frame = fadeHost.bounds - let clearFrom = width - Self.inset - Self.addSize / 2 - let solidUntil = width - Self.inset - Self.addSize - Self.fadeLead - fade.locations = [0, Self.inset / width, solidUntil / width, clearFrom / width, 1].map { NSNumber(value: Double($0)) } + if isRightToLeft { + fade.startPoint = CGPoint(x: 1, y: 0.5) + fade.endPoint = CGPoint(x: 0, y: 0.5) + } else { + fade.startPoint = CGPoint(x: 0, y: 0.5) + fade.endPoint = CGPoint(x: 1, y: 0.5) + } + fade.colors = alphas.map { UIColor.black.withAlphaComponent($0).cgColor } + fade.locations = stops.map { NSNumber(value: Double($0)) } CATransaction.commit() } @@ -162,9 +216,12 @@ final class ReactionStripView: UIView { $0.removeFromSuperview() } for entry in entries { - let button = UIButton(type: .system) - button.setTitle(entry.emoji, for: .normal) - button.titleLabel?.font = .systemFont(ofSize: 28) + // Drawn as an image cropped to the glyph's ink, so the button centers the emoji itself + // rather than the taller line box a title label would center. + var configuration = UIButton.Configuration.plain() + configuration.image = Self.glyphImage(entry.emoji) + configuration.contentInsets = .zero + let button = UIButton(configuration: configuration) button.backgroundColor = entry.highlighted ? UIColor.white.withAlphaComponent(0.18) : .clear button.layer.cornerRadius = Self.itemSize / 2 button.clipsToBounds = true @@ -174,7 +231,55 @@ final class ReactionStripView: UIView { button.accessibilityLabel = entry.emoji stack.addArrangedSubview(button) } - scrollView.contentOffset = CGPoint(x: -Self.inset, y: 0) + needsRestingOffset = true + setNeedsLayout() + } + + private static var glyphImages: [String: UIImage] = [:] + + /// `emoji` at the strip's size, cropped to its drawn pixels. + private static func glyphImage(_ emoji: String) -> UIImage { + if let cached = glyphImages[emoji] { return cached } + let text = NSAttributedString(string: emoji, attributes: [.font: emojiFont]) + let box = text.size() + let line = UIGraphicsImageRenderer(size: CGSize(width: ceil(box.width), height: ceil(box.height))).image { _ in + text.draw(at: .zero) + } + // Cropped by pixel rather than by `usesDeviceMetrics`, which stops Apple Color Emoji at the + // baseline and would cut off everything below it. + let image: UIImage + if let cgImage = line.cgImage, let ink = inkBounds(of: cgImage), let cropped = cgImage.cropping(to: ink) { + image = UIImage(cgImage: cropped, scale: line.scale, orientation: .up) + } else { + image = line + } + glyphImages[emoji] = image.withRenderingMode(.alwaysOriginal) + return glyphImages[emoji]! + } + + /// The smallest pixel rect holding every non-transparent pixel of `image`, or `nil` if it has none. + private static func inkBounds(of image: CGImage) -> CGRect? { + let width = image.width, height = image.height + var alpha = [UInt8](repeating: 0, count: width * height) + let drawn = alpha.withUnsafeMutableBytes { buffer -> Bool in + guard let context = CGContext( + data: buffer.baseAddress, width: width, height: height, bitsPerComponent: 8, + bytesPerRow: width, space: CGColorSpaceCreateDeviceGray(), bitmapInfo: CGImageAlphaInfo.alphaOnly.rawValue + ) else { return false } + context.draw(image, in: CGRect(x: 0, y: 0, width: width, height: height)) + return true + } + guard drawn else { return nil } + var minX = width, minY = height, maxX = -1, maxY = -1 + for y in 0.. 0 { + minX = min(minX, x); maxX = max(maxX, x) + minY = min(minY, y); maxY = max(maxY, y) + } + } + guard maxX >= 0 else { return nil } + // The context's rows run top to bottom in memory, the same order `cropping(to:)` counts. + return CGRect(x: minX, y: minY, width: maxX - minX + 1, height: maxY - minY + 1) } /// Pops the emoji in one after another, starting from the side the strip grows out of. Only the