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