mirror of
https://github.com/block/buzz.git
synced 2026-10-27 04:30:52 +00:00
354 lines
15 KiB
Swift
354 lines
15 KiB
Swift
import UIKit
|
|
|
|
/// Signal-style context presentation composed entirely of public UIKit views.
|
|
/// Flutter supplies an anchor and action IDs; it retains all mutation authority.
|
|
final class NativeMessageMenuViewController: UIViewController {
|
|
var onPreviewReady: ((@escaping () -> Void) -> Void)?
|
|
var onSelect: (([String: String]) -> Void)?
|
|
private let data: [String: Any]
|
|
private let sourceRect: CGRect
|
|
private let preview: UIView
|
|
private let backdrop = UIVisualEffectView()
|
|
private let content = UIStackView()
|
|
private let scroll = NativeMessageScrollView()
|
|
private var contentTop: NSLayoutConstraint?
|
|
private var closing = false
|
|
private var previewHost: UIView?
|
|
private var actionMenu: UIView?
|
|
private var sourceTransform = CGAffineTransform.identity
|
|
private var reactionTray: UIView?
|
|
private var reactionButtons: [UIView] = []
|
|
|
|
static func sourceRect(_ data: [String: Any]) -> CGRect? {
|
|
guard let x = data["x"] as? Double, let y = data["y"] as? Double,
|
|
let width = data["width"] as? Double, let height = data["height"] as? Double,
|
|
x.isFinite, y.isFinite, width.isFinite, height.isFinite,
|
|
width > 0, height > 0 else { return nil }
|
|
return CGRect(x: x, y: y, width: width, height: height)
|
|
}
|
|
|
|
init(data: [String: Any], sourceRect: CGRect, preview: UIView) {
|
|
self.data = data
|
|
self.sourceRect = sourceRect
|
|
self.preview = preview
|
|
super.init(nibName: nil, bundle: nil)
|
|
}
|
|
|
|
@available(*, unavailable)
|
|
required init?(coder: NSCoder) { fatalError("init(coder:) is unavailable") }
|
|
|
|
override func viewDidLoad() {
|
|
super.viewDidLoad()
|
|
view.backgroundColor = .clear
|
|
view.accessibilityViewIsModal = true
|
|
backdrop.frame = view.bounds
|
|
backdrop.autoresizingMask = [.flexibleWidth, .flexibleHeight]
|
|
view.addSubview(backdrop)
|
|
let dismiss = UIButton(primaryAction: UIAction { [weak self] _ in self?.select([:]) })
|
|
dismiss.accessibilityLabel = "Dismiss message menu"
|
|
dismiss.frame = view.bounds
|
|
dismiss.autoresizingMask = [.flexibleWidth, .flexibleHeight]
|
|
view.addSubview(dismiss)
|
|
|
|
scroll.translatesAutoresizingMaskIntoConstraints = false
|
|
scroll.showsVerticalScrollIndicator = false
|
|
scroll.contentInsetAdjustmentBehavior = .never
|
|
// Keep all painted actions inside the tappable viewport. Inner padding
|
|
// gives glass shadows space without exposing off-viewport content.
|
|
scroll.clipsToBounds = true
|
|
view.addSubview(scroll)
|
|
content.axis = .vertical
|
|
content.spacing = 12
|
|
content.alignment = .leading
|
|
content.translatesAutoresizingMaskIntoConstraints = false
|
|
scroll.addSubview(content)
|
|
scroll.passthroughView = content
|
|
contentTop = content.topAnchor.constraint(equalTo: scroll.contentLayoutGuide.topAnchor)
|
|
contentTop?.isActive = true
|
|
let safe = view.safeAreaLayoutGuide
|
|
NSLayoutConstraint.activate([
|
|
scroll.leadingAnchor.constraint(equalTo: safe.leadingAnchor),
|
|
scroll.trailingAnchor.constraint(equalTo: safe.trailingAnchor),
|
|
scroll.topAnchor.constraint(equalTo: safe.topAnchor, constant: 12),
|
|
scroll.bottomAnchor.constraint(equalTo: view.keyboardLayoutGuide.topAnchor, constant: -12),
|
|
content.leadingAnchor.constraint(equalTo: scroll.contentLayoutGuide.leadingAnchor, constant: 16),
|
|
content.trailingAnchor.constraint(equalTo: scroll.contentLayoutGuide.trailingAnchor, constant: -16),
|
|
content.bottomAnchor.constraint(equalTo: scroll.contentLayoutGuide.bottomAnchor, constant: -48),
|
|
content.widthAnchor.constraint(equalTo: scroll.frameLayoutGuide.widthAnchor, constant: -32),
|
|
])
|
|
let reactionTray = makeReactionTray()
|
|
self.reactionTray = reactionTray
|
|
content.addArrangedSubview(reactionTray)
|
|
reactionTray.widthAnchor.constraint(lessThanOrEqualTo: content.widthAnchor).isActive = true
|
|
let trayWidth = reactionTray.widthAnchor.constraint(equalToConstant: 324)
|
|
trayWidth.priority = .defaultHigh
|
|
trayWidth.isActive = true
|
|
// Preserve the actual message rendering, including media and markdown.
|
|
preview.layer.cornerRadius = 0
|
|
preview.layer.cornerCurve = .continuous
|
|
preview.clipsToBounds = true
|
|
preview.isAccessibilityElement = true
|
|
preview.accessibilityLabel = data["previewLabel"] as? String
|
|
let previewHost = UIView()
|
|
self.previewHost = previewHost
|
|
previewHost.clipsToBounds = true
|
|
previewHost.layer.cornerRadius = 0
|
|
previewHost.addSubview(preview)
|
|
content.addArrangedSubview(previewHost)
|
|
let width = min(sourceRect.width, max(1, view.bounds.width - 32))
|
|
let height = min(sourceRect.height * width / sourceRect.width, 200)
|
|
previewHost.widthAnchor.constraint(equalTo: content.widthAnchor).isActive = true
|
|
previewHost.heightAnchor.constraint(equalToConstant: height).isActive = true
|
|
preview.frame = CGRect(x: 0, y: 0, width: width, height: sourceRect.height * width / sourceRect.width)
|
|
let actions = data["actions"] as? [[String: Any]] ?? []
|
|
if !actions.isEmpty {
|
|
let menu = NativeMessageSurface(radius: 16)
|
|
actionMenu = menu
|
|
let rows = UIStackView()
|
|
rows.axis = .vertical
|
|
for (index, action) in actions.enumerated() {
|
|
if index > 0 {
|
|
let divider = UIView()
|
|
divider.backgroundColor = .separator
|
|
divider.heightAnchor.constraint(equalToConstant: 1 / traitCollection.displayScale).isActive = true
|
|
rows.addArrangedSubview(divider)
|
|
}
|
|
let button = UIButton(primaryAction: UIAction { [weak self] _ in
|
|
guard let id = action["id"] as? String else { return }
|
|
self?.select(["action": id])
|
|
})
|
|
let color: UIColor = action["destructive"] as? Bool == true ? .systemRed : .label
|
|
let title = UILabel()
|
|
title.text = action["title"] as? String
|
|
title.font = .preferredFont(forTextStyle: .body)
|
|
title.adjustsFontForContentSizeCategory = true
|
|
title.numberOfLines = 0
|
|
title.textColor = color
|
|
let icon = UIImageView(image: UIImage(systemName: action["symbol"] as? String ?? "ellipsis",
|
|
withConfiguration: UIImage.SymbolConfiguration(pointSize: 18, weight: .regular)))
|
|
icon.tintColor = color
|
|
icon.contentMode = .center
|
|
icon.widthAnchor.constraint(equalToConstant: 24).isActive = true
|
|
let row = UIStackView(arrangedSubviews: [title, icon])
|
|
row.alignment = .center
|
|
row.spacing = 20
|
|
row.isUserInteractionEnabled = false
|
|
row.translatesAutoresizingMaskIntoConstraints = false
|
|
button.addSubview(row)
|
|
NSLayoutConstraint.activate([
|
|
row.leadingAnchor.constraint(equalTo: button.leadingAnchor, constant: 16),
|
|
row.trailingAnchor.constraint(equalTo: button.trailingAnchor, constant: -16),
|
|
row.topAnchor.constraint(equalTo: button.topAnchor, constant: 12),
|
|
row.bottomAnchor.constraint(equalTo: button.bottomAnchor, constant: -12),
|
|
button.heightAnchor.constraint(greaterThanOrEqualToConstant: 44),
|
|
])
|
|
button.accessibilityLabel = title.text
|
|
button.configurationUpdateHandler = { button in
|
|
button.backgroundColor = button.isHighlighted ? .tertiarySystemFill : .clear
|
|
}
|
|
rows.addArrangedSubview(button)
|
|
}
|
|
menu.embed(rows)
|
|
content.addArrangedSubview(menu)
|
|
menu.widthAnchor.constraint(lessThanOrEqualTo: content.widthAnchor).isActive = true
|
|
let menuWidth = menu.widthAnchor.constraint(equalToConstant: 280)
|
|
menuWidth.priority = .defaultHigh
|
|
menuWidth.isActive = true
|
|
}
|
|
}
|
|
|
|
private func makeReactionTray() -> UIView {
|
|
let surface = NativeMessageSurface(radius: 30)
|
|
let trayScroll = UIScrollView()
|
|
trayScroll.showsHorizontalScrollIndicator = false
|
|
let row = UIStackView()
|
|
row.spacing = 4
|
|
for reaction in data["reactions"] as? [[String: Any]] ?? [] {
|
|
guard let emoji = reaction["emoji"] as? String else { continue }
|
|
let button = UIButton(primaryAction: UIAction { [weak self] _ in
|
|
self?.select(["action": "reaction", "emoji": emoji])
|
|
})
|
|
button.accessibilityLabel = reaction["label"] as? String ?? emoji
|
|
let selected = reaction["selected"] as? Bool == true
|
|
button.accessibilityValue = selected ? "Selected. Remove reaction" : "Add reaction"
|
|
button.backgroundColor = selected ? .tertiarySystemFill : .clear
|
|
button.layer.cornerRadius = 24
|
|
let glyph = NativeMessageGlyph(data: reaction, size: 28)
|
|
glyph.isUserInteractionEnabled = false
|
|
button.addSubview(glyph)
|
|
glyph.translatesAutoresizingMaskIntoConstraints = false
|
|
NSLayoutConstraint.activate([
|
|
glyph.centerXAnchor.constraint(equalTo: button.centerXAnchor),
|
|
glyph.centerYAnchor.constraint(equalTo: button.centerYAnchor),
|
|
glyph.widthAnchor.constraint(equalToConstant: 32),
|
|
glyph.heightAnchor.constraint(equalToConstant: 32),
|
|
button.widthAnchor.constraint(equalToConstant: 48),
|
|
button.heightAnchor.constraint(equalToConstant: 48),
|
|
])
|
|
row.addArrangedSubview(button)
|
|
reactionButtons.append(button)
|
|
}
|
|
let more = UIButton(primaryAction: UIAction { [weak self] _ in self?.select(["action": "more"]) })
|
|
more.setImage(UIImage(systemName: "plus"), for: .normal)
|
|
more.tintColor = .secondaryLabel
|
|
more.accessibilityLabel = "More emoji"
|
|
more.widthAnchor.constraint(equalToConstant: 48).isActive = true
|
|
row.addArrangedSubview(more)
|
|
reactionButtons.append(more)
|
|
row.translatesAutoresizingMaskIntoConstraints = false
|
|
trayScroll.addSubview(row)
|
|
NSLayoutConstraint.activate([
|
|
row.leadingAnchor.constraint(equalTo: trayScroll.contentLayoutGuide.leadingAnchor, constant: 6),
|
|
row.trailingAnchor.constraint(equalTo: trayScroll.contentLayoutGuide.trailingAnchor, constant: -6),
|
|
row.topAnchor.constraint(equalTo: trayScroll.contentLayoutGuide.topAnchor, constant: 6),
|
|
row.bottomAnchor.constraint(equalTo: trayScroll.contentLayoutGuide.bottomAnchor, constant: -6),
|
|
row.heightAnchor.constraint(equalTo: trayScroll.frameLayoutGuide.heightAnchor, constant: -12),
|
|
])
|
|
surface.embed(trayScroll)
|
|
surface.heightAnchor.constraint(equalToConstant: 60).isActive = true
|
|
return surface
|
|
}
|
|
|
|
override func viewDidLayoutSubviews() {
|
|
super.viewDidLayoutSubviews()
|
|
// The scroll view lays out its content after this callback. Measure the
|
|
// complete stack explicitly so the first presentation cannot use a stale
|
|
// (or zero) height and leave actions below the screen.
|
|
let size = content.systemLayoutSizeFitting(
|
|
CGSize(width: max(1, scroll.bounds.width - 32), height: UIView.layoutFittingCompressedSize.height),
|
|
withHorizontalFittingPriority: .required,
|
|
verticalFittingPriority: .fittingSizeLevel)
|
|
let available = max(0, scroll.bounds.height - size.height - 48)
|
|
let top = max(32, min(sourceRect.minY - scroll.frame.minY - 72, available))
|
|
if contentTop?.constant != top {
|
|
contentTop?.constant = top
|
|
scroll.layoutIfNeeded()
|
|
}
|
|
}
|
|
|
|
override func viewWillAppear(_ animated: Bool) {
|
|
super.viewWillAppear(animated)
|
|
reactionTray?.alpha = 0
|
|
actionMenu?.alpha = 0
|
|
previewHost?.alpha = 0
|
|
}
|
|
|
|
override func viewDidAppear(_ animated: Bool) {
|
|
super.viewDidAppear(animated)
|
|
guard !closing, previewHost != nil else { return }
|
|
view.layoutIfNeeded()
|
|
if let onPreviewReady {
|
|
onPreviewReady { [weak self] in self?.animateIn() }
|
|
} else {
|
|
animateIn()
|
|
}
|
|
}
|
|
|
|
private func animateIn() {
|
|
guard !closing, let previewHost else { return }
|
|
let reducedMotion = UIAccessibility.isReduceMotionEnabled
|
|
let target = previewHost.convert(previewHost.bounds, to: view)
|
|
sourceTransform = CGAffineTransform(
|
|
translationX: sourceRect.minX - target.minX,
|
|
y: sourceRect.minY - target.minY)
|
|
if !reducedMotion {
|
|
previewHost.transform = sourceTransform
|
|
actionMenu?.transform = CGAffineTransform(translationX: 0, y: sourceRect.minY - target.minY)
|
|
.scaledBy(x: 0.95, y: 0.95)
|
|
for button in reactionButtons {
|
|
button.alpha = 0
|
|
button.transform = CGAffineTransform(translationX: 0, y: 24)
|
|
}
|
|
}
|
|
previewHost.alpha = 1
|
|
UIView.animate(withDuration: reducedMotion ? 0 : 0.14) {
|
|
self.backdrop.effect = UIBlurEffect(style: .systemUltraThinMaterial)
|
|
}
|
|
UIView.animate(withDuration: reducedMotion ? 0 : 0.25, delay: 0,
|
|
usingSpringWithDamping: 0.8, initialSpringVelocity: 1,
|
|
options: [.beginFromCurrentState, .allowUserInteraction]) {
|
|
previewHost.transform = .identity
|
|
self.actionMenu?.transform = .identity
|
|
self.actionMenu?.alpha = 1
|
|
} completion: { _ in
|
|
guard !self.closing else { return }
|
|
UIAccessibility.post(notification: .screenChanged, argument: self.content)
|
|
}
|
|
UIView.animate(withDuration: reducedMotion ? 0 : 0.2) {
|
|
self.reactionTray?.alpha = 1
|
|
}
|
|
guard !reducedMotion else { return }
|
|
for (index, button) in reactionButtons.enumerated() {
|
|
UIView.animate(withDuration: 0.2, delay: Double(index) * 0.01,
|
|
options: [.beginFromCurrentState, .curveEaseOut, .allowUserInteraction]) {
|
|
button.alpha = 1
|
|
button.transform = .identity
|
|
}
|
|
}
|
|
}
|
|
|
|
override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) {
|
|
super.viewWillTransition(to: size, with: coordinator)
|
|
select([:]) // The Flutter anchor is no longer valid after rotation.
|
|
}
|
|
|
|
override func accessibilityPerformEscape() -> Bool { select([:]); return true }
|
|
|
|
private func select(_ value: [String: String]) {
|
|
guard !closing else { return }
|
|
closing = true
|
|
onSelect?(value)
|
|
}
|
|
|
|
func animateOut(completion: @escaping () -> Void) {
|
|
UIView.animate(withDuration: UIAccessibility.isReduceMotionEnabled ? 0 : 0.16,
|
|
delay: 0, options: [.beginFromCurrentState, .curveEaseOut]) {
|
|
self.reactionTray?.alpha = 0
|
|
self.actionMenu?.alpha = 0
|
|
if !UIAccessibility.isReduceMotionEnabled {
|
|
self.previewHost?.transform = self.sourceTransform
|
|
self.actionMenu?.transform = CGAffineTransform(scaleX: 0.95, y: 0.95)
|
|
}
|
|
self.backdrop.effect = nil
|
|
} completion: { _ in completion() }
|
|
}
|
|
}
|
|
|
|
final class NativeMessageSurface: UIVisualEffectView {
|
|
init(radius: CGFloat) {
|
|
let effect: UIVisualEffect
|
|
if #available(iOS 26.0, *) {
|
|
effect = UIGlassEffect(style: .regular)
|
|
} else {
|
|
effect = UIBlurEffect(style: .systemMaterial)
|
|
}
|
|
super.init(effect: effect)
|
|
layer.cornerRadius = radius
|
|
layer.cornerCurve = .continuous
|
|
clipsToBounds = true
|
|
}
|
|
|
|
@available(*, unavailable)
|
|
required init?(coder: NSCoder) { fatalError("init(coder:) is unavailable") }
|
|
|
|
func embed(_ child: UIView) {
|
|
child.translatesAutoresizingMaskIntoConstraints = false
|
|
contentView.addSubview(child)
|
|
NSLayoutConstraint.activate([
|
|
child.leadingAnchor.constraint(equalTo: contentView.leadingAnchor),
|
|
child.trailingAnchor.constraint(equalTo: contentView.trailingAnchor),
|
|
child.topAnchor.constraint(equalTo: contentView.topAnchor),
|
|
child.bottomAnchor.constraint(equalTo: contentView.bottomAnchor),
|
|
])
|
|
}
|
|
}
|
|
|
|
/// Transparent space around the cards belongs to the dismissal backdrop.
|
|
private final class NativeMessageScrollView: UIScrollView {
|
|
weak var passthroughView: UIView?
|
|
override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
|
|
let hit = super.hitTest(point, with: event)
|
|
return hit === self || hit === passthroughView ? nil : hit
|
|
}
|
|
}
|