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
18 changes: 18 additions & 0 deletions Flipcash/Core/Screens/Conversation/ConversationScreen.swift
Original file line number Diff line number Diff line change
Expand Up @@ -230,7 +230,22 @@ struct ConversationScreen: View {
.background(Color.backgroundMain)
.navigationTitle("")
.toolbarTitleDisplayMode(.inline)
// An edit blurs the whole screen behind the message being edited, navigation bar included,
// so the back button is the only thing up there worth keeping legible — and it backs out of
// the edit rather than out of the chat.
.navigationBarBackButtonHidden(composer.isEditing)
.toolbar {
if composer.isEditing {
ToolbarItem(placement: .topBarLeading) {
Button {
composer.endEditing()
} label: {
Image(systemName: "chevron.backward")
.foregroundStyle(Color.textMain)
}
.accessibilityLabel("Stop editing")
}
}
ToolbarItem(placement: .principal) {
ConversationTitleItem(
title: title,
Expand All @@ -242,6 +257,9 @@ struct ConversationScreen: View {
onTap: titleTapAction,
opensProfile: profileTapAction != nil
)
.opacity(composer.isEditing ? 0 : 1)
.allowsHitTesting(!composer.isEditing)
.animation(.easeInOut(duration: 0.2), value: composer.isEditing)
}
}
// Fetch the tip counterpart's avatar for the title and profile card.
Expand Down
27 changes: 19 additions & 8 deletions FlipcashUI/Sources/FlipcashUI/Chat/ChatScreenViewController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -181,7 +181,7 @@ public final class ChatScreenViewController: UIViewController {
public func beginEditSpotlight(for stableID: String) {
editedStableID = stableID
backdrop.present(over: contextMenuBackdropHost, animator: nil)
backdrop.hold(under: bar)
backdrop.hold(clearing: bar, under: hostNavigationController?.navigationBar)
transcript.afterContextMenu { [weak self] in self?.refreshEditSpotlight() }
}

Expand All @@ -197,25 +197,35 @@ public final class ChatScreenViewController: UIViewController {
/// doesn't follow the cell on its own. A row scrolled out of the transcript leaves the copy at
/// its last frame rather than dropping it, so the message stays on screen for the whole edit.
private func refreshEditSpotlight() {
// Measured in the backdrop's own host, which is the navigation stack rather than this
// screen whenever there is one to be in.
guard let editedStableID,
let frame = transcript.bubbleFrame(forStableID: editedStableID, in: view) else { return }
let frame = transcript.bubbleFrame(
forStableID: editedStableID,
in: contextMenuBackdropHost
) else { return }
if backdrop.hasSpotlight {
backdrop.moveSpotlight(to: frame)
} else if let bubble = transcript.bubbleSnapshot(forStableID: editedStableID) {
backdrop.setSpotlight(bubble, at: frame)
}
}

/// The view the context-menu blur covers: the navigation stack when this screen is inside one,
/// so the navigation bar goes soft with the transcript and the composer, and this screen's own
/// view otherwise.
private var contextMenuBackdropHost: UIView {
/// The navigation stack this screen is inside, if any — the SwiftUI hosting controllers this
/// screen is wrapped in sit between the two, so it is reached by walking the parent chain.
private var hostNavigationController: UINavigationController? {
var ancestor = parent
while let current = ancestor {
if let navigation = current as? UINavigationController { return navigation.view }
if let navigation = current as? UINavigationController { return navigation }
ancestor = current.parent
}
return view
return nil
}

/// The view the blur covers: the navigation stack when this screen is inside one, so the
/// navigation bar goes soft with the transcript, and this screen's own view otherwise.
private var contextMenuBackdropHost: UIView {
hostNavigationController?.view ?? view
}

/// Adds a hosted bar pinned to the view's width and bottom; returns the height constraint
Expand Down Expand Up @@ -343,6 +353,7 @@ public final class ChatScreenViewController: UIViewController {
// has to stay opaque for; it holds until just above the title and clears over the tail.
topFadeHeightConstraint.constant = view.safeAreaInsets.top + Self.topFadeTail
topFade.opaqueLength = max(view.safeAreaInsets.top - 12, 0)
backdrop.layoutHeld()
refreshEditSpotlight()
// Reserve only the bar's own height. On-device the system already grows the collection
// view's adjusted content inset by the keyboard when it's up, so adding the keyboard here
Expand Down
39 changes: 30 additions & 9 deletions FlipcashUI/Sources/FlipcashUI/Chat/MessageBackdrop.swift
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,10 @@ import UIKit
///
/// Choosing Edit holds the same blur past the menu rather than fading it and raising a second one,
/// which is what keeps the transcript from flashing back to legible between the two states. Held,
/// the blur moves below the composer bar so the field stays sharp and usable, carries a detached
/// copy of the edited bubble above itself, and takes the taps that land outside it.
/// it stays over the same host, so an edit is as soft as the menu was rather than sparing the
/// navigation bar; it slides under that bar, so the back button stays legible above it; it stops at
/// the top of the composer, the one piece of chrome an edit needs sharp; and it carries a detached
/// copy of the edited bubble above itself and takes the taps that land outside it.
@MainActor
final class MessageBackdrop {

Expand All @@ -37,6 +39,8 @@ final class MessageBackdrop {
private let effect = UIBlurEffect(style: .systemUltraThinMaterialDark)
private var effectView: UIVisualEffectView?
private var spotlight: UIView?
/// The composer bar a held blur stops short of, re-measured on every layout pass.
private weak var clearance: UIView?

/// Fades the blur in over `host`, riding `animator` so it lands with the menu. Presenting twice
/// is a no-op: the display callback fires once for the lift and again for the menu.
Expand All @@ -60,21 +64,37 @@ final class MessageBackdrop {
}
}

/// Keeps the blur up after the menu that raised it goes, moving it under `bar` so the composer
/// stays sharp, and starts taking taps. The message itself is floated separately, by
/// `setSpotlight`, once the menu has finished putting its lifted preview back.
func hold(under bar: UIView) {
guard let blur = effectView, let host = bar.superview else { return }
/// Keeps the blur up after the menu that raised it goes, and starts taking taps. It stays over
/// the host the menu blurred, so nothing sharpens on the way into an edit, and only its z-order
/// and its bottom edge change: under `navigationBar`, so the back button stays legible and
/// tappable, and stopping at the top of `bar`, so the composer does too. The message itself is
/// floated separately, by `setSpotlight`, once the menu has finished putting its lifted preview
/// back.
func hold(clearing bar: UIView, under navigationBar: UIView?) {
guard let blur = effectView, let host = blur.superview else { return }
isHeld = true
clearance = bar

blur.frame = host.bounds
host.insertSubview(blur, belowSubview: bar)
if let navigationBar, navigationBar.superview === host {
host.insertSubview(blur, belowSubview: navigationBar)
}
// The frame is driven by the bar from here on, so the host can no longer resize it.
blur.autoresizingMask = []
layoutHeld()

blur.isUserInteractionEnabled = true
let tap = UITapGestureRecognizer(target: self, action: #selector(handleTap))
blur.addGestureRecognizer(tap)
}

/// Re-measures a held blur against the composer bar, which rises and falls with the keyboard.
/// A no-op when nothing is held, so a layout pass outside an edit is harmless.
func layoutHeld() {
guard isHeld, let blur = effectView, let host = blur.superview, let bar = clearance else { return }
let barTop = bar.convert(bar.bounds, to: host).minY
blur.frame = CGRect(x: 0, y: 0, width: host.bounds.width, height: max(barTop, 0))
}

/// Floats `bubble` — a detached copy of the edited message — above a held blur, at `frame` in
/// the blur's own coordinates.
///
Expand Down Expand Up @@ -121,6 +141,7 @@ final class MessageBackdrop {
guard isHeld, let blur = effectView else { return }
isHeld = false
effectView = nil
clearance = nil

let bubble = spotlight
spotlight = nil
Expand Down
Loading