Files
buzz/mobile/ios/Runner/NativeAttachmentPopoverCoordinator.swift
klopez4212andGitHub 857e63c4dd Polish mobile composer and messaging UI (#3918)
## Summary

- Refine the mobile composer with compact and expanded states, shared
footer fades, haptics, reliable keyboard dismissal, and full-width
camera and photo surfaces.
- Standardize popovers, filters, and section menus with consistent type,
strokes, radii, spacing, icons, and destructive styling.
- Align message presentation with desktop through consistent system
rows, typing and loading feedback, emoji placement, and predictable
photo viewing.

## Validation

- `just mobile-check`
- `just mobile-test` — 1,037 passed, 1 skipped
- Tested on Pixel 10 and a connected iPhone

## Snapshots

<table>
  <tr>
    <td align="center">Compact composer</td>
    <td align="center">Attachment menu</td>
    <td align="center">Recent photos</td>
  </tr>
  <tr>
<td><img
src="https://raw.githubusercontent.com/block/buzz/9732022cb13bb39ce797c4faaa714fe4c924955f/pr-3918--01-compact-composer.png"
width="260" /></td>
<td><img
src="https://raw.githubusercontent.com/block/buzz/9732022cb13bb39ce797c4faaa714fe4c924955f/pr-3918--02-attachment-menu.png"
width="260" /></td>
<td><img
src="https://raw.githubusercontent.com/block/buzz/9732022cb13bb39ce797c4faaa714fe4c924955f/pr-3918--03-photo-surface.png"
width="260" /></td>
  </tr>
</table>

---------

Signed-off-by: kenny lopez <klopez4212@gmail.com>
2026-08-03 07:30:23 -07:00

432 lines
12 KiB
Swift

import CoreText
import Flutter
import UIKit
enum NativeAttachmentExpandedSurfaceBehavior {
@MainActor
static func dismissKeyboard(in window: UIWindow?) {
window?.endEditing(true)
}
static func keyboardOverlap(
containerBounds: CGRect,
keyboardLayoutFrame: CGRect
) -> CGFloat {
guard
!keyboardLayoutFrame.isNull,
!keyboardLayoutFrame.isInfinite,
keyboardLayoutFrame.minY < containerBounds.maxY
else {
return 0
}
return max(0, containerBounds.maxY - keyboardLayoutFrame.minY)
}
}
enum NativeAttachmentPopoverAnchorLayout {
static let expandedVerticalOffset: CGFloat = 40
static func sourceRect(
anchorBounds: CGRect,
keyboardDismissalOffset: CGFloat,
isExpanded: Bool
) -> CGRect {
anchorBounds.offsetBy(
dx: 0,
dy: keyboardDismissalOffset
+ (isExpanded ? expandedVerticalOffset : 0)
)
}
}
enum NativeAttachmentPopoverPresentationLayout {
static func keyboardDismissalOffset(
sourceRect: CGRect,
containerBounds: CGRect,
safeAreaInsets: UIEdgeInsets,
keyboardLayoutFrame: CGRect,
menuHeight: CGFloat
) -> CGFloat {
let keyboardOverlap =
NativeAttachmentExpandedSurfaceBehavior.keyboardOverlap(
containerBounds: containerBounds,
keyboardLayoutFrame: keyboardLayoutFrame
)
guard keyboardOverlap > 0 else { return 0 }
let availableHeight =
sourceRect.minY - (containerBounds.minY + safeAreaInsets.top)
return availableHeight >= menuHeight ? 0 : keyboardOverlap
}
static func sourceRect(
_ sourceRect: CGRect,
keyboardDismissalOffset: CGFloat
) -> CGRect {
sourceRect.offsetBy(dx: 0, dy: keyboardDismissalOffset)
}
}
final class NativeAttachmentPopoverCoordinator: NSObject {
private let channel: FlutterMethodChannel
private weak var parentViewController: UIViewController?
private weak var presentedController: UIViewController?
private weak var sourceAnchorView: UIView?
init(
messenger: FlutterBinaryMessenger,
parentViewController: UIViewController?
) {
channel = FlutterMethodChannel(
name: "buzz/native_attachment_popover",
binaryMessenger: messenger
)
self.parentViewController = parentViewController
super.init()
channel.setMethodCallHandler { [weak self] call, result in
self?.handle(call, result: result)
}
}
private func handle(
_ call: FlutterMethodCall,
result: @escaping FlutterResult
) {
switch call.method {
case "isSupported":
if #available(iOS 26.0, *) {
result(true)
} else {
result(false)
}
case "present":
guard
let arguments = call.arguments as? [String: Any],
let x = arguments["x"] as? NSNumber,
let y = arguments["y"] as? NSNumber,
let width = arguments["width"] as? NSNumber,
let height = arguments["height"] as? NSNumber
else {
result(
FlutterError(
code: "invalid_arguments",
message: "Expected the attachment trigger bounds.",
details: nil
)
)
return
}
let sourceRect = CGRect(
x: CGFloat(truncating: x),
y: CGFloat(truncating: y),
width: CGFloat(truncating: width),
height: CGFloat(truncating: height)
)
DispatchQueue.main.async { [weak self] in
result(self?.presentPopover(sourceRect: sourceRect) ?? false)
}
case "dismiss":
DispatchQueue.main.async { [weak self] in
if #available(iOS 26.0, *),
let controller =
self?.presentedController
as? NativeAttachmentPopoverViewController
{
controller.dismissAndNotify()
} else {
self?.presentedController?.dismiss(animated: true)
}
result(nil)
}
default:
result(FlutterMethodNotImplemented)
}
}
@MainActor
private func presentPopover(sourceRect: CGRect) -> Bool {
guard #available(iOS 26.0, *) else { return false }
guard presentedController == nil else { return true }
let rootViewController =
parentViewController ?? activeWindowRootViewController()
guard let presenter = topViewController(from: rootViewController) else {
return false
}
let sourceView = presenter.view
var convertedRect: CGRect
if let window = sourceView?.window {
convertedRect = sourceView?.convert(sourceRect, from: window) ?? sourceRect
} else {
convertedRect = sourceRect
}
if let sourceView {
let keyboardDismissalOffset =
NativeAttachmentPopoverPresentationLayout.keyboardDismissalOffset(
sourceRect: convertedRect,
containerBounds: sourceView.bounds,
safeAreaInsets: sourceView.safeAreaInsets,
keyboardLayoutFrame: sourceView.keyboardLayoutGuide.layoutFrame,
menuHeight: NativeAttachmentMenuLayout.size(
compatibleWith: sourceView.traitCollection
).height
)
if keyboardDismissalOffset > 0 {
NativeAttachmentExpandedSurfaceBehavior.dismissKeyboard(
in: sourceView.window
)
convertedRect =
NativeAttachmentPopoverPresentationLayout.sourceRect(
convertedRect,
keyboardDismissalOffset: keyboardDismissalOffset
)
}
}
let anchorView = makeSourceAnchor(frame: convertedRect)
sourceView?.addSubview(anchorView)
sourceAnchorView = anchorView
let availableWidth = max(
320,
min(
(sourceView?.bounds.width ?? UIScreen.main.bounds.width) - 24,
430
)
)
let controller = NativeAttachmentPopoverViewController(
channel: channel,
expandedWidth: availableWidth
)
controller.modalPresentationStyle = .popover
controller.preferredTransition = .zoom { [weak anchorView] _ in
anchorView
}
controller.onDismiss = { [weak self] in
self?.presentedController = nil
self?.sourceAnchorView?.removeFromSuperview()
self?.channel.invokeMethod("dismissed", arguments: nil)
}
guard let popover = controller.popoverPresentationController else {
anchorView.removeFromSuperview()
return false
}
popover.sourceView = anchorView
popover.sourceRect = anchorView.bounds
popover.permittedArrowDirections = [.down]
popover.backgroundColor = .clear
popover.delegate = controller
presentedController = controller
presenter.present(controller, animated: true)
return true
}
@MainActor
private func makeSourceAnchor(frame: CGRect) -> UIView {
let anchor = UIView(frame: frame)
anchor.isUserInteractionEnabled = false
anchor.accessibilityElementsHidden = true
anchor.backgroundColor = .clear
anchor.layer.cornerRadius = min(frame.width, frame.height) / 2
anchor.layer.cornerCurve = .continuous
return anchor
}
@MainActor
private func activeWindowRootViewController() -> UIViewController? {
UIApplication.shared.connectedScenes
.compactMap { $0 as? UIWindowScene }
.filter { $0.activationState == .foregroundActive }
.flatMap(\.windows)
.first(where: \.isKeyWindow)?
.rootViewController
}
@MainActor
private func topViewController(
from viewController: UIViewController?
) -> UIViewController? {
if let presented = viewController?.presentedViewController {
return topViewController(from: presented)
}
if let navigation = viewController as? UINavigationController {
return topViewController(from: navigation.visibleViewController)
}
if let tab = viewController as? UITabBarController {
return topViewController(from: tab.selectedViewController)
}
return viewController
}
}
enum NativeAttachmentMenuLayout {
static let itemCount: CGFloat = 4
static let contentPadding: CGFloat = 16
static let minimumItemHeight: CGFloat = 52
static let itemSpacing: CGFloat = 8
static let itemVerticalPadding: CGFloat = 8
static let maximumHeight: CGFloat = 372
static let width: CGFloat = 216
static let labelTextStyle: UIFont.TextStyle = .title3
static func itemHeight(
compatibleWith traitCollection: UITraitCollection
) -> CGFloat {
let labelHeight = NativeAttachmentMenuTypography.font(
forTextStyle: labelTextStyle,
compatibleWith: traitCollection
).lineHeight
return max(
minimumItemHeight,
ceil(labelHeight + (itemVerticalPadding * 2))
)
}
static func itemsHeight(
compatibleWith traitCollection: UITraitCollection
) -> CGFloat {
(itemHeight(compatibleWith: traitCollection) * itemCount)
+ (itemSpacing * (itemCount - 1))
}
static func contentHeight(
compatibleWith traitCollection: UITraitCollection
) -> CGFloat {
(contentPadding * 2) + itemsHeight(compatibleWith: traitCollection)
}
static func size(
compatibleWith traitCollection: UITraitCollection,
maximumHeight: CGFloat = NativeAttachmentMenuLayout.maximumHeight
) -> CGSize {
CGSize(
width: width,
height: min(
contentHeight(compatibleWith: traitCollection),
maximumHeight
)
)
}
}
enum NativeAttachmentMenuTypography {
static let interPostScriptName = "InterVariable"
private static let registeredInter: Bool = {
let fontURL = Bundle.main.bundleURL
.appendingPathComponent("Frameworks")
.appendingPathComponent("App.framework")
.appendingPathComponent("flutter_assets")
.appendingPathComponent("assets")
.appendingPathComponent("fonts")
.appendingPathComponent("InterVariable.ttf")
guard FileManager.default.fileExists(atPath: fontURL.path) else {
return false
}
return CTFontManagerRegisterFontsForURL(
fontURL as CFURL,
.process,
nil
)
}()
static func font(
forTextStyle textStyle: UIFont.TextStyle,
compatibleWith traitCollection: UITraitCollection? = nil
) -> UIFont {
_ = registeredInter
let scaledPointSize = UIFontMetrics(forTextStyle: textStyle).scaledValue(
for: 20,
compatibleWith: traitCollection
)
let preferredFont = UIFont.preferredFont(
forTextStyle: textStyle,
compatibleWith: traitCollection
)
guard
let interFont = UIFont(
name: interPostScriptName,
size: scaledPointSize
)
else {
return preferredFont
}
return interFont
}
}
enum NativeAttachmentPopoverStyle {
static let cornerRadius: CGFloat = 20
static let shadowOpacity: Float = 0.18
static let shadowRadius: CGFloat = 12
static let shadowOffset = CGSize(width: 0, height: 6)
static let borderWidth: CGFloat = 1
}
func makeNativeAttachmentMenuButton(
title: String,
symbol: String,
action: @escaping () -> Void
) -> UIButton {
let button = UIButton(
primaryAction: UIAction { _ in
UISelectionFeedbackGenerator().selectionChanged()
action()
}
)
button.accessibilityLabel = title
let symbolConfiguration = UIImage.SymbolConfiguration(
pointSize: 18,
weight: .regular
)
let iconView = UIImageView(
image: UIImage(
systemName: symbol,
withConfiguration: symbolConfiguration
)
)
iconView.tintColor = .label
iconView.contentMode = .center
iconView.translatesAutoresizingMaskIntoConstraints = false
let titleLabel = UILabel()
titleLabel.text = title
titleLabel.textColor = .label
titleLabel.font = NativeAttachmentMenuTypography.font(
forTextStyle: NativeAttachmentMenuLayout.labelTextStyle
)
titleLabel.adjustsFontForContentSizeCategory = true
titleLabel.textAlignment = .left
titleLabel.translatesAutoresizingMaskIntoConstraints = false
button.addSubview(iconView)
button.addSubview(titleLabel)
NSLayoutConstraint.activate([
iconView.leadingAnchor.constraint(
equalTo: button.leadingAnchor,
constant: 8
),
iconView.centerYAnchor.constraint(equalTo: button.centerYAnchor),
iconView.widthAnchor.constraint(equalToConstant: 26),
titleLabel.leadingAnchor.constraint(
equalTo: iconView.trailingAnchor,
constant: 12
),
titleLabel.trailingAnchor.constraint(
equalTo: button.trailingAnchor,
constant: -8
),
titleLabel.centerYAnchor.constraint(equalTo: button.centerYAnchor),
])
button.configurationUpdateHandler = { button in
button.alpha = button.isHighlighted ? 0.62 : 1
}
return button
}