Files
buzz/mobile/ios/Runner/NativeMessageMenu.swift
klopez4212 c8ea4fb4eb feat(mobile): polish Home navigation and Solo workspace (#8393)
## Summary
Polish mobile Home and Solo: group Search/Inbox beside a separate
profile avatar, move Inbox and Settings to bottom-up pages with Close
controls, and place tabs opposite the plus/compose action. Morph plus to
compose clockwise when entering Solo and reverse counterclockwise when
returning Home, with a directional quarter-turn of the icon inside a
level button and a subtle scale pulse, respecting reduced motion. Align
Search’s Close control with Inbox, simplify its placeholder, and
initialize selected filter contrast correctly. Collapse the plus menu
when leaving Home. Center Solo’s native-glass New chat empty state and
keep it stationary behind the keyboard.

Standardize Tabler icons and neutral backgrounds, retain SF Back/Close
on iOS, use native Liquid Glass headers, tab container, and actions
(solid grey selected tab, slightly darker on iOS) on iOS and black
actions on Android, and smooth the expanded menu corners. Repair native
header safe-area/return layout and compose movement after cancellation.

### Related issue
None found for this UI refinement; follows #8337.

### Testing
- Formatting, analysis, full mobile suite: 3,487 passed, 4 skipped; 3
push-configuration tests passed. File-size checks passed.
- Base UI built and installed on Pixel and iPhone in place. Pixel
navigation/New chat cancellation checked. Subsequent glass and rotation
refinements are previewed on iPhone; motion feel awaits human
confirmation.
- Draft pending human confirmation of the final Settings entrance/return
behavior on iPhone.

### Pixel snapshots
| Solo | Expanded actions |
| --- | --- |
| <img
src="https://raw.githubusercontent.com/block/buzz/16dc0f8af971e63d788fee20974997fab925dec9/docs/screenshots/mobile-home-solo/pixel-solo.png"
width="280" alt="Solo empty state with New chat" /> | <img
src="https://raw.githubusercontent.com/block/buzz/16dc0f8af971e63d788fee20974997fab925dec9/docs/screenshots/mobile-home-solo/pixel-actions.png"
width="280" alt="Expanded channel actions with smooth corners" /> |

---------

Signed-off-by: kenny lopez <klopez4212@gmail.com>
Co-authored-by: Terp <5856f6900e8e0a4e0ea1f4f8d2ee33e4753d2119bc122f94ecafc660b46c0511@buzz.block.builderlab.xyz>
2026-10-16 12:28:02 -04:00

353 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: NativeTabler.image(action["symbol"] as? String ?? "ellipsis", size: 18))
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(NativeTabler.image("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
}
}