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
35 changes: 17 additions & 18 deletions Flipcash/Core/Screens/Conversation/ConversationScreen.swift
Original file line number Diff line number Diff line change
Expand Up @@ -230,9 +230,10 @@ 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.
// The edit blur slides under the navigation bar, so the bar stays sharp through an edit.
// What it holds changes: the counterpart's name and avatar go, since the edit is about one
// message rather than the person, and the back button backs out of the edit rather than the
// chat — leaving the chevron alone in the bar.
.navigationBarBackButtonHidden(composer.isEditing)
.toolbar {
if composer.isEditing {
Expand All @@ -245,21 +246,19 @@ struct ConversationScreen: View {
}
.accessibilityLabel("Stop editing")
}
}
ToolbarItem(placement: .principal) {
ConversationTitleItem(
title: title,
contact: contact,
conversationID: conversationID,
imageData: contact?.imageData ?? sessionContainer.tipAvatars.data(for: tipCounterpart?.userID),
blurhash: tipCounterpart?.profilePicture?.thumbnailBlurhash,
width: max(navBarWidth - Self.titleSideInset * 2, 0),
onTap: titleTapAction,
opensProfile: profileTapAction != nil
)
.opacity(composer.isEditing ? 0 : 1)
.allowsHitTesting(!composer.isEditing)
.animation(.easeInOut(duration: 0.2), value: composer.isEditing)
} else {
ToolbarItem(placement: .principal) {
ConversationTitleItem(
title: title,
contact: contact,
conversationID: conversationID,
imageData: contact?.imageData ?? sessionContainer.tipAvatars.data(for: tipCounterpart?.userID),
blurhash: tipCounterpart?.profilePicture?.thumbnailBlurhash,
width: max(navBarWidth - Self.titleSideInset * 2, 0),
onTap: titleTapAction,
opensProfile: profileTapAction != nil
)
}
}
}
// Fetch the tip counterpart's avatar for the title and profile card.
Expand Down
32 changes: 22 additions & 10 deletions FlipcashUI/Sources/FlipcashUI/Chat/ChatScreenViewController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,13 @@ public final class ChatScreenViewController: UIViewController {
private let backdrop = MessageBackdrop()
/// The row floated above a held blur, while an edit is open on it.
private var editedStableID: String?
/// Deferred attempts left at floating the edited message's copy. The menu's dismissal
/// completion lands while UIKit is still putting the row's own bubble back, so the first
/// attempt usually has nothing to copy; retrying over the next few runloop turns catches it
/// without waiting on a layout pass or a scroll that may never come.
private var spotlightAttemptsRemaining = 0
/// How many of those attempts a single edit gets.
private static let spotlightAttempts = 8
/// Whether a measured bar height has landed yet — the first one is applied without animation.
private var didMeasureBar = false

Expand Down Expand Up @@ -182,13 +189,17 @@ public final class ChatScreenViewController: UIViewController {
editedStableID = stableID
backdrop.present(over: contextMenuBackdropHost, animator: nil)
backdrop.hold(clearing: bar, under: hostNavigationController?.navigationBar)
transcript.afterContextMenu { [weak self] in self?.refreshEditSpotlight() }
transcript.afterContextMenu { [weak self] in
self?.spotlightAttemptsRemaining = Self.spotlightAttempts
self?.refreshEditSpotlight()
}
}

/// Takes the blur down once the edit is over, however it ended.
public func endEditSpotlight() {
guard editedStableID != nil else { return }
editedStableID = nil
spotlightAttemptsRemaining = 0
backdrop.release()
}

Expand All @@ -197,18 +208,19 @@ 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() {
guard let editedStableID else { return }
// 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: contextMenuBackdropHost
) else { return }
if backdrop.hasSpotlight {
backdrop.moveSpotlight(to: frame)
} else if let bubble = transcript.bubbleSnapshot(forStableID: editedStableID) {
backdrop.setSpotlight(bubble, at: frame)
if let frame = transcript.bubbleFrame(forStableID: editedStableID, in: contextMenuBackdropHost) {
if backdrop.hasSpotlight {
backdrop.moveSpotlight(to: frame)
} else if let bubble = transcript.bubbleSnapshot(forStableID: editedStableID) {
backdrop.setSpotlight(bubble, at: frame)
}
}
guard !backdrop.hasSpotlight, spotlightAttemptsRemaining > 0 else { return }
spotlightAttemptsRemaining -= 1
Task { @MainActor [weak self] in self?.refreshEditSpotlight() }
}

/// The navigation stack this screen is inside, if any — the SwiftUI hosting controllers this
Expand Down
8 changes: 7 additions & 1 deletion FlipcashUI/Sources/FlipcashUI/Chat/ChatViewController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -657,7 +657,13 @@ extension ChatViewController {
/// blur, because a `UIVisualEffectView` does not reliably honour a layer mask.
func bubbleSnapshot(forStableID stableID: String) -> UIView? {
guard let cell = bubbleCell(forStableID: stableID) else { return nil }
return cell.liftPreviewView.snapshotView(afterScreenUpdates: true)
let bubble = cell.liftPreviewView
// UIKit hides the row's own bubble for as long as its lifted preview is on screen and
// unhides it as the dismissal lands. Snapshotting in that window returns a view that is
// blank rather than nil, which would float an empty copy and never be retried — so report
// "not yet" and let the caller ask again.
guard !bubble.isHidden, bubble.alpha > 0, !bubble.bounds.isEmpty else { return nil }
return bubble.snapshotView(afterScreenUpdates: true)
}

/// Where that row's bubble currently sits, in `space`'s coordinates, or `nil` when it is not on
Expand Down
44 changes: 40 additions & 4 deletions FlipcashUI/Sources/FlipcashUI/Chat/MessageBackdrop.swift
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,11 @@ final class MessageBackdrop {
/// Matches the fade UIKit uses for its own dimming when no animator is supplied.
private static let fallbackDuration: TimeInterval = 0.2

/// Stands in for the dimming UIKit lays over the screen while a context menu is up, which goes
/// with the menu. Without it a held blur reads about twice as light as the one the menu had —
/// measured on the same patch of empty transcript, rgb 23 under the menu against 44 after it.
private static let heldDimAlpha: CGFloat = 0.48

/// Called when the held blur is tapped — the way out of an edit, as tapping outside the message
/// is in WhatsApp. Never fires while a context menu owns the screen: the menu's own container
/// sits above the blur and takes those taps.
Expand All @@ -39,6 +44,12 @@ final class MessageBackdrop {
private let effect = UIBlurEffect(style: .systemUltraThinMaterialDark)
private var effectView: UIVisualEffectView?
private var spotlight: UIView?
/// Replaces the menu's dimming once the menu is gone. Lives inside the blur, so it is clipped
/// and framed with it and sits under the floated copy.
private var dim: UIView?
/// Holds the floated copy and clips it to the blur, so a copy of a row that has scrolled past
/// either edge can't draw over the composer or the navigation bar.
private var spotlightClip: UIView?
/// The composer bar a held blur stops short of, re-measured on every layout pass.
private weak var clearance: UIView?

Expand Down Expand Up @@ -80,6 +91,24 @@ final class MessageBackdrop {
}
// The frame is driven by the bar from here on, so the host can no longer resize it.
blur.autoresizingMask = []

// Fade the stand-in dim in now, while the menu is still up: its own dimming fades out with
// the dismissal, so the two cross and the screen never brightens between the states.
let dim = UIView(frame: blur.bounds)
dim.backgroundColor = .black
dim.alpha = 0
dim.autoresizingMask = [.flexibleWidth, .flexibleHeight]
dim.isUserInteractionEnabled = false
blur.contentView.addSubview(dim)
self.dim = dim
UIView.animate(withDuration: Self.fallbackDuration) { dim.alpha = Self.heldDimAlpha }

let clip = UIView()
clip.clipsToBounds = true
clip.isUserInteractionEnabled = false
host.insertSubview(clip, aboveSubview: blur)
spotlightClip = clip

layoutHeld()

blur.isUserInteractionEnabled = true
Expand All @@ -93,20 +122,22 @@ final class MessageBackdrop {
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))
spotlightClip?.frame = blur.frame
}

/// Floats `bubble` — a detached copy of the edited message — above a held blur, at `frame` in
/// the blur's own coordinates.
///
/// A copy rather than a hole cut in the blur: a `UIVisualEffectView` renders its backdrop
/// through a private layer that ignores `layer.mask`, and the real bubble can't be raised out of
/// the collection view that owns it.
/// the collection view that owns it. It goes in the clip rather than straight into the host, so
/// it stops where the blur does instead of covering the composer.
func setSpotlight(_ bubble: UIView, at frame: CGRect) {
guard let blur = effectView, isHeld, let host = blur.superview else { return }
guard isHeld, let clip = spotlightClip else { return }
spotlight?.removeFromSuperview()
bubble.frame = frame
bubble.isUserInteractionEnabled = false
host.insertSubview(bubble, aboveSubview: blur)
clip.addSubview(bubble)
spotlight = bubble
}

Expand Down Expand Up @@ -145,12 +176,17 @@ final class MessageBackdrop {

let bubble = spotlight
spotlight = nil
let clip = spotlightClip
spotlightClip = nil
let dim = self.dim
self.dim = nil
UIView.animate(withDuration: Self.fallbackDuration) {
blur.effect = nil
bubble?.alpha = 0
dim?.alpha = 0
} completion: { _ in
blur.removeFromSuperview()
bubble?.removeFromSuperview()
clip?.removeFromSuperview()
}
}

Expand Down
Loading