mirror of
https://github.com/block/buzz.git
synced 2026-10-28 04:51:40 +00:00
## 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>
454 lines
14 KiB
Swift
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)
|
|
}
|
|
}
|