From 815e06d8bd87a010d3f0776bf235a51521b3abd0 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Thu, 15 Oct 2026 19:57:40 -0400 Subject: [PATCH] fix(chat): stand the jump button outside the bar clip so the reply strip no longer leaks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #1068 drew the jump-to-bottom button inside the bar and cut an 88x76pt hole in the bar clip's mask so it could show above the clip. The hole was pinned to the clip's top edge, and that edge is what slides to reveal and hide the reply strip. While it moved, the hole showed the strip's trailing end, and the stray ✕ in it took taps. The button now has its own hosting view, pinned on top of the clip's top edge and below the clip in z-order, so it rides every move of that edge and the clip cuts the bar at its bounds again. The view takes touches only in its trailing 88pt and only while the button shows. It decides by position because SwiftUI answers a tap on its content with the hosting view itself, the same view it returns for empty space. --- .../ChatScreenRepresentable.swift | 10 +- .../Conversation/ConversationBottomBar.swift | 25 ++-- .../Chat/ChatScreenBarClipTests.swift | 41 +++++-- .../Chat/ChatScreenViewController.swift | 116 +++++++++--------- .../FlipcashUI/Chat/JumpToBottomModel.swift | 3 +- 5 files changed, 108 insertions(+), 87 deletions(-) diff --git a/Flipcash/Core/Screens/Conversation/ChatScreenRepresentable.swift b/Flipcash/Core/Screens/Conversation/ChatScreenRepresentable.swift index 06f4c962a..62c9d93a2 100644 --- a/Flipcash/Core/Screens/Conversation/ChatScreenRepresentable.swift +++ b/Flipcash/Core/Screens/Conversation/ChatScreenRepresentable.swift @@ -153,7 +153,12 @@ struct ChatScreenRepresentable: UIViewControllerRepresentable { // indicator's safe area. Left to respect that safe area, the bar pushes its content up by // however far it rests into it, past the clip's top edge. barHost.safeAreaRegions = [] - let screen = ChatScreenViewController(bar: barHost.view, barController: barHost, jump: jump) + let jumpHost = UIHostingController(rootView: JumpToBottomOverlay(jump: jump, model: barModel)) + jumpHost.view.backgroundColor = .clear + // The badge stands past the button's top edge. + jumpHost.view.clipsToBounds = false + jumpHost.safeAreaRegions = [] + let screen = ChatScreenViewController(bar: barHost.view, barController: barHost, jump: jump, jumpButtonController: jumpHost) screen.focusesComposerOnAppear = focusOnAppear screen.isTranscriptObscured = gate.obscuresTranscript screen.showsGatePlaceholder = showsGatePlaceholder @@ -334,7 +339,6 @@ struct ChatScreenRepresentable: UIViewControllerRepresentable { onSendCash: onSendCash, model: barModel, composer: composer, - jump: coordinator.jump, gate: gate, gateMintName: gateMintName, gateShortfall: gateShortfall, @@ -380,7 +384,7 @@ struct ChatScreenRepresentable: UIViewControllerRepresentable { @MainActor final class Coordinator { var barHost: UIHostingController? - /// What the bar's jump-to-bottom button shows; handed to the screen that drives it. + /// What the jump-to-bottom button shows; handed to the screen that drives it. var jump: JumpToBottomModel? weak var screen: ChatScreenViewController? let model: ConversationBarModel diff --git a/Flipcash/Core/Screens/Conversation/ConversationBottomBar.swift b/Flipcash/Core/Screens/Conversation/ConversationBottomBar.swift index 65e9250b9..9184ae2a2 100644 --- a/Flipcash/Core/Screens/Conversation/ConversationBottomBar.swift +++ b/Flipcash/Core/Screens/Conversation/ConversationBottomBar.swift @@ -165,19 +165,20 @@ struct BarOverflowReporting: ViewModifier { } } -/// The jump-to-bottom button, drawn in the bar's own hierarchy above its trailing control so the one -/// transaction that moves `$` with the keyboard moves the button too. +/// The jump-to-bottom button, centred over the bar's trailing control at the foot of the box the +/// screen stands on the bar's top edge. /// -/// It stands outside the bar's measured height, so the transcript's inset and the keyboard floor -/// never see it; the screen's clip leaves the area it travels uncut. +/// It is hosted outside the bar, so the transcript's inset and the keyboard floor never see it and +/// the bar's clip never cuts it. struct JumpToBottomOverlay: View { let jump: JumpToBottomModel - let isCompact: Bool - /// Whether the attach panel is up and takes every touch outside itself, the button's included. - let takesTouches: Bool + let model: ConversationBarModel var body: some View { let isShown = jump.isShown + let isCompact = !model.isComposing + // The attach panel takes every touch outside itself, the button's included. + let takesTouches = !model.attachPanel.isOpen // Centred on the trailing control's column, which is narrower than the button. let inset = BarMetrics.trailingControlCentreInset(isCompact: isCompact) - JumpToBottomButtonView.diameter / 2 @@ -191,7 +192,8 @@ struct JumpToBottomOverlay: View { .animation(ChatMotion.reflow.animation, value: isShown) .padding(.trailing, inset) .animation(ChatMotion.barInset.animation, value: isCompact) - .offset(y: -(JumpToBottomButtonView.diameter + BarMetrics.jumpButtonGapAboveBar)) + .padding(.bottom, BarMetrics.jumpButtonGapAboveBar) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottomTrailing) } } @@ -321,8 +323,6 @@ struct ConversationBottomBar: View { let onSendCash: () -> Void let model: ConversationBarModel let composer: ComposerModel - /// What the jump-to-bottom button shows, or `nil` where the bar has none. - var jump: JumpToBottomModel? = nil /// Whether the chat's participation rules leave this user anything to type. Anything but /// ``ConversationGatePresentation/open`` replaces the whole composer with the gate panel. var gate: ConversationGatePresentation = .open @@ -637,11 +637,6 @@ struct ConversationBottomBar: View { .onGeometryChange(for: CGFloat.self, of: { $0.size.height }) { composerRowHeight = $0 } .preference(key: BarAccessoriesKey.self, value: BarAccessories(restHeight: composerRowHeight)) } - .overlay(alignment: .topTrailing) { - if let jump { - JumpToBottomOverlay(jump: jump, isCompact: isCompact, takesTouches: !model.attachPanel.isOpen) - } - } .onChange(of: mentions == nil ? nil : composer.mentionQuery?.text, initial: true) { _, query in mentions?.update(query: query) } diff --git a/FlipcashTests/Chat/ChatScreenBarClipTests.swift b/FlipcashTests/Chat/ChatScreenBarClipTests.swift index 2e5025829..42e3cdcf3 100644 --- a/FlipcashTests/Chat/ChatScreenBarClipTests.swift +++ b/FlipcashTests/Chat/ChatScreenBarClipTests.swift @@ -204,21 +204,38 @@ struct ChatScreenBarClipTests { #expect(clip.frame == clipFrame, "The clip, which rides the keyboard floor, is the composer's alone") } - @Test("Above the clip, only the button's band takes touches") - func jumpBand_takesTouchesAboveTheClipOnlyInItsBand() throws { - let (_, bar, _) = makeScreen() + @Test("The button's box stands on the clip's edge through a reply, and takes only the button's touches") + func jumpButton_ridesTheClipEdgeAndPassesOtherTouchesThrough() throws { + let buttonHost = UIViewController() + // SwiftUI answers a hit on its content with the hosting view itself, not a subview. + let button = UIView(frame: CGRect(x: 320, y: 20, width: 44, height: 44)) + button.isUserInteractionEnabled = false + buttonHost.view.addSubview(button) + let bar = UIView() + let screen = ChatScreenViewController(bar: bar, jumpButtonController: buttonHost) + let window = UIWindow(frame: CGRect(x: 0, y: 0, width: 390, height: 844)) + window.rootViewController = screen + window.makeKeyAndVisible() let clip = try #require(bar.superview) - let reach = JumpToBottomButtonView.reach + let box = try #require(buttonHost.view.superview) - let inBand = CGPoint(x: clip.bounds.width - reach.width / 2, y: -reach.height / 2) - #expect(clip.point(inside: inBand, with: nil)) - - let beside = CGPoint(x: clip.bounds.width - reach.width - 30, y: -reach.height / 2) - #expect(!clip.point(inside: beside, with: nil), "A tap beside the button belongs to the transcript") - #expect(clip.hitTest(beside, with: nil) == nil) + for (height, open, exiting) in [(composerRow, [], 0), (composerRow + strip, [.reply], 0), (composerRow + strip, [], strip)] as [(CGFloat, Set, CGFloat)] { + report(screen, height, open: open, exiting: exiting) + window.layoutIfNeeded() + #expect(box.frame.maxY == clip.frame.minY) + #expect(box.frame.height == JumpToBottomButtonView.reach.height) + } - let above = CGPoint(x: clip.bounds.width - reach.width / 2, y: -reach.height - 1) - #expect(!clip.point(inside: above, with: nil)) + let onButton = button.convert(CGPoint(x: 22, y: 22), to: window) + screen.jump.isVisible = true + #expect(window.hitTest(onButton, with: nil) === buttonHost.view, "The button takes its own tap") + screen.jump.isVisible = false + #expect(window.hitTest(onButton, with: nil) !== buttonHost.view, "A hidden button takes nothing") + screen.jump.isVisible = true + let beside = button.convert(CGPoint(x: -100, y: 22), to: window) + let hit = window.hitTest(beside, with: nil) + #expect(hit !== box && hit !== buttonHost.view, "A tap beside the button belongs to the transcript") + #expect(!clip.point(inside: CGPoint(x: clip.bounds.width - 22, y: -22), with: nil), "The clip takes nothing above its edge") } @Test("The room reported leaves the list out and counts the reply strip") diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/ChatScreenViewController.swift b/FlipcashUI/Sources/FlipcashUI/Chat/ChatScreenViewController.swift index 927bb63b1..676d9097b 100644 --- a/FlipcashUI/Sources/FlipcashUI/Chat/ChatScreenViewController.swift +++ b/FlipcashUI/Sources/FlipcashUI/Chat/ChatScreenViewController.swift @@ -24,6 +24,8 @@ public final class ChatScreenViewController: UIViewController { private let transcript = ChatViewController() private let bar: UIView private let barController: UIViewController? + /// Draws the jump-to-bottom button, standing on the clip's top edge outside it. + private let jumpButtonController: UIViewController? /// The box the bar is seen through: the bar sits at its *bottom* edge and is clipped by it. /// /// The reply strip is the reason there are two views rather than one. The bar's own height has @@ -68,7 +70,7 @@ public final class ChatScreenViewController: UIViewController { guard barOverflowsTop != oldValue, barClipHeightConstraint != nil else { return } // The bar is already as tall as the screen and pinned to the clip's bottom, so letting // it draw and take touches above the clip is all the overflow needs. - barClip.clipsBar = !barOverflowsTop + barClip.clipsToBounds = !barOverflowsTop barClip.overflowTarget = barOverflowsTop ? bar : nil } } @@ -112,7 +114,7 @@ public final class ChatScreenViewController: UIViewController { private let backdrop = MessageBackdrop() /// The blur over a transcript the user is not allowed to read. See `TranscriptBlur`. private let transcriptBlur = TranscriptBlur() - /// What the jump-to-bottom button shows. The bar draws the button, above its trailing control. + /// What the jump-to-bottom button shows. `jumpButtonController` draws it. public let jump: JumpToBottomModel /// The decorative shapes the blur softens when there is no transcript to soften. See /// `GatePreviewPlaceholder`. @@ -161,11 +163,20 @@ public final class ChatScreenViewController: UIViewController { /// - barController: the view controller owning the bar, when hosted (e.g. a /// `UIHostingController` for a SwiftUI bar). Adopted as a child so its lifecycle and /// environment work. Pass `nil` for a plain `UIView` bar. - /// - jump: the model the bar draws its jump-to-bottom button from; this screen drives it. - public init(bar: UIView, barController: UIViewController? = nil, jump: JumpToBottomModel = JumpToBottomModel()) { + /// - jump: the model the jump-to-bottom button is drawn from; this screen drives it. + /// - jumpButtonController: draws the jump-to-bottom button, in a box of + /// ``JumpToBottomButtonView/reach`` standing on the bar's top edge across its width. Only the + /// box's trailing `reach.width` takes touches, and only while the button shows. + public init( + bar: UIView, + barController: UIViewController? = nil, + jump: JumpToBottomModel = JumpToBottomModel(), + jumpButtonController: UIViewController? = nil + ) { self.jump = jump self.bar = bar self.barController = barController + self.jumpButtonController = jumpButtonController super.init(nibName: nil, bundle: nil) } @@ -401,8 +412,10 @@ public final class ChatScreenViewController: UIViewController { // Under the blur, so the shapes are only ever seen through it. gatePlaceholder.install(in: view, below: transcriptBlur.view) + // Above the blur and the composer fade, and under the clip, so a bar overflowing its top + // for the attach panel covers the button. + addJumpButton() wireJumpButton() - barClip.overhang = JumpToBottomButtonView.reach barClipHeightConstraint = constraints.clipHeight keyboardFloor = KeyboardFloor(view: view, bottomConstraint: constraints.bottom, keyboardEdgeConstraint: constraints.keyboardEdge) keyboardFloor.restingDrop = barRestingDrop @@ -592,7 +605,7 @@ public final class ChatScreenViewController: UIViewController { ) -> (clipHeight: NSLayoutConstraint, bottom: NSLayoutConstraint, keyboardEdge: NSLayoutConstraint) { if let controller { addChild(controller) } barClip.translatesAutoresizingMaskIntoConstraints = false - barClip.clipsBar = true + barClip.clipsToBounds = true view.addSubview(barClip) let keyboardEdge = addComposerFade(below: barClip) addKeyboardCornerCover(below: barClip) @@ -948,6 +961,32 @@ public final class ChatScreenViewController: UIViewController { didSet { jump.unreadCount = unreadBelowCount } } + /// Stands the button's box on the clip's top edge, so it rides the keyboard and every travel of + /// that edge in the same layout pass as the clip. + private func addJumpButton() { + guard let jumpButtonController else { return } + let host = PassthroughView() + host.takesTouches = { [jump] in jump.isShown } + host.translatesAutoresizingMaskIntoConstraints = false + view.insertSubview(host, belowSubview: barClip) + addChild(jumpButtonController) + let button = jumpButtonController.view! + button.backgroundColor = .clear + button.translatesAutoresizingMaskIntoConstraints = false + host.addSubview(button) + jumpButtonController.didMove(toParent: self) + NSLayoutConstraint.activate([ + host.leadingAnchor.constraint(equalTo: barClip.leadingAnchor), + host.trailingAnchor.constraint(equalTo: barClip.trailingAnchor), + host.bottomAnchor.constraint(equalTo: barClip.topAnchor), + host.heightAnchor.constraint(equalToConstant: JumpToBottomButtonView.reach.height), + button.topAnchor.constraint(equalTo: host.topAnchor), + button.leadingAnchor.constraint(equalTo: host.leadingAnchor), + button.trailingAnchor.constraint(equalTo: host.trailingAnchor), + button.bottomAnchor.constraint(equalTo: host.bottomAnchor), + ]) + } + private func wireJumpButton() { jump.onTap = { [weak self] in self?.transcript.scrollToBottomFromJumpButton() } transcript.onJumpButtonVisibilityChange = { [weak self] visible in @@ -1159,54 +1198,6 @@ private final class BarClipView: UIView { /// Where, in window coordinates, the target starts taking those touches; `nil` for all of it. var overflowTouchTop: CGFloat? - /// The area above the box's top edge, from its trailing side, that is left uncut and takes - /// touches: where the bar draws its jump-to-bottom button. Touches in it that the bar's content - /// does not claim fall through to what is behind the box. - var overhang: CGSize = .zero { - didSet { applyClipping() } - } - - /// Whether the bar is cut at the box's edges. Off while the bar overflows. - var clipsBar = true { - didSet { applyClipping() } - } - - // A mask view rather than a layer: its frame animates with the box when the height constraint - // changes inside an animation block, where a bare layer's would trail it. - private let maskContainer = UIView() - private let maskBody = UIView() - private let maskOverhang = UIView() - - private func applyClipping() { - if clipsBar, overhang != .zero { - if maskContainer.superview == nil { - maskContainer.backgroundColor = .clear - for part in [maskBody, maskOverhang] { - part.backgroundColor = .black - maskContainer.addSubview(part) - } - } - clipsToBounds = false - mask = maskContainer - setNeedsLayout() - } else { - mask = nil - clipsToBounds = clipsBar - } - } - - override func layoutSubviews() { - super.layoutSubviews() - guard mask === maskContainer else { return } - maskContainer.frame = bounds - maskBody.frame = bounds - maskOverhang.frame = CGRect(x: bounds.width - overhang.width, y: -overhang.height, width: overhang.width, height: overhang.height) - } - - private var overhangRect: CGRect { - CGRect(x: bounds.width - overhang.width, y: -overhang.height, width: overhang.width, height: overhang.height) - } - override func point(inside point: CGPoint, with event: UIEvent?) -> Bool { if let overflowTarget { var region = overflowTarget.frame @@ -1216,7 +1207,6 @@ private final class BarClipView: UIView { } if region.contains(point) { return true } } - if clipsBar, overhang != .zero, overhangRect.contains(point) { return true } return super.point(inside: point, with: event) } @@ -1228,6 +1218,22 @@ private final class BarClipView: UIView { } } +/// The jump button's box, which takes touches only in its trailing ``JumpToBottomButtonView/reach`` +/// while the button shows, and lets every other one through to the transcript. +/// +/// Decided by where the touch lands, not what claims it: SwiftUI answers a tap on its content with the +/// hosting view itself, the same view that answers a tap on its empty area. +private final class PassthroughView: UIView { + + var takesTouches: () -> Bool = { false } + + override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { + guard takesTouches(), point.x >= bounds.maxX - JumpToBottomButtonView.reach.width else { return nil } + let hit = super.hitTest(point, with: event) + return hit === self ? nil : hit + } +} + /// A clear layer that swallows touches while its predicate holds and passes them through otherwise. private final class TranscriptShield: UIView { diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/JumpToBottomModel.swift b/FlipcashUI/Sources/FlipcashUI/Chat/JumpToBottomModel.swift index e068b71d3..54f0f581a 100644 --- a/FlipcashUI/Sources/FlipcashUI/Chat/JumpToBottomModel.swift +++ b/FlipcashUI/Sources/FlipcashUI/Chat/JumpToBottomModel.swift @@ -35,8 +35,7 @@ public struct JumpToBottomButtonView: View { public static let diameter: CGFloat = 44 /// The area above the bar's top edge, from its trailing side, that the button and its badge - /// occupy, with room to spare for the gap the bar leaves under it. The bar's clip leaves it - /// uncut and lets touches in it through to the button. + /// occupy, with the gap the bar leaves under the button. Touches in it go to the button. public static let reach = CGSize(width: 88, height: 20 + diameter + 12) private static let badgeHeight: CGFloat = 22