Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 7 additions & 3 deletions Flipcash/Core/Screens/Conversation/ChatScreenRepresentable.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -334,7 +339,6 @@ struct ChatScreenRepresentable: UIViewControllerRepresentable {
onSendCash: onSendCash,
model: barModel,
composer: composer,
jump: coordinator.jump,
gate: gate,
gateMintName: gateMintName,
gateShortfall: gateShortfall,
Expand Down Expand Up @@ -380,7 +384,7 @@ struct ChatScreenRepresentable: UIViewControllerRepresentable {

@MainActor final class Coordinator {
var barHost: UIHostingController<AnyView>?
/// 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
Expand Down
25 changes: 10 additions & 15 deletions Flipcash/Core/Screens/Conversation/ConversationBottomBar.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
}
}

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
}
Expand Down
41 changes: 29 additions & 12 deletions FlipcashTests/Chat/ChatScreenBarClipTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<BarAccessories.Kind>, 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")
Expand Down
116 changes: 61 additions & 55 deletions FlipcashUI/Sources/FlipcashUI/Chat/ChatScreenViewController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
}
}
Expand Down Expand Up @@ -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`.
Expand Down Expand Up @@ -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)
}

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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)
}

Expand All @@ -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 {

Expand Down
3 changes: 1 addition & 2 deletions FlipcashUI/Sources/FlipcashUI/Chat/JumpToBottomModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Loading