Files
buzz/mobile/ios/Runner/NativeAttachmentPopoverCoordinator.swift
T
cls 9dfa06ffee
Docker image / Build (linux/amd64) (push) Has been cancelled
Docker image / Build (linux/arm64) (push) Has been cancelled
Docker image / Merge release multi-arch manifest (push) Has been cancelled
Docker image / Merge debug multi-arch manifest (push) Has been cancelled
Docker image / Build public push gateway (linux/amd64) (push) Has been cancelled
Docker image / Build public push gateway (linux/arm64) (push) Has been cancelled
Docker image / Publish public push gateway image (push) Has been cancelled
Sprig image / Build (linux/amd64) (push) Has been cancelled
Sprig image / Build (linux/arm64) (push) Has been cancelled
Sprig image / Merge multi-arch manifest (push) Has been cancelled
Harbor Buzz Orchestra / Python tests and lint (push) Has been cancelled
CI / Detect Changed Paths (push) Has been cancelled
CI / Rust Lint (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Desktop Core (push) Has been cancelled
CI / Desktop Smoke E2E (1) (push) Has been cancelled
CI / Desktop Smoke E2E (2) (push) Has been cancelled
CI / Desktop Smoke E2E (3) (push) Has been cancelled
CI / Desktop Smoke E2E (4) (push) Has been cancelled
CI / Desktop (push) Has been cancelled
CI / Desktop E2E Relay (push) Has been cancelled
CI / Desktop E2E Integration (1/2) (push) Has been cancelled
CI / Desktop E2E Integration (2/2) (push) Has been cancelled
CI / Desktop E2E Integration (push) Has been cancelled
CI / Backend Integration (relay e2e) (push) Has been cancelled
CI / Relay E2E (push) Has been cancelled
CI / Web (push) Has been cancelled
CI / Mobile (push) Has been cancelled
CI / Security (push) Has been cancelled
CI / Dead Token Reference Guard (push) Has been cancelled
CI / Server Cross-Compile (aarch64-unknown-linux-musl) (push) Has been cancelled
CI / Server Cross-Compile (x86_64-unknown-linux-musl) (push) Has been cancelled
CI / Windows Rust (x86_64-pc-windows-msvc) (push) Has been cancelled
CI / Desktop Build (macOS) (push) Has been cancelled
helm chart / lint + unittest + render matrix (push) Has been cancelled
helm chart / install on kind (gated) (push) Has been cancelled
helm chart / publish chart to GHCR (push) Has been cancelled
Mesh Lifecycle / Relay-Driven Mesh Lifecycle Smoke (push) Has been cancelled
Sprig / Build (aarch64-unknown-linux-musl) (push) Has been cancelled
Sprig / Build (x86_64-unknown-linux-musl) (push) Has been cancelled
Sprig / Publish rolling release (push) Has been cancelled
Sprig / Publish tagged release (push) Has been cancelled
feat: import Chinese-localized Buzz source snapshot
Signed-off-by: cls_宁波本机 <908705107@qq.com>
2026-08-13 18:34:25 +08: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
}