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