Files
buzz/mobile/ios/Runner/NativeMessageActionSurface.swift
klopez4212 78267b0c3a Polish mobile message actions (#5873)
## Summary

- Add a lifted-message long-press popover with the reaction tray and
grouped actions.
- Use a native action surface
- Preserve taps and scrolling, exclude attached reactions from the
lifted preview, and align the composition to the bottom safe area.
<img width="1080" height="2424" alt="Screenshot_20260814-160732"
src="https://github.com/user-attachments/assets/a9d5fc91-4ee3-427c-8199-1c3be2b4e98a"
/>

## Testing

- `bin/just mobile-check`
- `flutter test test/features/channels/message_actions_test.dart`
- `flutter test` (1,364 tests)
- Manual interaction review on iPhone and Pixel 10

---------

Signed-off-by: kenny lopez <klopez4212@gmail.com>
Signed-off-by: Kenny Lopez <klopez4212@gmail.com>
Signed-off-by: Princess Donut <b238ea756dee4d98afa5883fc7f1de61eeabe65bf700e3a5a5a80db5e42e2c2b@buzz.block.builderlab.xyz>
Signed-off-by: Wes <wesbillman@users.noreply.github.com>
Co-authored-by: Princess Donut <b238ea756dee4d98afa5883fc7f1de61eeabe65bf700e3a5a5a80db5e42e2c2b@buzz.block.builderlab.xyz>
Co-authored-by: Wes <wesbillman@users.noreply.github.com>
Co-authored-by: Carl <c7ebe626f000404285d3686e1dc74cc07cc60a9754a150041ba132e14bd3e2ec@buzz.block.builderlab.xyz>
2026-08-18 15:00:45 -07:00

454 lines
14 KiB
Swift

import Flutter
import UIKit
struct NativeMessageActionDefinition {
enum Group: String, CaseIterable {
case primary
case utility
case destructive
}
let id: String
let title: String
let symbol: String
let group: Group
let isDestructive: Bool
init?(arguments: [String: Any]) {
guard
let id = arguments["id"] as? String,
let title = arguments["title"] as? String,
let symbol = arguments["symbol"] as? String,
let groupName = arguments["group"] as? String,
let group = Group(rawValue: groupName)
else {
return nil
}
self.id = id
self.title = title
self.symbol = symbol
self.group = group
isDestructive = arguments["destructive"] as? Bool ?? false
}
}
enum NativeMessageActionSurfaceLayout {
static let minimumRowHeight: CGFloat = 48
static let rowVerticalPadding: CGFloat = 4
static let separatorHeight: CGFloat = 0.5
static let verticalInset: CGFloat = 4
static let horizontalInset: CGFloat = 16
static let iconColumnWidth: CGFloat = 32
static let iconToTextSpacing: CGFloat = 12
static var cornerRadius: CGFloat {
if #available(iOS 26.0, *) {
return 33
}
return 12
}
static func populatedGroups(
actions: [NativeMessageActionDefinition]
) -> [NativeMessageActionDefinition.Group] {
NativeMessageActionDefinition.Group.allCases.filter { group in
actions.contains { $0.group == group }
}
}
static func separatorCount(
actions: [NativeMessageActionDefinition]
) -> Int {
max(0, populatedGroups(actions: actions).count - 1)
}
static func rowHeight(
minimumHeight: CGFloat = minimumRowHeight,
compatibleWith traitCollection: UITraitCollection? = nil
) -> CGFloat {
let labelHeight = UIFont.preferredFont(
forTextStyle: .body,
compatibleWith: traitCollection
).lineHeight
return max(
minimumHeight,
ceil(labelHeight + (rowVerticalPadding * 2))
)
}
static func preferredHeight(
actions: [NativeMessageActionDefinition],
minimumRowHeight: CGFloat = minimumRowHeight,
compatibleWith traitCollection: UITraitCollection? = nil
) -> CGFloat {
let resolvedRowHeight = rowHeight(
minimumHeight: minimumRowHeight,
compatibleWith: traitCollection
)
return (verticalInset * 2)
+ (CGFloat(actions.count) * resolvedRowHeight)
+ (CGFloat(separatorCount(actions: actions)) * separatorHeight)
}
}
@available(iOS 16.0, *)
enum NativeMessageActionSurfaceAppearance {
// The native list is only one sibling inside the Flutter-owned dialog.
// Keeping it non-modal leaves the reaction tray and dismiss barrier
// reachable to VoiceOver.
static let actionListAccessibilityViewIsModal = false
static func interfaceStyle(from value: Any?) -> UIUserInterfaceStyle {
switch value as? String {
case "dark":
return .dark
case "light":
return .light
default:
return .unspecified
}
}
static func backdropEffect(reduceTransparency: Bool) -> UIVisualEffect? {
guard !reduceTransparency else { return nil }
if #available(iOS 26.0, *) {
let effect = UIGlassEffect(style: .regular)
effect.isInteractive = true
return effect
}
return UIBlurEffect(style: .systemMaterial)
}
}
@available(iOS 16.0, *)
final class NativeMessageActionRowControl: UIControl {
let actionImageView: UIImageView
let actionTitleLabel = UILabel()
init(
definition: NativeMessageActionDefinition,
foregroundColor: UIColor,
destructiveColor: UIColor,
minimumHeight: CGFloat = NativeMessageActionSurfaceLayout.minimumRowHeight,
compatibleWith traitCollection: UITraitCollection? = nil,
onSelected: @escaping () -> Void
) {
actionImageView = UIImageView(image: UIImage(systemName: definition.symbol))
super.init(frame: .zero)
let color = definition.isDestructive ? destructiveColor : foregroundColor
actionImageView.tintColor = color
actionImageView.contentMode = .center
actionTitleLabel.text = definition.title
actionTitleLabel.textColor = color
actionTitleLabel.font = UIFont.preferredFont(
forTextStyle: .body,
compatibleWith: traitCollection
)
actionTitleLabel.adjustsFontForContentSizeCategory = true
actionTitleLabel.numberOfLines = 0
actionTitleLabel.lineBreakMode = .byWordWrapping
actionTitleLabel.setContentCompressionResistancePriority(
.required,
for: .vertical
)
let iconColumn = UIView()
iconColumn.translatesAutoresizingMaskIntoConstraints = false
actionImageView.translatesAutoresizingMaskIntoConstraints = false
actionTitleLabel.translatesAutoresizingMaskIntoConstraints = false
iconColumn.addSubview(actionImageView)
addSubview(iconColumn)
addSubview(actionTitleLabel)
NSLayoutConstraint.activate([
iconColumn.leadingAnchor.constraint(
equalTo: leadingAnchor,
constant: NativeMessageActionSurfaceLayout.horizontalInset
),
iconColumn.centerYAnchor.constraint(equalTo: centerYAnchor),
iconColumn.widthAnchor.constraint(
equalToConstant: NativeMessageActionSurfaceLayout.iconColumnWidth
),
iconColumn.heightAnchor.constraint(
equalToConstant: NativeMessageActionSurfaceLayout.iconColumnWidth
),
actionImageView.leadingAnchor.constraint(equalTo: iconColumn.leadingAnchor),
actionImageView.trailingAnchor.constraint(equalTo: iconColumn.trailingAnchor),
actionImageView.topAnchor.constraint(equalTo: iconColumn.topAnchor),
actionImageView.bottomAnchor.constraint(equalTo: iconColumn.bottomAnchor),
actionTitleLabel.leadingAnchor.constraint(
equalTo: iconColumn.trailingAnchor,
constant: NativeMessageActionSurfaceLayout.iconToTextSpacing
),
actionTitleLabel.trailingAnchor.constraint(
equalTo: trailingAnchor,
constant: -NativeMessageActionSurfaceLayout.horizontalInset
),
actionTitleLabel.topAnchor.constraint(
greaterThanOrEqualTo: topAnchor,
constant: NativeMessageActionSurfaceLayout.rowVerticalPadding
),
actionTitleLabel.bottomAnchor.constraint(
lessThanOrEqualTo: bottomAnchor,
constant: -NativeMessageActionSurfaceLayout.rowVerticalPadding
),
actionTitleLabel.centerYAnchor.constraint(equalTo: centerYAnchor),
heightAnchor.constraint(
greaterThanOrEqualToConstant: NativeMessageActionSurfaceLayout.rowHeight(
minimumHeight: minimumHeight,
compatibleWith: traitCollection
)
),
])
accessibilityLabel = definition.title
accessibilityTraits = .button
isAccessibilityElement = true
addAction(UIAction { _ in onSelected() }, for: .touchUpInside)
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) is unavailable")
}
override var isHighlighted: Bool {
didSet {
backgroundColor =
isHighlighted
? actionTitleLabel.textColor.withAlphaComponent(0.08)
: .clear
}
}
}
@available(iOS 16.0, *)
final class NativeMessageActionSeparatorView: UIView {
init(color: UIColor) {
super.init(frame: .zero)
backgroundColor = color
heightAnchor.constraint(
equalToConstant: NativeMessageActionSurfaceLayout.separatorHeight
).isActive = true
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) is unavailable")
}
}
@available(iOS 16.0, *)
final class NativeMessageActionSurfaceFactory: NSObject,
FlutterPlatformViewFactory
{
private let messenger: FlutterBinaryMessenger
init(messenger: FlutterBinaryMessenger) {
self.messenger = messenger
super.init()
}
func createArgsCodec() -> FlutterMessageCodec & NSObjectProtocol {
FlutterStandardMessageCodec.sharedInstance()
}
func create(
withFrame frame: CGRect,
viewIdentifier viewId: Int64,
arguments args: Any?
) -> FlutterPlatformView {
NativeMessageActionSurfacePlatformView(
frame: frame,
viewIdentifier: viewId,
messenger: messenger,
arguments: args
)
}
}
@available(iOS 16.0, *)
final class NativeMessageActionSurfacePlatformView: NSObject,
FlutterPlatformView
{
private let surfaceView: UIView
private let backdropView: UIVisualEffectView
private let channel: FlutterMethodChannel
init(
frame: CGRect,
viewIdentifier viewId: Int64,
messenger: FlutterBinaryMessenger,
arguments args: Any?
) {
let arguments = args as? [String: Any]
let surfaceColor = Self.color(
from: arguments?["surfaceColor"],
fallback: .systemBackground
)
let foregroundColor = Self.color(
from: arguments?["foregroundColor"],
fallback: .label
)
let separatorColor = Self.color(
from: arguments?["separatorColor"],
fallback: .separator
)
let destructiveColor = Self.color(
from: arguments?["errorColor"],
fallback: .systemRed
)
let interfaceStyle = NativeMessageActionSurfaceAppearance.interfaceStyle(
from: arguments?["interfaceStyle"]
)
var minimumRowHeight = NativeMessageActionSurfaceLayout.minimumRowHeight
if let requestedRowHeight = arguments?["rowHeight"] as? NSNumber,
requestedRowHeight.doubleValue.isFinite
{
minimumRowHeight = max(
minimumRowHeight,
CGFloat(requestedRowHeight.doubleValue)
)
}
let actionArguments = arguments?["actions"] as? [[String: Any]]
let actions =
actionArguments?.compactMap(
NativeMessageActionDefinition.init(arguments:)
) ?? []
surfaceView = UIView(frame: frame)
backdropView = UIVisualEffectView(
effect: NativeMessageActionSurfaceAppearance.backdropEffect(
reduceTransparency: UIAccessibility.isReduceTransparencyEnabled
)
)
channel = FlutterMethodChannel(
name: "buzz/native_message_action_surface/\(viewId)",
binaryMessenger: messenger
)
super.init()
surfaceView.backgroundColor = .clear
surfaceView.clipsToBounds = false
surfaceView.accessibilityViewIsModal =
NativeMessageActionSurfaceAppearance.actionListAccessibilityViewIsModal
surfaceView.overrideUserInterfaceStyle = interfaceStyle
backdropView.translatesAutoresizingMaskIntoConstraints = false
backdropView.overrideUserInterfaceStyle = interfaceStyle
backdropView.layer.cornerRadius = NativeMessageActionSurfaceLayout.cornerRadius
backdropView.layer.cornerCurve = .continuous
backdropView.layer.masksToBounds = true
if backdropView.effect == nil {
backdropView.backgroundColor = surfaceColor
}
surfaceView.addSubview(backdropView)
NSLayoutConstraint.activate([
backdropView.leadingAnchor.constraint(equalTo: surfaceView.leadingAnchor),
backdropView.trailingAnchor.constraint(equalTo: surfaceView.trailingAnchor),
backdropView.topAnchor.constraint(equalTo: surfaceView.topAnchor),
backdropView.bottomAnchor.constraint(equalTo: surfaceView.bottomAnchor),
])
if #unavailable(iOS 26.0) {
surfaceView.layer.cornerRadius = NativeMessageActionSurfaceLayout.cornerRadius
surfaceView.layer.shadowRadius = 32
surfaceView.layer.shadowOffset = CGSize(width: 0, height: 16)
surfaceView.layer.shadowColor = UIColor.black.cgColor
surfaceView.layer.shadowOpacity = 0.2
}
install(
actions: actions,
foregroundColor: foregroundColor,
destructiveColor: destructiveColor,
separatorColor: separatorColor,
minimumRowHeight: minimumRowHeight
)
}
func view() -> UIView {
surfaceView
}
private func install(
actions: [NativeMessageActionDefinition],
foregroundColor: UIColor,
destructiveColor: UIColor,
separatorColor: UIColor,
minimumRowHeight: CGFloat
) {
let scrollView = UIScrollView()
scrollView.translatesAutoresizingMaskIntoConstraints = false
scrollView.alwaysBounceVertical = false
scrollView.showsVerticalScrollIndicator = false
scrollView.contentInsetAdjustmentBehavior = .never
let stack = UIStackView()
stack.axis = .vertical
stack.spacing = 0
stack.translatesAutoresizingMaskIntoConstraints = false
backdropView.contentView.addSubview(scrollView)
scrollView.addSubview(stack)
NSLayoutConstraint.activate([
scrollView.leadingAnchor.constraint(equalTo: backdropView.contentView.leadingAnchor),
scrollView.trailingAnchor.constraint(equalTo: backdropView.contentView.trailingAnchor),
scrollView.topAnchor.constraint(equalTo: backdropView.contentView.topAnchor),
scrollView.bottomAnchor.constraint(equalTo: backdropView.contentView.bottomAnchor),
stack.leadingAnchor.constraint(equalTo: scrollView.contentLayoutGuide.leadingAnchor),
stack.trailingAnchor.constraint(equalTo: scrollView.contentLayoutGuide.trailingAnchor),
stack.topAnchor.constraint(
equalTo: scrollView.contentLayoutGuide.topAnchor,
constant: NativeMessageActionSurfaceLayout.verticalInset
),
stack.bottomAnchor.constraint(
equalTo: scrollView.contentLayoutGuide.bottomAnchor,
constant: -NativeMessageActionSurfaceLayout.verticalInset
),
stack.widthAnchor.constraint(equalTo: scrollView.frameLayoutGuide.widthAnchor),
])
var installedGroup = false
for group in NativeMessageActionDefinition.Group.allCases {
let groupActions = actions.filter { $0.group == group }
guard !groupActions.isEmpty else { continue }
if installedGroup {
stack.addArrangedSubview(
NativeMessageActionSeparatorView(color: separatorColor)
)
}
for definition in groupActions {
stack.addArrangedSubview(
NativeMessageActionRowControl(
definition: definition,
foregroundColor: foregroundColor,
destructiveColor: destructiveColor,
minimumHeight: minimumRowHeight,
onSelected: { [weak self] in self?.select(definition) }
)
)
}
installedGroup = true
}
}
private func select(_ definition: NativeMessageActionDefinition) {
channel.invokeMethod("selected", arguments: ["id": definition.id])
}
private static func color(from value: Any?, fallback: UIColor) -> UIColor {
guard let number = value as? NSNumber else { return fallback }
let color = number.uint32Value
let alpha = CGFloat((color >> 24) & 0xFF) / 255
let red = CGFloat((color >> 16) & 0xFF) / 255
let green = CGFloat((color >> 8) & 0xFF) / 255
let blue = CGFloat(color & 0xFF) / 255
return UIColor(red: red, green: green, blue: blue, alpha: alpha)
}
}