Минимизированный call-бар: UIKit additionalSafeAreaInsets для сдвига навбара, Telegram-style градиент и UI-рефакторинг
This commit is contained in:
@@ -1,28 +1,15 @@
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
|
||||
/// Action model for context menu buttons.
|
||||
struct BubbleContextAction {
|
||||
let title: String
|
||||
let image: UIImage?
|
||||
let role: UIMenuElement.Attributes
|
||||
let handler: () -> Void
|
||||
}
|
||||
|
||||
/// Transparent overlay that attaches UIContextMenuInteraction to a message bubble.
|
||||
/// Transparent overlay that triggers a Telegram-style context menu on long press.
|
||||
///
|
||||
/// Uses a **window snapshot** approach instead of UIHostingController preview:
|
||||
/// 1. On long-press, captures a pixel-perfect screenshot of the bubble from the window
|
||||
/// 2. Uses this snapshot as `UITargetedPreview` with `previewProvider: nil`
|
||||
/// 3. UIKit lifts the snapshot in-place — no horizontal shift, no re-rendering issues
|
||||
///
|
||||
/// Also supports an optional `onTap` callback that fires on single tap.
|
||||
/// This is needed because the overlay UIView intercepts all touch events,
|
||||
/// preventing SwiftUI `onTapGesture` on content below from firing.
|
||||
/// Also supports single-tap routing (image viewer, file download, reply quote tap)
|
||||
/// because the overlay UIView intercepts all touch events, preventing SwiftUI
|
||||
/// `onTapGesture` on content below from firing.
|
||||
struct BubbleContextMenuOverlay: UIViewRepresentable {
|
||||
let actions: [BubbleContextAction]
|
||||
let items: [TelegramContextMenuItem]
|
||||
let previewShape: MessageBubbleShape
|
||||
let readStatusText: String?
|
||||
let isOutgoing: Bool
|
||||
|
||||
/// Called when user single-taps the bubble. Receives tap location in the overlay's
|
||||
/// coordinate space (for determining which sub-element was tapped, e.g., which photo in a collage).
|
||||
@@ -38,48 +25,54 @@ struct BubbleContextMenuOverlay: UIViewRepresentable {
|
||||
func makeUIView(context: Context) -> UIView {
|
||||
let view = UIView()
|
||||
view.backgroundColor = .clear
|
||||
let interaction = UIContextMenuInteraction(delegate: context.coordinator)
|
||||
view.addInteraction(interaction)
|
||||
|
||||
// Single tap recognizer — coexists with context menu's long press.
|
||||
// Single tap recognizer — coexists with long press.
|
||||
// ALL taps go through this (overlay UIView blocks SwiftUI gestures below).
|
||||
// onTap handler in ChatDetailView routes to image viewer, file share,
|
||||
// or posts .triggerAttachmentDownload notification for downloads.
|
||||
let tap = UITapGestureRecognizer(target: context.coordinator, action: #selector(Coordinator.handleTap(_:)))
|
||||
view.addGestureRecognizer(tap)
|
||||
|
||||
// Long press → Telegram context menu
|
||||
let longPress = UILongPressGestureRecognizer(
|
||||
target: context.coordinator,
|
||||
action: #selector(Coordinator.handleLongPress(_:))
|
||||
)
|
||||
longPress.minimumPressDuration = 0.35
|
||||
view.addGestureRecognizer(longPress)
|
||||
|
||||
return view
|
||||
}
|
||||
|
||||
func updateUIView(_ uiView: UIView, context: Context) {
|
||||
// PERF: only update callbacks (lightweight pointer swap).
|
||||
// Skip actions/previewShape/readStatusText — these involve array allocation
|
||||
// and struct copying on EVERY layout pass (40× cells × 8 keyboard ticks = 320/s).
|
||||
// Context menu will use stale actions until cell is recycled — acceptable trade-off.
|
||||
context.coordinator.items = items
|
||||
context.coordinator.previewShape = previewShape
|
||||
context.coordinator.isOutgoing = isOutgoing
|
||||
context.coordinator.onTap = onTap
|
||||
context.coordinator.replyQuoteHeight = replyQuoteHeight
|
||||
context.coordinator.onReplyQuoteTap = onReplyQuoteTap
|
||||
}
|
||||
|
||||
func makeCoordinator() -> Coordinator { Coordinator(overlay: self) }
|
||||
|
||||
final class Coordinator: NSObject, UIContextMenuInteractionDelegate {
|
||||
var actions: [BubbleContextAction]
|
||||
final class Coordinator: NSObject {
|
||||
var items: [TelegramContextMenuItem]
|
||||
var previewShape: MessageBubbleShape
|
||||
var readStatusText: String?
|
||||
var isOutgoing: Bool
|
||||
var onTap: ((CGPoint) -> Void)?
|
||||
var replyQuoteHeight: CGFloat = 0
|
||||
var onReplyQuoteTap: (() -> Void)?
|
||||
private var snapshotView: UIImageView?
|
||||
private let haptic = UIImpactFeedbackGenerator(style: .medium)
|
||||
|
||||
init(overlay: BubbleContextMenuOverlay) {
|
||||
self.actions = overlay.actions
|
||||
self.items = overlay.items
|
||||
self.previewShape = overlay.previewShape
|
||||
self.readStatusText = overlay.readStatusText
|
||||
self.isOutgoing = overlay.isOutgoing
|
||||
self.onTap = overlay.onTap
|
||||
self.replyQuoteHeight = overlay.replyQuoteHeight
|
||||
self.onReplyQuoteTap = overlay.onReplyQuoteTap
|
||||
}
|
||||
|
||||
// MARK: - Single Tap
|
||||
|
||||
@objc func handleTap(_ recognizer: UITapGestureRecognizer) {
|
||||
// Route taps in the reply quote region to the reply handler.
|
||||
if replyQuoteHeight > 0, let view = recognizer.view {
|
||||
@@ -93,100 +86,32 @@ struct BubbleContextMenuOverlay: UIViewRepresentable {
|
||||
onTap?(location)
|
||||
}
|
||||
|
||||
func contextMenuInteraction(
|
||||
_ interaction: UIContextMenuInteraction,
|
||||
configurationForMenuAtLocation location: CGPoint
|
||||
) -> UIContextMenuConfiguration? {
|
||||
captureSnapshot(for: interaction)
|
||||
// MARK: - Long Press → Context Menu
|
||||
|
||||
return UIContextMenuConfiguration(
|
||||
identifier: nil,
|
||||
previewProvider: nil,
|
||||
actionProvider: { [weak self] _ in
|
||||
self?.buildMenu()
|
||||
}
|
||||
@objc func handleLongPress(_ recognizer: UILongPressGestureRecognizer) {
|
||||
guard recognizer.state == .began else { return }
|
||||
haptic.impactOccurred()
|
||||
presentMenu(from: recognizer.view)
|
||||
}
|
||||
|
||||
private func presentMenu(from view: UIView?) {
|
||||
guard let view else { return }
|
||||
guard !items.isEmpty else { return }
|
||||
|
||||
// Capture snapshot from window
|
||||
guard let (snapshot, frame) = TelegramContextMenuController.captureSnapshot(of: view) else { return }
|
||||
|
||||
// Build bubble mask path
|
||||
let shapePath = previewShape.path(in: CGRect(origin: .zero, size: frame.size))
|
||||
let bubblePath = UIBezierPath(cgPath: shapePath.cgPath)
|
||||
|
||||
TelegramContextMenuController.present(
|
||||
snapshot: snapshot,
|
||||
sourceFrame: frame,
|
||||
bubblePath: bubblePath,
|
||||
items: items,
|
||||
isOutgoing: isOutgoing
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Snapshot
|
||||
|
||||
private func captureSnapshot(for interaction: UIContextMenuInteraction) {
|
||||
guard let view = interaction.view, let window = view.window else { return }
|
||||
let frameInWindow = view.convert(view.bounds, to: window)
|
||||
let renderer = UIGraphicsImageRenderer(size: view.bounds.size)
|
||||
let image = renderer.image { ctx in
|
||||
ctx.cgContext.translateBy(x: -frameInWindow.origin.x, y: -frameInWindow.origin.y)
|
||||
window.drawHierarchy(in: window.bounds, afterScreenUpdates: false)
|
||||
}
|
||||
let sv = UIImageView(image: image)
|
||||
sv.frame = view.bounds
|
||||
view.addSubview(sv)
|
||||
self.snapshotView = sv
|
||||
}
|
||||
|
||||
// MARK: - Menu
|
||||
|
||||
private func buildMenu() -> UIMenu {
|
||||
var sections: [UIMenuElement] = []
|
||||
|
||||
if let readStatus = readStatusText {
|
||||
let readAction = UIAction(
|
||||
title: readStatus,
|
||||
image: UIImage(systemName: "checkmark"),
|
||||
attributes: .disabled
|
||||
) { _ in }
|
||||
sections.append(UIMenu(options: .displayInline, children: [readAction]))
|
||||
}
|
||||
|
||||
let menuActions = actions.map { action in
|
||||
UIAction(
|
||||
title: action.title,
|
||||
image: action.image,
|
||||
attributes: action.role
|
||||
) { _ in
|
||||
action.handler()
|
||||
}
|
||||
}
|
||||
sections.append(UIMenu(options: .displayInline, children: menuActions))
|
||||
|
||||
return UIMenu(children: sections)
|
||||
}
|
||||
|
||||
// MARK: - Targeted Preview (lift & dismiss)
|
||||
|
||||
func contextMenuInteraction(
|
||||
_ interaction: UIContextMenuInteraction,
|
||||
previewForHighlightingMenuWithConfiguration configuration: UIContextMenuConfiguration
|
||||
) -> UITargetedPreview? {
|
||||
guard let sv = snapshotView else { return nil }
|
||||
return makeTargetedPreview(for: sv)
|
||||
}
|
||||
|
||||
func contextMenuInteraction(
|
||||
_ interaction: UIContextMenuInteraction,
|
||||
previewForDismissingMenuWithConfiguration configuration: UIContextMenuConfiguration
|
||||
) -> UITargetedPreview? {
|
||||
guard let sv = snapshotView else { return nil }
|
||||
return makeTargetedPreview(for: sv)
|
||||
}
|
||||
|
||||
func contextMenuInteraction(
|
||||
_ interaction: UIContextMenuInteraction,
|
||||
willEndFor configuration: UIContextMenuConfiguration,
|
||||
animator: (any UIContextMenuInteractionAnimating)?
|
||||
) {
|
||||
animator?.addCompletion { [weak self] in
|
||||
self?.snapshotView?.removeFromSuperview()
|
||||
self?.snapshotView = nil
|
||||
}
|
||||
}
|
||||
|
||||
private func makeTargetedPreview(for view: UIView) -> UITargetedPreview {
|
||||
let params = UIPreviewParameters()
|
||||
let shapePath = previewShape.path(in: view.bounds)
|
||||
params.visiblePath = UIBezierPath(cgPath: shapePath.cgPath)
|
||||
params.backgroundColor = .clear
|
||||
return UITargetedPreview(view: view, parameters: params)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1167,58 +1167,51 @@ private extension ChatDetailView {
|
||||
// MARK: - Reply Bar
|
||||
|
||||
/// Extract reply preview text for ComposerView (same logic as SwiftUI replyBar).
|
||||
///
|
||||
/// Priority: attachment type label first, then clean caption text.
|
||||
/// Previous version checked `message.text` BEFORE attachment type which caused empty
|
||||
/// previews when text contained invisible/encrypted characters for photo/file messages.
|
||||
func replyPreviewText(for message: ChatMessage) -> String {
|
||||
if message.attachments.contains(where: { $0.type == .image }) {
|
||||
let caption = message.text.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return caption.isEmpty ? "Photo" : caption
|
||||
}
|
||||
if let file = message.attachments.first(where: { $0.type == .file }) {
|
||||
let caption = message.text.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if !caption.isEmpty { return caption }
|
||||
let parsed = AttachmentPreviewCodec.parseFilePreview(file.preview)
|
||||
if !parsed.fileName.isEmpty { return parsed.fileName }
|
||||
return file.id.isEmpty ? "File" : file.id
|
||||
}
|
||||
if message.attachments.contains(where: { $0.type == .avatar }) { return "Avatar" }
|
||||
if message.attachments.contains(where: { $0.type == .messages }) { return "Forwarded message" }
|
||||
// Android/Desktop parity: show "Call" for call attachments
|
||||
if message.attachments.contains(where: { $0.type == .call }) { return "Call" }
|
||||
let trimmed = message.text.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if !trimmed.isEmpty { return message.text }
|
||||
if !message.attachments.isEmpty { return "Attachment" }
|
||||
// 1. Determine attachment type label
|
||||
let attachmentLabel: String? = {
|
||||
for att in message.attachments {
|
||||
switch att.type {
|
||||
case .image: return "Photo"
|
||||
case .file:
|
||||
let parsed = AttachmentPreviewCodec.parseFilePreview(att.preview)
|
||||
if !parsed.fileName.isEmpty { return parsed.fileName }
|
||||
return att.id.isEmpty ? "File" : att.id
|
||||
case .avatar: return "Avatar"
|
||||
case .messages: return "Forwarded message"
|
||||
case .call: return "Call"
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}()
|
||||
|
||||
// 2. Clean caption — strip invisible chars (zero-width spaces, encrypted residue)
|
||||
let visibleText: String = {
|
||||
let stripped = message.text
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
.filter { !$0.isASCII || $0.asciiValue! >= 0x20 } // drop control chars
|
||||
// Extra guard: if text looks like encrypted payload, ignore it
|
||||
if MessageCellLayout.isGarbageOrEncrypted(stripped) { return "" }
|
||||
return stripped
|
||||
}()
|
||||
|
||||
// 3. For image/file with non-empty caption: show caption
|
||||
if attachmentLabel != nil, !visibleText.isEmpty { return visibleText }
|
||||
// 4. For image/file with no caption: show type label
|
||||
if let label = attachmentLabel { return label }
|
||||
// 5. No attachment: show text
|
||||
if !visibleText.isEmpty { return message.text }
|
||||
return ""
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
func replyBar(for message: ChatMessage) -> some View {
|
||||
let senderName = senderDisplayName(for: message.fromPublicKey)
|
||||
let previewText: String = {
|
||||
// Attachment type labels — check BEFORE text so photo/avatar messages
|
||||
// always show their type even if text contains invisible characters.
|
||||
if message.attachments.contains(where: { $0.type == .image }) {
|
||||
let caption = message.text.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return caption.isEmpty ? "Photo" : caption
|
||||
}
|
||||
if let file = message.attachments.first(where: { $0.type == .file }) {
|
||||
let caption = message.text.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if !caption.isEmpty { return caption }
|
||||
let parsed = AttachmentPreviewCodec.parseFilePreview(file.preview)
|
||||
if !parsed.fileName.isEmpty { return parsed.fileName }
|
||||
return file.id.isEmpty ? "File" : file.id
|
||||
}
|
||||
if message.attachments.contains(where: { $0.type == .avatar }) { return "Avatar" }
|
||||
if message.attachments.contains(where: { $0.type == .messages }) { return "Forwarded message" }
|
||||
// Android/Desktop parity: show "Call" for call attachments
|
||||
if message.attachments.contains(where: { $0.type == .call }) { return "Call" }
|
||||
// No known attachment type — fall back to text
|
||||
let trimmed = message.text.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if !trimmed.isEmpty { return message.text }
|
||||
if !message.attachments.isEmpty { return "Attachment" }
|
||||
return ""
|
||||
}()
|
||||
#if DEBUG
|
||||
let _ = print("📋 REPLY: preview='\(previewText.prefix(30))' text='\(message.text.prefix(30))' textHex=\(Array(message.text.utf8).prefix(16).map { String(format: "%02x", $0) }.joined(separator: " ")) atts=\(message.attachments.count) types=\(message.attachments.map { $0.type.rawValue })")
|
||||
#endif
|
||||
let previewText = replyPreviewText(for: message)
|
||||
|
||||
HStack(spacing: 0) {
|
||||
RoundedRectangle(cornerRadius: 1.0)
|
||||
|
||||
@@ -235,12 +235,18 @@ final class ComposerView: UIView, UITextViewDelegate {
|
||||
|
||||
func setReply(senderName: String?, previewText: String?) {
|
||||
let shouldShow = senderName != nil
|
||||
|
||||
// Always update label text when reply is active (fixes race where
|
||||
// syncComposerState runs before SwiftUI body re-evaluates with new state)
|
||||
if shouldShow {
|
||||
replySenderLabel.text = senderName
|
||||
replyPreviewLabel.text = previewText ?? ""
|
||||
}
|
||||
|
||||
guard shouldShow != isReplyVisible else { return }
|
||||
isReplyVisible = shouldShow
|
||||
|
||||
if shouldShow {
|
||||
replySenderLabel.text = senderName
|
||||
replyPreviewLabel.text = previewText ?? ""
|
||||
replyBar.isHidden = false
|
||||
}
|
||||
|
||||
|
||||
@@ -122,9 +122,9 @@ struct MessageCellView: View, Equatable {
|
||||
.background { bubbleBackground(outgoing: outgoing, position: position) }
|
||||
.overlay {
|
||||
BubbleContextMenuOverlay(
|
||||
actions: bubbleActions(for: message),
|
||||
items: contextMenuItems(for: message),
|
||||
previewShape: MessageBubbleShape(position: position, outgoing: outgoing),
|
||||
readStatusText: contextMenuReadStatus(for: message),
|
||||
isOutgoing: outgoing,
|
||||
replyQuoteHeight: replyData != nil ? 46 : 0,
|
||||
onReplyQuoteTap: replyData.map { reply in
|
||||
{ [reply] in actions.onScrollToMessage(reply.message_id) }
|
||||
@@ -262,9 +262,9 @@ struct MessageCellView: View, Equatable {
|
||||
.background { bubbleBackground(outgoing: outgoing, position: position) }
|
||||
.overlay {
|
||||
BubbleContextMenuOverlay(
|
||||
actions: bubbleActions(for: message),
|
||||
items: contextMenuItems(for: message),
|
||||
previewShape: MessageBubbleShape(position: position, outgoing: outgoing),
|
||||
readStatusText: contextMenuReadStatus(for: message),
|
||||
isOutgoing: outgoing,
|
||||
onTap: !imageAttachments.isEmpty ? { _ in
|
||||
if let firstId = imageAttachments.first?.id {
|
||||
actions.onImageTap(firstId)
|
||||
@@ -362,9 +362,9 @@ struct MessageCellView: View, Equatable {
|
||||
.clipShape(MessageBubbleShape(position: position, outgoing: outgoing))
|
||||
.overlay {
|
||||
BubbleContextMenuOverlay(
|
||||
actions: bubbleActions(for: message),
|
||||
items: contextMenuItems(for: message),
|
||||
previewShape: MessageBubbleShape(position: position, outgoing: outgoing),
|
||||
readStatusText: contextMenuReadStatus(for: message),
|
||||
isOutgoing: outgoing,
|
||||
onTap: !attachments.isEmpty ? { tapLocation in
|
||||
if !imageAttachments.isEmpty {
|
||||
let tappedId = imageAttachments.count == 1
|
||||
@@ -536,48 +536,13 @@ struct MessageCellView: View, Equatable {
|
||||
|
||||
// MARK: - Context Menu
|
||||
|
||||
private func contextMenuReadStatus(for message: ChatMessage) -> String? {
|
||||
let outgoing = message.isFromMe(myPublicKey: currentPublicKey)
|
||||
guard outgoing, message.deliveryStatus == .delivered, message.isRead else { return nil }
|
||||
return "Read"
|
||||
}
|
||||
|
||||
private func bubbleActions(for message: ChatMessage) -> [BubbleContextAction] {
|
||||
var result: [BubbleContextAction] = []
|
||||
|
||||
// Desktop parity: system accounts + ATTACHMENTS_NOT_ALLOWED_TO_REPLY = [AVATAR, MESSAGES]
|
||||
let isAvatarOrForwarded = message.attachments.contains(where: { $0.type == .avatar || $0.type == .messages })
|
||||
let canReplyForward = !isSavedMessages && !isSystemAccount && !isAvatarOrForwarded
|
||||
|
||||
if canReplyForward {
|
||||
result.append(BubbleContextAction(
|
||||
title: "Reply",
|
||||
image: UIImage(systemName: "arrowshape.turn.up.left"),
|
||||
role: []
|
||||
) { actions.onReply(message) })
|
||||
}
|
||||
|
||||
result.append(BubbleContextAction(
|
||||
title: "Copy",
|
||||
image: UIImage(systemName: "doc.on.doc"),
|
||||
role: []
|
||||
) { actions.onCopy(message.text) })
|
||||
|
||||
if canReplyForward {
|
||||
result.append(BubbleContextAction(
|
||||
title: "Forward",
|
||||
image: UIImage(systemName: "arrowshape.turn.up.right"),
|
||||
role: []
|
||||
) { actions.onForward(message) })
|
||||
}
|
||||
|
||||
result.append(BubbleContextAction(
|
||||
title: "Delete",
|
||||
image: UIImage(systemName: "trash"),
|
||||
role: .destructive
|
||||
) { actions.onDelete(message) })
|
||||
|
||||
return result
|
||||
private func contextMenuItems(for message: ChatMessage) -> [TelegramContextMenuItem] {
|
||||
TelegramContextMenuBuilder.menuItems(
|
||||
for: message,
|
||||
actions: actions,
|
||||
isSavedMessages: isSavedMessages,
|
||||
isSystemAccount: isSystemAccount
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Reply Quote
|
||||
|
||||
@@ -9,7 +9,7 @@ import UIKit
|
||||
/// 3. No SwiftUI, no UIHostingConfiguration, no self-sizing
|
||||
///
|
||||
/// Subviews are always present but hidden when not needed (no alloc/dealloc overhead).
|
||||
final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDelegate {
|
||||
final class NativeMessageCell: UICollectionViewCell {
|
||||
|
||||
// MARK: - Constants
|
||||
|
||||
@@ -400,9 +400,10 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel
|
||||
deliveryFailedButton.addTarget(self, action: #selector(handleDeliveryFailedTap), for: .touchUpInside)
|
||||
contentView.addSubview(deliveryFailedButton)
|
||||
|
||||
// Interactions
|
||||
let contextMenu = UIContextMenuInteraction(delegate: self)
|
||||
bubbleView.addInteraction(contextMenu)
|
||||
// Long-press → Telegram context menu
|
||||
let longPress = UILongPressGestureRecognizer(target: self, action: #selector(handleLongPress(_:)))
|
||||
longPress.minimumPressDuration = 0.35
|
||||
bubbleView.addGestureRecognizer(longPress)
|
||||
|
||||
let pan = UIPanGestureRecognizer(target: self, action: #selector(handleSwipe(_:)))
|
||||
pan.delegate = self
|
||||
@@ -527,6 +528,7 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel
|
||||
|
||||
// Title (16pt medium — Telegram parity)
|
||||
fileNameLabel.font = UIFont.systemFont(ofSize: 16, weight: .medium)
|
||||
fileNameLabel.textColor = .white
|
||||
if isMissed {
|
||||
fileNameLabel.text = isIncoming ? "Missed Call" : "Cancelled Call"
|
||||
} else {
|
||||
@@ -998,36 +1000,45 @@ final class NativeMessageCell: UICollectionViewCell, UIContextMenuInteractionDel
|
||||
return attrs
|
||||
}
|
||||
|
||||
// MARK: - Context Menu
|
||||
// MARK: - Context Menu (Telegram-style)
|
||||
|
||||
func contextMenuInteraction(
|
||||
_ interaction: UIContextMenuInteraction,
|
||||
configurationForMenuAtLocation location: CGPoint
|
||||
) -> UIContextMenuConfiguration? {
|
||||
guard let message, let actions else { return nil }
|
||||
return UIContextMenuConfiguration(identifier: nil, previewProvider: nil) { _ in
|
||||
var items: [UIAction] = []
|
||||
if !message.text.isEmpty {
|
||||
items.append(UIAction(title: "Copy", image: UIImage(systemName: "doc.on.doc")) { _ in
|
||||
actions.onCopy(message.text)
|
||||
})
|
||||
}
|
||||
// Desktop parity: system accounts + ATTACHMENTS_NOT_ALLOWED_TO_REPLY = [AVATAR, MESSAGES]
|
||||
let isAvatarOrForwarded = message.attachments.contains(where: { $0.type == .avatar || $0.type == .messages })
|
||||
let canReplyForward = !self.isSavedMessages && !self.isSystemAccount && !isAvatarOrForwarded
|
||||
if canReplyForward {
|
||||
items.append(UIAction(title: "Reply", image: UIImage(systemName: "arrowshape.turn.up.left")) { _ in
|
||||
actions.onReply(message)
|
||||
})
|
||||
items.append(UIAction(title: "Forward", image: UIImage(systemName: "arrowshape.turn.up.right")) { _ in
|
||||
actions.onForward(message)
|
||||
})
|
||||
}
|
||||
items.append(UIAction(title: "Delete", image: UIImage(systemName: "trash"), attributes: .destructive) { _ in
|
||||
actions.onDelete(message)
|
||||
})
|
||||
return UIMenu(children: items)
|
||||
}
|
||||
private let contextMenuHaptic = UIImpactFeedbackGenerator(style: .medium)
|
||||
|
||||
@objc private func handleLongPress(_ gesture: UILongPressGestureRecognizer) {
|
||||
guard gesture.state == .began else { return }
|
||||
contextMenuHaptic.impactOccurred()
|
||||
presentContextMenu()
|
||||
}
|
||||
|
||||
private func presentContextMenu() {
|
||||
guard let message, let actions else { return }
|
||||
guard let layout = currentLayout else { return }
|
||||
|
||||
// Capture snapshot from window (pixel-perfect, accounts for inverted scroll)
|
||||
guard let (snapshot, frame) = TelegramContextMenuController.captureSnapshot(of: bubbleView) else { return }
|
||||
|
||||
// Build bubble mask path
|
||||
let bubblePath = BubbleGeometryEngine.makeBezierPath(
|
||||
in: CGRect(origin: .zero, size: frame.size),
|
||||
mergeType: layout.mergeType,
|
||||
outgoing: layout.isOutgoing
|
||||
)
|
||||
|
||||
// Build menu items
|
||||
let items = TelegramContextMenuBuilder.menuItems(
|
||||
for: message,
|
||||
actions: actions,
|
||||
isSavedMessages: isSavedMessages,
|
||||
isSystemAccount: isSystemAccount
|
||||
)
|
||||
|
||||
TelegramContextMenuController.present(
|
||||
snapshot: snapshot,
|
||||
sourceFrame: frame,
|
||||
bubblePath: bubblePath,
|
||||
items: items,
|
||||
isOutgoing: layout.isOutgoing
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Swipe to Reply
|
||||
|
||||
@@ -17,9 +17,9 @@ final class NativeMessageListController: UIViewController {
|
||||
static let messageToComposerGap: CGFloat = 16
|
||||
static let scrollButtonSize: CGFloat = 40
|
||||
static let scrollButtonIconCanvas: CGFloat = 38
|
||||
static let scrollButtonBaseTrailing: CGFloat = 8
|
||||
static let scrollButtonBaseTrailing: CGFloat = 17
|
||||
static let scrollButtonCompactExtraTrailing: CGFloat = 18
|
||||
static let scrollButtonBottomOffset: CGFloat = 20
|
||||
|
||||
}
|
||||
|
||||
// MARK: - Configuration
|
||||
@@ -363,7 +363,7 @@ final class NativeMessageListController: UIViewController {
|
||||
)
|
||||
let bottom = container.bottomAnchor.constraint(
|
||||
equalTo: view.keyboardLayoutGuide.topAnchor,
|
||||
constant: -(lastComposerHeight + UIConstants.scrollButtonBottomOffset)
|
||||
constant: -(lastComposerHeight + 4)
|
||||
)
|
||||
NSLayoutConstraint.activate([
|
||||
container.widthAnchor.constraint(equalToConstant: size),
|
||||
@@ -448,7 +448,7 @@ final class NativeMessageListController: UIViewController {
|
||||
let safeBottom = view.safeAreaInsets.bottom
|
||||
let compactShift = safeBottom <= 32 ? UIConstants.scrollButtonCompactExtraTrailing : 0
|
||||
scrollToBottomTrailingConstraint?.constant = -(UIConstants.scrollButtonBaseTrailing + compactShift)
|
||||
scrollToBottomBottomConstraint?.constant = -(lastComposerHeight + UIConstants.scrollButtonBottomOffset)
|
||||
scrollToBottomBottomConstraint?.constant = -(lastComposerHeight + 4)
|
||||
}
|
||||
|
||||
private func updateScrollToBottomBadge() {
|
||||
@@ -491,10 +491,12 @@ final class NativeMessageListController: UIViewController {
|
||||
gc.setLineCap(.round)
|
||||
gc.setLineJoin(.round)
|
||||
|
||||
let position = CGPoint(x: 9.0 - 0.5, y: 23.0)
|
||||
gc.move(to: CGPoint(x: position.x + 1.0, y: position.y - 1.0))
|
||||
gc.addLine(to: CGPoint(x: position.x + 10.0, y: position.y - 10.0))
|
||||
gc.addLine(to: CGPoint(x: position.x + 19.0, y: position.y - 1.0))
|
||||
// Down chevron (v), centered in canvas — Telegram parity.
|
||||
let cx = size.width / 2
|
||||
let cy = size.height / 2
|
||||
gc.move(to: CGPoint(x: cx - 9.0, y: cy - 4.5)) // top-left
|
||||
gc.addLine(to: CGPoint(x: cx, y: cy + 4.5)) // bottom-center
|
||||
gc.addLine(to: CGPoint(x: cx + 9.0, y: cy - 4.5)) // top-right
|
||||
gc.strokePath()
|
||||
}.withRenderingMode(.alwaysOriginal)
|
||||
}
|
||||
@@ -1048,6 +1050,11 @@ struct NativeMessageListView: UIViewControllerRepresentable {
|
||||
private func syncComposerState(_ controller: NativeMessageListController) {
|
||||
guard let composer = controller.composerView else { return }
|
||||
composer.setText(messageText)
|
||||
#if DEBUG
|
||||
if replySenderName != nil {
|
||||
print("📋 syncComposer: sender=\(replySenderName ?? "nil") preview=\(replyPreviewText ?? "nil")")
|
||||
}
|
||||
#endif
|
||||
composer.setReply(senderName: replySenderName, previewText: replyPreviewText)
|
||||
composer.setFocused(isInputFocused)
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import UIKit
|
||||
/// Pure UIKit message cell for text messages (with optional reply quote).
|
||||
/// Replaces UIHostingConfiguration + SwiftUI for the most common message type.
|
||||
/// Features: Figma-accurate bubble tail, context menu, swipe-to-reply, reply quote.
|
||||
final class NativeTextBubbleCell: UICollectionViewCell, UIContextMenuInteractionDelegate {
|
||||
final class NativeTextBubbleCell: UICollectionViewCell {
|
||||
|
||||
// MARK: - Constants
|
||||
|
||||
@@ -110,9 +110,10 @@ final class NativeTextBubbleCell: UICollectionViewCell, UIContextMenuInteraction
|
||||
replyIconView.alpha = 0
|
||||
contentView.addSubview(replyIconView)
|
||||
|
||||
// Context menu
|
||||
let contextMenu = UIContextMenuInteraction(delegate: self)
|
||||
bubbleView.addInteraction(contextMenu)
|
||||
// Long-press → Telegram context menu
|
||||
let longPress = UILongPressGestureRecognizer(target: self, action: #selector(handleLongPress(_:)))
|
||||
longPress.minimumPressDuration = 0.35
|
||||
bubbleView.addGestureRecognizer(longPress)
|
||||
|
||||
// Swipe-to-reply gesture
|
||||
let pan = UIPanGestureRecognizer(target: self, action: #selector(handleSwipe(_:)))
|
||||
@@ -383,32 +384,45 @@ final class NativeTextBubbleCell: UICollectionViewCell, UIContextMenuInteraction
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Context Menu
|
||||
// MARK: - Context Menu (Telegram-style)
|
||||
|
||||
func contextMenuInteraction(
|
||||
_ interaction: UIContextMenuInteraction,
|
||||
configurationForMenuAtLocation location: CGPoint
|
||||
) -> UIContextMenuConfiguration? {
|
||||
guard let message, let actions else { return nil }
|
||||
private let contextMenuHaptic = UIImpactFeedbackGenerator(style: .medium)
|
||||
|
||||
return UIContextMenuConfiguration(identifier: nil, previewProvider: nil) { _ in
|
||||
var items: [UIAction] = []
|
||||
@objc private func handleLongPress(_ gesture: UILongPressGestureRecognizer) {
|
||||
guard gesture.state == .began else { return }
|
||||
contextMenuHaptic.impactOccurred()
|
||||
presentContextMenu()
|
||||
}
|
||||
|
||||
items.append(UIAction(title: "Copy", image: UIImage(systemName: "doc.on.doc")) { _ in
|
||||
actions.onCopy(message.text)
|
||||
})
|
||||
items.append(UIAction(title: "Reply", image: UIImage(systemName: "arrowshape.turn.up.left")) { _ in
|
||||
actions.onReply(message)
|
||||
})
|
||||
items.append(UIAction(title: "Forward", image: UIImage(systemName: "arrowshape.turn.up.right")) { _ in
|
||||
actions.onForward(message)
|
||||
})
|
||||
items.append(UIAction(title: "Delete", image: UIImage(systemName: "trash"), attributes: .destructive) { _ in
|
||||
actions.onDelete(message)
|
||||
})
|
||||
private func presentContextMenu() {
|
||||
guard let message, let actions else { return }
|
||||
|
||||
return UIMenu(children: items)
|
||||
}
|
||||
// Capture snapshot from window
|
||||
guard let (snapshot, frame) = TelegramContextMenuController.captureSnapshot(of: bubbleView) else { return }
|
||||
|
||||
// Build bubble mask path
|
||||
let mergeType = BubbleGeometryEngine.mergeType(for: position)
|
||||
let bubblePath = BubbleGeometryEngine.makeBezierPath(
|
||||
in: CGRect(origin: .zero, size: frame.size),
|
||||
mergeType: mergeType,
|
||||
outgoing: isOutgoing
|
||||
)
|
||||
|
||||
// Build menu items
|
||||
let items = TelegramContextMenuBuilder.menuItems(
|
||||
for: message,
|
||||
actions: actions,
|
||||
isSavedMessages: false,
|
||||
isSystemAccount: false
|
||||
)
|
||||
|
||||
TelegramContextMenuController.present(
|
||||
snapshot: snapshot,
|
||||
sourceFrame: frame,
|
||||
bubblePath: bubblePath,
|
||||
items: items,
|
||||
isOutgoing: isOutgoing
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Swipe to Reply
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
import UIKit
|
||||
|
||||
/// Telegram-exact context menu card.
|
||||
///
|
||||
/// Source: ContextActionsContainerNode.swift + DefaultDarkPresentationTheme.swift
|
||||
/// - Background: .systemMaterialDark blur + UIColor(0x252525, alpha: 0.78) tint
|
||||
/// - Items: 17pt font, icon LEFT + title RIGHT
|
||||
/// - Corner radius: 14pt continuous
|
||||
/// - Separator: screenPixel, white 15% alpha, full width
|
||||
/// - Destructive: 0xeb5545
|
||||
final class TelegramContextMenuCardView: UIView {
|
||||
|
||||
// MARK: - Constants
|
||||
|
||||
private static let itemHeight: CGFloat = 44
|
||||
private static let cornerRadius: CGFloat = 14
|
||||
private static let hPad: CGFloat = 16
|
||||
private static let iconSize: CGFloat = 24
|
||||
private static let screenPixel = 1.0 / max(UIScreen.main.scale, 1)
|
||||
|
||||
// MARK: - Colors (Telegram dark theme)
|
||||
|
||||
private static let tintBg = UIColor(red: 0x25/255, green: 0x25/255, blue: 0x25/255, alpha: 0.78)
|
||||
private static let textColor = UIColor.white
|
||||
private static let destructiveColor = UIColor(red: 0xEB/255, green: 0x55/255, blue: 0x45/255, alpha: 1)
|
||||
private static let separatorColor = UIColor(white: 1, alpha: 0.15)
|
||||
private static let highlightColor = UIColor(white: 1, alpha: 0.15)
|
||||
|
||||
// MARK: - Public
|
||||
|
||||
let itemCount: Int
|
||||
var onItemSelected: (() -> Void)?
|
||||
|
||||
// MARK: - Views
|
||||
|
||||
private let blurView = UIVisualEffectView(effect: UIBlurEffect(style: .systemMaterialDark))
|
||||
private let tintView = UIView()
|
||||
private let items: [TelegramContextMenuItem]
|
||||
|
||||
// Row subviews stored for layout
|
||||
private var titleLabels: [UILabel] = []
|
||||
private var iconViews: [UIImageView] = []
|
||||
private var highlightViews: [UIView] = []
|
||||
private var separators: [UIView] = []
|
||||
|
||||
// MARK: - Init
|
||||
|
||||
init(items: [TelegramContextMenuItem]) {
|
||||
self.items = items
|
||||
self.itemCount = items.count
|
||||
super.init(frame: .zero)
|
||||
setup()
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) { fatalError() }
|
||||
|
||||
// MARK: - Setup
|
||||
|
||||
private func setup() {
|
||||
clipsToBounds = true
|
||||
layer.cornerRadius = Self.cornerRadius
|
||||
layer.cornerCurve = .continuous
|
||||
|
||||
blurView.clipsToBounds = true
|
||||
addSubview(blurView)
|
||||
|
||||
tintView.backgroundColor = Self.tintBg
|
||||
tintView.isUserInteractionEnabled = false
|
||||
addSubview(tintView)
|
||||
|
||||
for (i, item) in items.enumerated() {
|
||||
let color = item.isDestructive ? Self.destructiveColor : Self.textColor
|
||||
|
||||
// Highlight
|
||||
let hl = UIView()
|
||||
hl.backgroundColor = Self.highlightColor
|
||||
hl.alpha = 0
|
||||
addSubview(hl)
|
||||
highlightViews.append(hl)
|
||||
|
||||
// Title
|
||||
let label = UILabel()
|
||||
label.text = item.title
|
||||
label.font = .systemFont(ofSize: 17, weight: .regular)
|
||||
label.textColor = color
|
||||
addSubview(label)
|
||||
titleLabels.append(label)
|
||||
|
||||
// Icon
|
||||
let iv = UIImageView()
|
||||
iv.image = UIImage(systemName: item.iconName)?
|
||||
.withConfiguration(UIImage.SymbolConfiguration(pointSize: Self.iconSize, weight: .medium))
|
||||
iv.tintColor = color
|
||||
iv.contentMode = .center
|
||||
addSubview(iv)
|
||||
iconViews.append(iv)
|
||||
|
||||
// Separator
|
||||
if i < items.count - 1 {
|
||||
let sep = UIView()
|
||||
sep.backgroundColor = Self.separatorColor
|
||||
addSubview(sep)
|
||||
separators.append(sep)
|
||||
}
|
||||
|
||||
// Row gesture
|
||||
let rowView = UIView()
|
||||
rowView.backgroundColor = .clear
|
||||
rowView.tag = i
|
||||
let press = UILongPressGestureRecognizer(target: self, action: #selector(rowPress(_:)))
|
||||
press.minimumPressDuration = 0
|
||||
press.cancelsTouchesInView = false
|
||||
rowView.addGestureRecognizer(press)
|
||||
addSubview(rowView)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Layout
|
||||
|
||||
override func layoutSubviews() {
|
||||
super.layoutSubviews()
|
||||
let w = bounds.width
|
||||
blurView.frame = bounds
|
||||
tintView.frame = bounds
|
||||
|
||||
for i in 0..<items.count {
|
||||
let y = CGFloat(i) * Self.itemHeight
|
||||
let rowRect = CGRect(x: 0, y: y, width: w, height: Self.itemHeight)
|
||||
|
||||
// Highlight fills row
|
||||
highlightViews[i].frame = rowRect
|
||||
|
||||
// Icon left (Telegram: icon on LEFT side)
|
||||
let iconX = Self.hPad
|
||||
iconViews[i].frame = CGRect(x: iconX, y: y, width: Self.iconSize, height: Self.itemHeight)
|
||||
|
||||
// Title right of icon (Telegram: text follows icon)
|
||||
let titleX = Self.hPad + Self.iconSize + 12
|
||||
let titleW = w - titleX - Self.hPad
|
||||
titleLabels[i].frame = CGRect(x: titleX, y: y, width: titleW, height: Self.itemHeight)
|
||||
|
||||
// Gesture receiver (topmost, transparent)
|
||||
if let rv = subviews.first(where: { $0.tag == i && $0.gestureRecognizers?.isEmpty == false }) {
|
||||
rv.frame = rowRect
|
||||
}
|
||||
}
|
||||
|
||||
// Separators
|
||||
for (i, sep) in separators.enumerated() {
|
||||
let y = CGFloat(i + 1) * Self.itemHeight
|
||||
sep.frame = CGRect(x: 0, y: y, width: w, height: Self.screenPixel)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Interaction
|
||||
|
||||
@objc private func rowPress(_ g: UILongPressGestureRecognizer) {
|
||||
let i = g.view?.tag ?? -1
|
||||
guard i >= 0, i < items.count else { return }
|
||||
|
||||
switch g.state {
|
||||
case .began:
|
||||
UIView.animate(withDuration: 0.08) { self.highlightViews[i].alpha = 1 }
|
||||
case .ended:
|
||||
let loc = g.location(in: g.view)
|
||||
UIView.animate(withDuration: 0.12) { self.highlightViews[i].alpha = 0 }
|
||||
if g.view?.bounds.contains(loc) == true {
|
||||
let handler = items[i].handler
|
||||
onItemSelected?()
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.15) { handler() }
|
||||
}
|
||||
case .cancelled, .failed:
|
||||
UIView.animate(withDuration: 0.12) { self.highlightViews[i].alpha = 0 }
|
||||
default: break
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,370 @@
|
||||
import UIKit
|
||||
|
||||
// MARK: - Menu Item Model
|
||||
|
||||
struct TelegramContextMenuItem {
|
||||
let title: String
|
||||
let iconName: String
|
||||
let isDestructive: Bool
|
||||
let handler: () -> Void
|
||||
}
|
||||
|
||||
// MARK: - Shared Builder
|
||||
|
||||
enum TelegramContextMenuBuilder {
|
||||
|
||||
static func menuItems(
|
||||
for message: ChatMessage,
|
||||
actions: MessageCellActions,
|
||||
isSavedMessages: Bool,
|
||||
isSystemAccount: Bool
|
||||
) -> [TelegramContextMenuItem] {
|
||||
var items: [TelegramContextMenuItem] = []
|
||||
|
||||
let isAvatarOrForwarded = message.attachments.contains(where: {
|
||||
$0.type == .avatar || $0.type == .messages
|
||||
})
|
||||
let canReplyForward = !isSavedMessages && !isSystemAccount && !isAvatarOrForwarded
|
||||
|
||||
if canReplyForward {
|
||||
items.append(TelegramContextMenuItem(
|
||||
title: "Reply",
|
||||
iconName: "arrowshape.turn.up.left",
|
||||
isDestructive: false,
|
||||
handler: { actions.onReply(message) }
|
||||
))
|
||||
}
|
||||
|
||||
if !message.text.isEmpty {
|
||||
items.append(TelegramContextMenuItem(
|
||||
title: "Copy",
|
||||
iconName: "doc.on.doc",
|
||||
isDestructive: false,
|
||||
handler: { actions.onCopy(message.text) }
|
||||
))
|
||||
}
|
||||
|
||||
if canReplyForward {
|
||||
items.append(TelegramContextMenuItem(
|
||||
title: "Forward",
|
||||
iconName: "arrowshape.turn.up.right",
|
||||
isDestructive: false,
|
||||
handler: { actions.onForward(message) }
|
||||
))
|
||||
}
|
||||
|
||||
items.append(TelegramContextMenuItem(
|
||||
title: "Delete",
|
||||
iconName: "trash",
|
||||
isDestructive: true,
|
||||
handler: { actions.onDelete(message) }
|
||||
))
|
||||
|
||||
return items
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Context Menu Controller
|
||||
|
||||
/// Full-screen overlay replicating Telegram iOS context menu.
|
||||
/// Telegram source: ContextController.swift, DefaultDarkPresentationTheme.swift
|
||||
final class TelegramContextMenuController: UIView {
|
||||
|
||||
// MARK: - Constants
|
||||
|
||||
private static let menuWidth: CGFloat = 250
|
||||
private static let menuItemHeight: CGFloat = 44
|
||||
private static let menuGap: CGFloat = 8
|
||||
private static let panDismissThreshold: CGFloat = 100
|
||||
private static let panVelocityThreshold: CGFloat = 800
|
||||
|
||||
// MARK: - Subviews
|
||||
|
||||
/// Telegram: UIVisualEffectView(.dark) + UIColor(rgb: 0x000000, alpha: 0.6) dim
|
||||
private let backgroundBlurView = UIVisualEffectView(effect: UIBlurEffect(style: .dark))
|
||||
private let dimView = UIView()
|
||||
private let snapshotContainer = UIView()
|
||||
private let snapshotImageView = UIImageView()
|
||||
private let menuCard: TelegramContextMenuCardView
|
||||
|
||||
// MARK: - State
|
||||
|
||||
private let sourceFrame: CGRect
|
||||
private let isOutgoing: Bool
|
||||
private var onDismiss: (() -> Void)?
|
||||
private var isDismissing = false
|
||||
private var panStartSnapshotCenter: CGPoint = .zero
|
||||
private var panStartMenuCenter: CGPoint = .zero
|
||||
|
||||
// MARK: - Init
|
||||
|
||||
private init(
|
||||
snapshot: UIImage,
|
||||
sourceFrame: CGRect,
|
||||
bubblePath: UIBezierPath,
|
||||
items: [TelegramContextMenuItem],
|
||||
isOutgoing: Bool,
|
||||
onDismiss: (() -> Void)?
|
||||
) {
|
||||
self.sourceFrame = sourceFrame
|
||||
self.isOutgoing = isOutgoing
|
||||
self.onDismiss = onDismiss
|
||||
self.menuCard = TelegramContextMenuCardView(items: items)
|
||||
super.init(frame: .zero)
|
||||
buildHierarchy(snapshot: snapshot, bubblePath: bubblePath)
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) { fatalError() }
|
||||
|
||||
// MARK: - Public API
|
||||
|
||||
@MainActor
|
||||
static func present(
|
||||
snapshot: UIImage,
|
||||
sourceFrame: CGRect,
|
||||
bubblePath: UIBezierPath,
|
||||
items: [TelegramContextMenuItem],
|
||||
isOutgoing: Bool,
|
||||
onDismiss: (() -> Void)? = nil
|
||||
) {
|
||||
guard !items.isEmpty else { return }
|
||||
guard let scene = UIApplication.shared.connectedScenes
|
||||
.first(where: { $0.activationState == .foregroundActive }) as? UIWindowScene,
|
||||
let window = scene.windows.first(where: \.isKeyWindow)
|
||||
else { return }
|
||||
|
||||
// Telegram always dismisses keyboard before showing context menu
|
||||
window.endEditing(true)
|
||||
|
||||
let overlay = TelegramContextMenuController(
|
||||
snapshot: snapshot,
|
||||
sourceFrame: sourceFrame,
|
||||
bubblePath: bubblePath,
|
||||
items: items,
|
||||
isOutgoing: isOutgoing,
|
||||
onDismiss: onDismiss
|
||||
)
|
||||
overlay.frame = window.bounds
|
||||
overlay.autoresizingMask = [.flexibleWidth, .flexibleHeight]
|
||||
window.addSubview(overlay)
|
||||
overlay.performPresentation()
|
||||
}
|
||||
|
||||
// MARK: - Build Hierarchy
|
||||
|
||||
private func buildHierarchy(snapshot: UIImage, bubblePath: UIBezierPath) {
|
||||
// 1. Full-screen blur (Telegram: custom zoom blur + .systemMaterialDark)
|
||||
backgroundBlurView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
|
||||
addSubview(backgroundBlurView)
|
||||
|
||||
// 2. Dim overlay on top of blur (Telegram: UIColor(rgb: 0x000000, alpha: 0.6))
|
||||
dimView.backgroundColor = UIColor(white: 0, alpha: 0.6)
|
||||
dimView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
|
||||
addSubview(dimView)
|
||||
|
||||
// 2. Snapshot container
|
||||
snapshotContainer.backgroundColor = .clear
|
||||
snapshotImageView.image = snapshot
|
||||
snapshotImageView.contentMode = .scaleToFill
|
||||
|
||||
let mask = CAShapeLayer()
|
||||
mask.path = bubblePath.cgPath
|
||||
snapshotImageView.layer.mask = mask
|
||||
|
||||
snapshotContainer.layer.shadowColor = UIColor.black.cgColor
|
||||
snapshotContainer.layer.shadowOpacity = 0.3
|
||||
snapshotContainer.layer.shadowRadius = 12
|
||||
snapshotContainer.layer.shadowOffset = CGSize(width: 0, height: 4)
|
||||
snapshotContainer.layer.shadowPath = bubblePath.cgPath
|
||||
|
||||
snapshotContainer.addSubview(snapshotImageView)
|
||||
addSubview(snapshotContainer)
|
||||
|
||||
// 3. Menu card
|
||||
menuCard.onItemSelected = { [weak self] in
|
||||
self?.performDismissal()
|
||||
}
|
||||
addSubview(menuCard)
|
||||
|
||||
// 4. Gestures
|
||||
let tap = UITapGestureRecognizer(target: self, action: #selector(tapDismiss(_:)))
|
||||
tap.delegate = self
|
||||
addGestureRecognizer(tap)
|
||||
|
||||
let pan = UIPanGestureRecognizer(target: self, action: #selector(panDismiss(_:)))
|
||||
addGestureRecognizer(pan)
|
||||
}
|
||||
|
||||
// MARK: - Layout
|
||||
|
||||
/// Positions snapshot + menu card. MUST be called with identity transforms.
|
||||
private func layoutContent() {
|
||||
let windowInsets = window?.safeAreaInsets ?? .zero
|
||||
let safeTop = max(windowInsets.top, 20)
|
||||
let safeBottom = max(windowInsets.bottom, 20)
|
||||
|
||||
// Snapshot at original bubble position
|
||||
snapshotContainer.frame = sourceFrame
|
||||
snapshotImageView.frame = snapshotContainer.bounds
|
||||
|
||||
// Menu dimensions
|
||||
let menuW = Self.menuWidth
|
||||
let menuH = CGFloat(menuCard.itemCount) * Self.menuItemHeight
|
||||
let gap = Self.menuGap
|
||||
|
||||
// Vertical: prefer below, then above, then shift
|
||||
let belowSpace = bounds.height - safeBottom - sourceFrame.maxY
|
||||
let aboveSpace = sourceFrame.minY - safeTop
|
||||
|
||||
var menuY: CGFloat
|
||||
var menuAbove = false
|
||||
|
||||
if belowSpace >= menuH + gap {
|
||||
menuY = sourceFrame.maxY + gap
|
||||
} else if aboveSpace >= menuH + gap {
|
||||
menuY = sourceFrame.minY - gap - menuH
|
||||
menuAbove = true
|
||||
} else {
|
||||
// Shift snapshot up to make room below
|
||||
let shift = (menuH + gap) - belowSpace
|
||||
snapshotContainer.frame.origin.y -= shift
|
||||
menuY = snapshotContainer.frame.maxY + gap
|
||||
}
|
||||
|
||||
// Horizontal: align with bubble side
|
||||
let menuX: CGFloat
|
||||
if isOutgoing {
|
||||
menuX = min(sourceFrame.maxX - menuW, bounds.width - menuW - 8)
|
||||
} else {
|
||||
menuX = max(sourceFrame.minX, 8)
|
||||
}
|
||||
|
||||
menuCard.frame = CGRect(x: menuX, y: menuY, width: menuW, height: menuH)
|
||||
|
||||
// Anchor for scale animation — scale from the edge nearest to the bubble
|
||||
let anchorX: CGFloat = isOutgoing ? 1.0 : 0.0
|
||||
let anchorY: CGFloat = menuAbove ? 1.0 : 0.0
|
||||
setAnchorPointPreservingPosition(CGPoint(x: anchorX, y: anchorY), for: menuCard)
|
||||
}
|
||||
|
||||
/// Changes anchor point without moving the view.
|
||||
private func setAnchorPointPreservingPosition(_ anchor: CGPoint, for view: UIView) {
|
||||
let oldAnchor = view.layer.anchorPoint
|
||||
let delta = CGPoint(
|
||||
x: (anchor.x - oldAnchor.x) * view.bounds.width,
|
||||
y: (anchor.y - oldAnchor.y) * view.bounds.height
|
||||
)
|
||||
view.layer.anchorPoint = anchor
|
||||
view.layer.position = CGPoint(
|
||||
x: view.layer.position.x + delta.x,
|
||||
y: view.layer.position.y + delta.y
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Presentation
|
||||
|
||||
private func performPresentation() {
|
||||
// Layout with identity transforms (CRITICAL — frame + transform interaction)
|
||||
backgroundBlurView.frame = bounds
|
||||
dimView.frame = bounds
|
||||
layoutContent()
|
||||
|
||||
// Set initial pre-animation state
|
||||
backgroundBlurView.alpha = 0
|
||||
dimView.alpha = 0
|
||||
snapshotContainer.alpha = 0
|
||||
snapshotContainer.transform = CGAffineTransform(scaleX: 0.96, y: 0.96)
|
||||
menuCard.alpha = 0
|
||||
menuCard.transform = CGAffineTransform(scaleX: 0.01, y: 0.01)
|
||||
|
||||
// Telegram spring: mass 5, stiffness 900, damping 88
|
||||
let spring = UISpringTimingParameters(
|
||||
mass: 5.0, stiffness: 900.0, damping: 88.0, initialVelocity: .zero
|
||||
)
|
||||
|
||||
let animator = UIViewPropertyAnimator(duration: 0, timingParameters: spring)
|
||||
animator.addAnimations {
|
||||
self.backgroundBlurView.alpha = 1
|
||||
self.dimView.alpha = 1
|
||||
self.snapshotContainer.alpha = 1
|
||||
self.snapshotContainer.transform = .identity
|
||||
self.menuCard.alpha = 1
|
||||
self.menuCard.transform = .identity
|
||||
}
|
||||
animator.startAnimation()
|
||||
}
|
||||
|
||||
// MARK: - Dismissal
|
||||
|
||||
private func performDismissal() {
|
||||
guard !isDismissing else { return }
|
||||
isDismissing = true
|
||||
|
||||
UIView.animate(withDuration: 0.2, delay: 0, options: .curveEaseIn) {
|
||||
self.backgroundBlurView.alpha = 0
|
||||
self.dimView.alpha = 0
|
||||
self.snapshotContainer.alpha = 0
|
||||
self.menuCard.alpha = 0
|
||||
self.menuCard.transform = CGAffineTransform(scaleX: 0.01, y: 0.01)
|
||||
} completion: { [weak self] _ in
|
||||
self?.onDismiss?()
|
||||
self?.removeFromSuperview()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Gestures
|
||||
|
||||
@objc private func tapDismiss(_ g: UITapGestureRecognizer) {
|
||||
performDismissal()
|
||||
}
|
||||
|
||||
@objc private func panDismiss(_ g: UIPanGestureRecognizer) {
|
||||
switch g.state {
|
||||
case .began:
|
||||
panStartSnapshotCenter = snapshotContainer.center
|
||||
panStartMenuCenter = menuCard.center
|
||||
case .changed:
|
||||
let dy = max(g.translation(in: self).y, 0) * 0.6
|
||||
snapshotContainer.center = CGPoint(x: panStartSnapshotCenter.x, y: panStartSnapshotCenter.y + dy)
|
||||
menuCard.center = CGPoint(x: panStartMenuCenter.x, y: panStartMenuCenter.y + dy)
|
||||
dimView.alpha = max(1 - dy / 300, 0.3)
|
||||
case .ended, .cancelled:
|
||||
if g.translation(in: self).y > Self.panDismissThreshold || g.velocity(in: self).y > Self.panVelocityThreshold {
|
||||
performDismissal()
|
||||
} else {
|
||||
UIView.animate(withDuration: 0.3, delay: 0, usingSpringWithDamping: 0.8, initialSpringVelocity: 0) {
|
||||
self.snapshotContainer.center = self.panStartSnapshotCenter
|
||||
self.menuCard.center = self.panStartMenuCenter
|
||||
self.dimView.alpha = 1
|
||||
}
|
||||
}
|
||||
default: break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - UIGestureRecognizerDelegate
|
||||
|
||||
extension TelegramContextMenuController: UIGestureRecognizerDelegate {
|
||||
func gestureRecognizer(_ g: UIGestureRecognizer, shouldReceive touch: UITouch) -> Bool {
|
||||
guard g is UITapGestureRecognizer else { return true }
|
||||
let loc = touch.location(in: self)
|
||||
return !snapshotContainer.frame.contains(loc) && !menuCard.frame.contains(loc)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Snapshot Helper
|
||||
|
||||
extension TelegramContextMenuController {
|
||||
static func captureSnapshot(of sourceView: UIView) -> (image: UIImage, frame: CGRect)? {
|
||||
guard let window = sourceView.window else { return nil }
|
||||
let frameInWindow = sourceView.convert(sourceView.bounds, to: window)
|
||||
let renderer = UIGraphicsImageRenderer(size: sourceView.bounds.size)
|
||||
let image = renderer.image { ctx in
|
||||
ctx.cgContext.translateBy(x: -frameInWindow.origin.x, y: -frameInWindow.origin.y)
|
||||
window.drawHierarchy(in: window.bounds, afterScreenUpdates: false)
|
||||
}
|
||||
return (image, frameInWindow)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user