Files
buzz/mobile/ios/Runner/NativeMessageMenu.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
}
}