Refine mobile attachment picking (#3313)

## What
- morph the composer plus button into the attachment menu, camera, and
photo surfaces
- add ordered multi-select with inline recent photos and system picker
fallback
- add native iOS attachment/photo popovers and align the Android camera
treatment

## Stack
- follows #3312

## Validation
- `just mobile-check`
- `flutter test test/features/channels/compose_bar_test.dart`
- full mobile pre-push suite

---------

Signed-off-by: kenny lopez <klopez4212@gmail.com>
This commit is contained in:
klopez4212
2026-07-28 18:05:43 +01:00
committed by GitHub
parent 6da45ac5cf
commit a3b097745a
21 changed files with 3930 additions and 462 deletions
@@ -10,6 +10,9 @@
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; };
331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; };
331C809B294A63AB00263BE5 /* MediaSanitizer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C809A294A618700263BE5 /* MediaSanitizer.swift */; };
4A71C0012F40100100A17E01 /* InlinePhotoPicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A71C0022F40100100A17E01 /* InlinePhotoPicker.swift */; };
4A71C0032F40200100A17E01 /* NativeAttachmentPopover.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A71C0042F40200100A17E01 /* NativeAttachmentPopover.swift */; };
4A71C0052F40300100A17E01 /* NativeAttachmentPopoverCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A71C0062F40300100A17E01 /* NativeAttachmentPopoverCoordinator.swift */; };
331C809D294A63AB00263BE5 /* UIKitEncoded.png in Resources */ = {isa = PBXBuildFile; fileRef = 331C809C294A618700263BE5 /* UIKitEncoded.png */; };
331C809F294A63AB00263BE5 /* UIKitEncoded.jpg in Resources */ = {isa = PBXBuildFile; fileRef = 331C809E294A618700263BE5 /* UIKitEncoded.jpg */; };
33ADD70AB275E0EC81295559 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 8906419FB4E98B4B12B7A56F /* Pods_Runner.framework */; };
@@ -51,6 +54,9 @@
30CE81D3D1E0B195EF2A6390 /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = "<group>"; };
331C807B294A618700263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = "<group>"; };
331C809A294A618700263BE5 /* MediaSanitizer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaSanitizer.swift; sourceTree = "<group>"; };
4A71C0022F40100100A17E01 /* InlinePhotoPicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InlinePhotoPicker.swift; sourceTree = "<group>"; };
4A71C0042F40200100A17E01 /* NativeAttachmentPopover.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativeAttachmentPopover.swift; sourceTree = "<group>"; };
4A71C0062F40300100A17E01 /* NativeAttachmentPopoverCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativeAttachmentPopoverCoordinator.swift; sourceTree = "<group>"; };
331C809C294A618700263BE5 /* UIKitEncoded.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = UIKitEncoded.png; sourceTree = "<group>"; };
331C809E294A618700263BE5 /* UIKitEncoded.jpg */ = {isa = PBXFileReference; lastKnownFileType = image.jpeg; path = UIKitEncoded.jpg; sourceTree = "<group>"; };
331C8081294A63A400263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
@@ -170,6 +176,9 @@
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */,
74858FAE1ED2DC5600515810 /* AppDelegate.swift */,
331C809A294A618700263BE5 /* MediaSanitizer.swift */,
4A71C0022F40100100A17E01 /* InlinePhotoPicker.swift */,
4A71C0042F40200100A17E01 /* NativeAttachmentPopover.swift */,
4A71C0062F40300100A17E01 /* NativeAttachmentPopoverCoordinator.swift */,
7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */,
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */,
);
@@ -401,6 +410,9 @@
files = (
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */,
331C809B294A63AB00263BE5 /* MediaSanitizer.swift in Sources */,
4A71C0012F40100100A17E01 /* InlinePhotoPicker.swift in Sources */,
4A71C0032F40200100A17E01 /* NativeAttachmentPopover.swift in Sources */,
4A71C0052F40300100A17E01 /* NativeAttachmentPopoverCoordinator.swift in Sources */,
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */,
7884E8682EC3CC0700C636F2 /* SceneDelegate.swift in Sources */,
);
+48 -7
View File
@@ -7,6 +7,8 @@ import UserNotifications
@objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate {
private var mediaUploadChannel: FlutterMethodChannel?
private var qrScannerChannel: FlutterMethodChannel?
private var inlinePhotoPickerSupportChannel: FlutterMethodChannel?
private var nativeAttachmentPopoverCoordinator: NativeAttachmentPopoverCoordinator?
override func application(
_ application: UIApplication,
@@ -18,20 +20,56 @@ import UserNotifications
func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) {
GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry)
let messenger = engineBridge.applicationRegistrar.messenger()
mediaUploadChannel = FlutterMethodChannel(
name: "buzz/media_upload",
binaryMessenger: engineBridge.applicationRegistrar.messenger()
binaryMessenger: messenger
)
mediaUploadChannel?.setMethodCallHandler { [weak self] call, result in
self?.handleMediaUploadMethodCall(call, result: result)
}
qrScannerChannel = FlutterMethodChannel(
name: "buzz/qr_scanner",
binaryMessenger: engineBridge.applicationRegistrar.messenger()
binaryMessenger: messenger
)
qrScannerChannel?.setMethodCallHandler { call, result in
Self.handleQrScannerMethodCall(call, result: result)
}
inlinePhotoPickerSupportChannel = FlutterMethodChannel(
name: "buzz/inline_photo_picker",
binaryMessenger: messenger
)
inlinePhotoPickerSupportChannel?.setMethodCallHandler { call, result in
guard call.method == "isSupported" else {
result(FlutterMethodNotImplemented)
return
}
if #available(iOS 17.0, *) {
result(true)
} else {
result(false)
}
}
if let inlinePhotoPickerRegistrar = engineBridge.pluginRegistry.registrar(
forPlugin: "BuzzInlinePhotoPicker"
) {
inlinePhotoPickerRegistrar.register(
InlinePhotoPickerFactory(
messenger: messenger,
parentViewController: inlinePhotoPickerRegistrar.viewController
),
withId: "buzz/inline_photo_picker"
)
}
let nativeAttachmentRegistrar = engineBridge.pluginRegistry.registrar(
forPlugin: "BuzzNativeAttachmentPopover"
)
nativeAttachmentPopoverCoordinator = NativeAttachmentPopoverCoordinator(
messenger: messenger,
parentViewController: nativeAttachmentRegistrar?.viewController
)
}
private static func handleQrScannerMethodCall(
@@ -231,10 +269,12 @@ import UserNotifications
let sourceURL = URL(fileURLWithPath: sourcePath)
let asset = AVURLAsset(url: sourceURL)
guard let exportSession = AVAssetExportSession(
asset: asset,
presetName: AVAssetExportPresetPassthrough
) else {
guard
let exportSession = AVAssetExportSession(
asset: asset,
presetName: AVAssetExportPresetPassthrough
)
else {
result(
FlutterError(
code: "transcode_failed",
@@ -259,7 +299,8 @@ import UserNotifications
case .completed:
result(outputURL.path)
default:
let errorMessage = exportSession.error?.localizedDescription
let errorMessage =
exportSession.error?.localizedDescription
?? "Video transcoding failed with status \(exportSession.status.rawValue)."
result(
FlutterError(
+2
View File
@@ -49,6 +49,8 @@
<string>Buzz needs photo library access so you can attach images to messages.</string>
<key>NSPhotoLibraryAddUsageDescription</key>
<string>Buzz needs permission to save images to your photo library.</string>
<key>PHPhotoLibraryPreventAutomaticLimitedAccessAlert</key>
<true/>
<key>UIApplicationSceneManifest</key>
<dict>
<key>UIApplicationSupportsMultipleScenes</key>
+235
View File
@@ -0,0 +1,235 @@
import Flutter
import PhotosUI
import UIKit
import UniformTypeIdentifiers
final class InlinePhotoPickerFactory: NSObject, FlutterPlatformViewFactory {
private let messenger: FlutterBinaryMessenger
private weak var parentViewController: UIViewController?
init(
messenger: FlutterBinaryMessenger,
parentViewController: UIViewController?
) {
self.messenger = messenger
self.parentViewController = parentViewController
super.init()
}
func create(
withFrame frame: CGRect,
viewIdentifier viewId: Int64,
arguments args: Any?
) -> FlutterPlatformView {
InlinePhotoPickerPlatformView(
frame: frame,
viewIdentifier: viewId,
messenger: messenger,
parentViewController: parentViewController
)
}
}
final class InlinePhotoPickerPlatformView: NSObject, FlutterPlatformView {
private let containerView: UIView
private let channel: FlutterMethodChannel
private weak var parentViewController: UIViewController?
private var pickerViewController: PHPickerViewController?
private var selectionGeneration = 0
private var selectionTask: Task<Void, Never>?
private var selectedTemporaryPaths: [String] = []
init(
frame: CGRect,
viewIdentifier viewId: Int64,
messenger: FlutterBinaryMessenger,
parentViewController: UIViewController?
) {
containerView = UIView(frame: frame)
channel = FlutterMethodChannel(
name: "buzz/inline_photo_picker/\(viewId)",
binaryMessenger: messenger
)
self.parentViewController = parentViewController
super.init()
channel.setMethodCallHandler { [weak self] call, result in
guard call.method == "claimSelection" else {
result(FlutterMethodNotImplemented)
return
}
let paths = call.arguments as? [String] ?? []
guard let self, paths == self.selectedTemporaryPaths else {
result(false)
return
}
self.selectedTemporaryPaths = []
result(true)
}
containerView.backgroundColor = .clear
if #available(iOS 17.0, *) {
installPicker()
}
}
deinit {
selectionTask?.cancel()
Self.removeTemporaryFiles(selectedTemporaryPaths)
channel.setMethodCallHandler(nil)
pickerViewController?.willMove(toParent: nil)
pickerViewController?.view.removeFromSuperview()
pickerViewController?.removeFromParent()
}
func view() -> UIView {
containerView
}
@available(iOS 17.0, *)
private func installPicker() {
var configuration = PHPickerConfiguration(photoLibrary: .shared())
configuration.filter = .images
configuration.selectionLimit = 0
configuration.selection = .continuousAndOrdered
configuration.preferredAssetRepresentationMode = .compatible
configuration.disabledCapabilities = [
.search,
.stagingArea,
.collectionNavigation,
.selectionActions,
]
configuration.edgesWithoutContentMargins = .all
let picker = PHPickerViewController(configuration: configuration)
picker.delegate = self
picker.view.backgroundColor = .clear
picker.view.translatesAutoresizingMaskIntoConstraints = false
if let parentViewController {
parentViewController.addChild(picker)
}
containerView.addSubview(picker.view)
NSLayoutConstraint.activate([
picker.view.leadingAnchor.constraint(equalTo: containerView.leadingAnchor),
picker.view.trailingAnchor.constraint(equalTo: containerView.trailingAnchor),
picker.view.topAnchor.constraint(equalTo: containerView.topAnchor),
picker.view.bottomAnchor.constraint(equalTo: containerView.bottomAnchor),
])
if parentViewController != nil {
picker.didMove(toParent: parentViewController)
}
pickerViewController = picker
}
private func exportPickerResult(_ result: PHPickerResult) async throws -> String {
let provider = result.itemProvider
guard
let typeIdentifier = provider.registeredTypeIdentifiers.first(where: {
guard let type = UTType($0) else { return false }
return type.conforms(to: .image)
})
else {
throw InlinePhotoPickerError.unsupportedImage
}
return try await withCheckedThrowingContinuation { continuation in
provider.loadFileRepresentation(forTypeIdentifier: typeIdentifier) {
sourceURL,
error in
if let error {
continuation.resume(throwing: error)
return
}
guard let sourceURL else {
continuation.resume(throwing: InlinePhotoPickerError.missingFile)
return
}
do {
let fileExtension =
sourceURL.pathExtension.isEmpty
? (UTType(typeIdentifier)?.preferredFilenameExtension ?? "jpg")
: sourceURL.pathExtension
let destinationURL = FileManager.default.temporaryDirectory
.appendingPathComponent(UUID().uuidString)
.appendingPathExtension(fileExtension)
try FileManager.default.copyItem(
at: sourceURL,
to: destinationURL
)
continuation.resume(returning: destinationURL.path)
} catch {
continuation.resume(throwing: error)
}
}
}
}
private static func removeTemporaryFiles(_ paths: [String]) {
for path in paths where !path.isEmpty {
try? FileManager.default.removeItem(atPath: path)
}
}
}
extension InlinePhotoPickerPlatformView: PHPickerViewControllerDelegate {
func picker(
_ picker: PHPickerViewController,
didFinishPicking results: [PHPickerResult]
) {
selectionGeneration += 1
let generation = selectionGeneration
selectionTask?.cancel()
selectionTask = nil
Self.removeTemporaryFiles(selectedTemporaryPaths)
selectedTemporaryPaths = []
channel.invokeMethod(
"selectionCountChanged",
arguments: results.count
)
guard !results.isEmpty else {
channel.invokeMethod("selectionDidChange", arguments: [String]())
return
}
selectionTask = Task { [weak self] in
guard let self else { return }
var paths: [String] = []
do {
for result in results {
try Task.checkCancellation()
paths.append(try await self.exportPickerResult(result))
}
try Task.checkCancellation()
await MainActor.run {
guard generation == self.selectionGeneration else {
Self.removeTemporaryFiles(paths)
return
}
self.selectedTemporaryPaths = paths
self.selectionTask = nil
self.channel.invokeMethod("selectionDidChange", arguments: paths)
}
} catch is CancellationError {
Self.removeTemporaryFiles(paths)
} catch {
Self.removeTemporaryFiles(paths)
await MainActor.run {
guard generation == self.selectionGeneration else { return }
self.selectionTask = nil
self.channel.invokeMethod(
"didFail",
arguments: "Unable to prepare the selected photos."
)
}
}
}
}
}
private enum InlinePhotoPickerError: Error {
case missingFile
case unsupportedImage
}
@@ -0,0 +1,969 @@
import AVFoundation
import Flutter
import PhotosUI
import UIKit
import UniformTypeIdentifiers
@available(iOS 26.0, *)
final class NativeAttachmentPopoverViewController:
UIViewController,
PHPickerViewControllerDelegate,
UIPopoverPresentationControllerDelegate,
AVCapturePhotoCaptureDelegate
{
private enum Surface {
case menu
case photos
case camera
}
private let channel: FlutterMethodChannel
private let expandedWidth: CGFloat
private let menuSize = CGSize(width: 176, height: 208)
private let expandedHeight: CGFloat = 372
private let expandedHorizontalOffset: CGFloat = 10
private let expandedVerticalOffset: CGFloat = 40
private let contentHost = UIView()
private let cameraSession = AVCaptureSession()
private let cameraOutput = AVCapturePhotoOutput()
private let cameraQueue = DispatchQueue(
label: "buzz.native-attachment-camera"
)
private var surface = Surface.menu
private var visibleContentView: UIView?
private var photoPickerViewController: PHPickerViewController?
private var cameraPreviewLayer: AVCaptureVideoPreviewLayer?
private weak var cameraPreviewView: UIView?
private weak var photoActionButton: UIButton?
private weak var cameraCaptureButton: UIButton?
private var cameraDevice: AVCaptureDevice?
private var cameraRotationCoordinator: AVCaptureDevice.RotationCoordinator?
private var cameraRotationObservation: NSKeyValueObservation?
private var selectionGeneration = 0
private var selectionTask: Task<Void, Never>?
private var selectedPhotoPaths: [String] = []
private var cameraConfigured = false
private var cameraIsStarting = false
private var cameraStartupGeneration = 0
private var cameraIsCapturing = false
private var activeCameraCaptureID: Int64?
private var isFinishing = false
private var didNotifyDismissal = false
var onDismiss: (() -> Void)?
init(channel: FlutterMethodChannel, expandedWidth: CGFloat) {
self.channel = channel
self.expandedWidth = expandedWidth
super.init(nibName: nil, bundle: nil)
preferredContentSize = menuSize
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = .clear
view.layer.cornerRadius = 22
view.layer.cornerCurve = .continuous
view.clipsToBounds = true
let glassEffect = UIGlassEffect(style: .regular)
glassEffect.isInteractive = true
let glassView = UIVisualEffectView(effect: glassEffect)
glassView.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(glassView)
contentHost.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(contentHost)
NSLayoutConstraint.activate([
glassView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
glassView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
glassView.topAnchor.constraint(equalTo: view.topAnchor),
glassView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
contentHost.leadingAnchor.constraint(equalTo: view.leadingAnchor),
contentHost.trailingAnchor.constraint(equalTo: view.trailingAnchor),
contentHost.topAnchor.constraint(equalTo: view.topAnchor),
contentHost.bottomAnchor.constraint(equalTo: view.bottomAnchor),
])
let menu = makeMenuView()
installContent(menu)
}
override func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews()
cameraPreviewLayer?.frame = cameraPreviewView?.bounds ?? .zero
}
override func viewWillDisappear(_ animated: Bool) {
super.viewWillDisappear(animated)
invalidateCameraCapture()
stopCamera()
}
func adaptivePresentationStyle(
for controller: UIPresentationController
) -> UIModalPresentationStyle {
.none
}
func presentationControllerDidDismiss(
_ presentationController: UIPresentationController
) {
notifyDismissalIfNeeded()
}
private func makeMenuView() -> UIView {
let container = UIView()
container.translatesAutoresizingMaskIntoConstraints = false
let stack = UIStackView()
stack.axis = .vertical
stack.distribution = .fillEqually
stack.translatesAutoresizingMaskIntoConstraints = false
container.addSubview(stack)
NSLayoutConstraint.activate([
stack.leadingAnchor.constraint(equalTo: container.leadingAnchor),
stack.trailingAnchor.constraint(equalTo: container.trailingAnchor),
stack.topAnchor.constraint(equalTo: container.topAnchor, constant: 8),
stack.bottomAnchor.constraint(equalTo: container.bottomAnchor, constant: -8),
])
stack.addArrangedSubview(
makeNativeAttachmentMenuButton(
title: "Camera",
symbol: "camera",
action: UIAction { [weak self] _ in self?.showCamera() }
)
)
stack.addArrangedSubview(
makeNativeAttachmentMenuButton(
title: "Photos",
symbol: "photo.on.rectangle.angled",
action: UIAction { [weak self] _ in self?.showPhotos() }
)
)
stack.addArrangedSubview(
makeNativeAttachmentMenuButton(
title: "Video",
symbol: "video",
action: UIAction { [weak self] _ in
self?.finish(method: "pickVideo")
}
)
)
stack.addArrangedSubview(
makeNativeAttachmentMenuButton(
title: "Files",
symbol: "doc",
action: UIAction { [weak self] _ in
self?.finish(method: "pickFiles")
}
)
)
return container
}
private func showPhotos() {
guard surface != .photos else { return }
stopCamera()
var configuration = PHPickerConfiguration(photoLibrary: .shared())
configuration.filter = .images
configuration.selectionLimit = 0
configuration.selection = .continuousAndOrdered
configuration.preferredAssetRepresentationMode = .compatible
configuration.disabledCapabilities = [
.search,
.stagingArea,
.collectionNavigation,
.selectionActions,
]
configuration.edgesWithoutContentMargins = .all
let picker = PHPickerViewController(configuration: configuration)
picker.delegate = self
picker.view.backgroundColor = .clear
let container = UIView()
container.backgroundColor = .clear
addChild(picker)
picker.view.translatesAutoresizingMaskIntoConstraints = false
container.addSubview(picker.view)
NSLayoutConstraint.activate([
picker.view.leadingAnchor.constraint(equalTo: container.leadingAnchor),
picker.view.trailingAnchor.constraint(equalTo: container.trailingAnchor),
picker.view.topAnchor.constraint(
equalTo: container.topAnchor,
constant: -8
),
picker.view.bottomAnchor.constraint(equalTo: container.bottomAnchor),
])
picker.didMove(toParent: self)
photoPickerViewController = picker
let backButton = makeGlassControl(
title: nil,
symbol: "chevron.left",
accessibilityLabel: "Back to attachment options",
action: UIAction { [weak self] _ in self?.showMenu() }
)
let actionButton = makeGlassControl(
title: "All Photos",
symbol: nil,
accessibilityLabel: "All Photos",
prominent: true,
action: UIAction { [weak self] _ in self?.performPhotoAction() }
)
photoActionButton = actionButton
addBottomControls(
to: container,
leading: backButton,
trailing: actionButton
)
transition(to: .photos, content: container)
}
private func showCamera() {
guard surface != .camera else { return }
removePhotoPicker()
let container = UIView()
container.backgroundColor = .black
let preview = UIView()
preview.backgroundColor = .black
preview.translatesAutoresizingMaskIntoConstraints = false
container.addSubview(preview)
NSLayoutConstraint.activate([
preview.leadingAnchor.constraint(equalTo: container.leadingAnchor),
preview.trailingAnchor.constraint(equalTo: container.trailingAnchor),
preview.topAnchor.constraint(equalTo: container.topAnchor),
preview.bottomAnchor.constraint(equalTo: container.bottomAnchor),
])
cameraPreviewView = preview
let placeholder = UIActivityIndicatorView(style: .large)
placeholder.color = .white
placeholder.startAnimating()
placeholder.translatesAutoresizingMaskIntoConstraints = false
preview.addSubview(placeholder)
NSLayoutConstraint.activate([
placeholder.centerXAnchor.constraint(equalTo: preview.centerXAnchor),
placeholder.centerYAnchor.constraint(equalTo: preview.centerYAnchor),
])
placeholder.tag = 7001
let backButton = makeGlassControl(
title: nil,
symbol: "chevron.left",
accessibilityLabel: "Back to attachment options",
action: UIAction { [weak self] _ in self?.showMenu() }
)
let captureButton = makeCameraCaptureButton()
cameraCaptureButton = captureButton
addBottomControls(
to: container,
leading: backButton,
center: captureButton
)
transition(to: .camera, content: container)
startCamera()
}
private func showMenu() {
guard surface != .menu else { return }
invalidateCameraCapture()
selectionGeneration += 1
selectionTask?.cancel()
selectionTask = nil
Self.removeTemporaryFiles(selectedPhotoPaths)
selectedPhotoPaths = []
stopCamera()
let menu = makeMenuView()
transition(to: .menu, content: menu) { [weak self] in
self?.removePhotoPicker()
}
}
private func installContent(_ content: UIView) {
content.translatesAutoresizingMaskIntoConstraints = false
contentHost.addSubview(content)
NSLayoutConstraint.activate([
content.leadingAnchor.constraint(equalTo: contentHost.leadingAnchor),
content.trailingAnchor.constraint(equalTo: contentHost.trailingAnchor),
content.topAnchor.constraint(equalTo: contentHost.topAnchor),
content.bottomAnchor.constraint(equalTo: contentHost.bottomAnchor),
])
visibleContentView = content
}
private func transition(
to nextSurface: Surface,
content nextView: UIView,
completion: (() -> Void)? = nil
) {
let previousView = visibleContentView
let isExpanding = nextSurface != .menu
let targetSize =
isExpanding
? CGSize(width: expandedWidth, height: expandedHeight)
: menuSize
nextView.translatesAutoresizingMaskIntoConstraints = false
contentHost.addSubview(nextView)
NSLayoutConstraint.activate([
nextView.leadingAnchor.constraint(equalTo: contentHost.leadingAnchor),
nextView.trailingAnchor.constraint(equalTo: contentHost.trailingAnchor),
nextView.topAnchor.constraint(equalTo: contentHost.topAnchor),
nextView.bottomAnchor.constraint(equalTo: contentHost.bottomAnchor),
])
contentHost.layoutIfNeeded()
let direction: CGFloat = isExpanding ? 34 : -34
nextView.alpha = UIAccessibility.isReduceMotionEnabled ? 0 : 0.01
nextView.transform =
UIAccessibility.isReduceMotionEnabled
? .identity
: CGAffineTransform(translationX: direction, y: 0).scaledBy(
x: 0.97,
y: 0.97
)
visibleContentView = nextView
surface = nextSurface
let duration = UIAccessibility.isReduceMotionEnabled ? 0.16 : 0.36
UIView.animate(
withDuration: duration,
delay: 0,
usingSpringWithDamping: 0.86,
initialSpringVelocity: 0.18,
options: [.beginFromCurrentState, .allowUserInteraction]
) {
self.preferredContentSize = targetSize
if let popover = self.popoverPresentationController,
let sourceView = popover.sourceView
{
popover.sourceRect = sourceView.bounds.offsetBy(
dx: isExpanding ? self.expandedHorizontalOffset : 0,
dy: isExpanding ? self.expandedVerticalOffset : 0
)
}
previousView?.alpha = 0
previousView?.transform =
UIAccessibility.isReduceMotionEnabled
? .identity
: CGAffineTransform(translationX: -direction, y: 0).scaledBy(
x: 0.97,
y: 0.97
)
nextView.alpha = 1
nextView.transform = .identity
self.view.layoutIfNeeded()
} completion: { _ in
previousView?.removeFromSuperview()
previousView?.transform = .identity
completion?()
}
}
private func makeGlassControl(
title: String?,
symbol: String?,
accessibilityLabel: String,
prominent: Bool = false,
action: UIAction
) -> UIButton {
var configuration =
prominent
? UIButton.Configuration.prominentGlass()
: UIButton.Configuration.glass()
configuration.title = title
if prominent {
configuration.baseBackgroundColor = .black
}
if let symbol {
configuration.image = UIImage(systemName: symbol)
}
configuration.imagePadding = 8
configuration.baseForegroundColor = .white
configuration.contentInsets = NSDirectionalEdgeInsets(
top: 11,
leading: 15,
bottom: 11,
trailing: 15
)
let button = UIButton(configuration: configuration, primaryAction: action)
button.accessibilityLabel = accessibilityLabel
return button
}
private func makeCameraCaptureButton() -> UIButton {
let button = UIButton(
primaryAction: UIAction { [weak self] _ in self?.capturePhoto() }
)
button.accessibilityLabel = "Take photo"
button.translatesAutoresizingMaskIntoConstraints = false
button.backgroundColor = UIColor.white.withAlphaComponent(0.22)
button.layer.cornerRadius = 34
button.layer.borderColor = UIColor.white.cgColor
button.layer.borderWidth = 3
let inner = UIView()
inner.isUserInteractionEnabled = false
inner.translatesAutoresizingMaskIntoConstraints = false
inner.backgroundColor = .white
inner.layer.cornerRadius = 25
button.addSubview(inner)
NSLayoutConstraint.activate([
button.widthAnchor.constraint(equalToConstant: 68),
button.heightAnchor.constraint(equalToConstant: 68),
inner.widthAnchor.constraint(equalToConstant: 50),
inner.heightAnchor.constraint(equalToConstant: 50),
inner.centerXAnchor.constraint(equalTo: button.centerXAnchor),
inner.centerYAnchor.constraint(equalTo: button.centerYAnchor),
])
return button
}
private func addBottomControls(
to container: UIView,
leading: UIButton,
center: UIButton? = nil,
trailing: UIButton? = nil
) {
leading.translatesAutoresizingMaskIntoConstraints = false
container.addSubview(leading)
var constraints = [
leading.leadingAnchor.constraint(
equalTo: container.leadingAnchor,
constant: 12
),
leading.bottomAnchor.constraint(
equalTo: container.bottomAnchor,
constant: -12
),
leading.heightAnchor.constraint(greaterThanOrEqualToConstant: 44),
]
if let center {
center.translatesAutoresizingMaskIntoConstraints = false
container.addSubview(center)
constraints.append(
center.centerXAnchor.constraint(equalTo: container.centerXAnchor)
)
constraints.append(
center.bottomAnchor.constraint(
equalTo: container.bottomAnchor,
constant: -12
)
)
}
if let trailing {
trailing.translatesAutoresizingMaskIntoConstraints = false
container.addSubview(trailing)
constraints.append(
trailing.trailingAnchor.constraint(
equalTo: container.trailingAnchor,
constant: -12
)
)
constraints.append(
trailing.bottomAnchor.constraint(
equalTo: container.bottomAnchor,
constant: -12
)
)
constraints.append(
trailing.heightAnchor.constraint(greaterThanOrEqualToConstant: 44)
)
}
NSLayoutConstraint.activate(constraints)
}
func picker(
_ picker: PHPickerViewController,
didFinishPicking results: [PHPickerResult]
) {
selectionGeneration += 1
let generation = selectionGeneration
selectionTask?.cancel()
selectionTask = nil
Self.removeTemporaryFiles(selectedPhotoPaths)
selectedPhotoPaths = []
updatePhotoAction(count: results.count, preparing: !results.isEmpty)
guard !results.isEmpty else { return }
selectionTask = Task { [weak self] in
guard let self else { return }
var paths: [String] = []
do {
for result in results {
try Task.checkCancellation()
paths.append(try await self.exportPickerResult(result))
}
try Task.checkCancellation()
await MainActor.run {
guard generation == self.selectionGeneration else {
Self.removeTemporaryFiles(paths)
return
}
self.selectedPhotoPaths = paths
self.selectionTask = nil
self.updatePhotoAction(count: paths.count, preparing: false)
}
} catch is CancellationError {
Self.removeTemporaryFiles(paths)
} catch {
Self.removeTemporaryFiles(paths)
await MainActor.run {
guard generation == self.selectionGeneration else { return }
self.selectionTask = nil
self.selectedPhotoPaths = []
self.updatePhotoAction(count: 0, preparing: false)
self.showError("Unable to prepare the selected photos.")
}
}
}
}
private func updatePhotoAction(count: Int, preparing: Bool) {
let title: String
if preparing {
title = "Preparing…"
} else if count == 0 {
title = "All Photos"
} else {
title = "Add \(count) \(count == 1 ? "photo" : "photos")"
}
guard let button = photoActionButton else { return }
let canInteract = !preparing
button.configuration?.title = title
button.accessibilityLabel = title
button.isUserInteractionEnabled = canInteract
if canInteract {
button.accessibilityTraits.remove(.notEnabled)
} else {
button.accessibilityTraits.insert(.notEnabled)
}
}
private func performPhotoAction() {
if selectedPhotoPaths.isEmpty {
finish(method: "pickAllPhotos")
} else {
let paths = selectedPhotoPaths
selectedPhotoPaths = []
finish(
method: "photosSelected",
arguments: paths,
temporaryPaths: paths
)
}
}
private func exportPickerResult(_ result: PHPickerResult) async throws
-> String
{
let provider = result.itemProvider
guard
let typeIdentifier = provider.registeredTypeIdentifiers.first(where: {
guard let type = UTType($0) else { return false }
return type.conforms(to: .image)
})
else {
throw NativeAttachmentPopoverError.unsupportedImage
}
return try await withCheckedThrowingContinuation { continuation in
provider.loadFileRepresentation(forTypeIdentifier: typeIdentifier) {
sourceURL,
error in
if let error {
continuation.resume(throwing: error)
return
}
guard let sourceURL else {
continuation.resume(
throwing: NativeAttachmentPopoverError.missingFile
)
return
}
do {
let fileExtension =
sourceURL.pathExtension.isEmpty
? (UTType(typeIdentifier)?.preferredFilenameExtension ?? "jpg")
: sourceURL.pathExtension
let destinationURL = FileManager.default.temporaryDirectory
.appendingPathComponent(UUID().uuidString)
.appendingPathExtension(fileExtension)
try FileManager.default.copyItem(
at: sourceURL,
to: destinationURL
)
continuation.resume(returning: destinationURL.path)
} catch {
continuation.resume(throwing: error)
}
}
}
}
private static func removeTemporaryFiles(_ paths: [String]) {
for path in paths where !path.isEmpty {
try? FileManager.default.removeItem(atPath: path)
}
}
private func startCamera() {
guard !cameraIsStarting else { return }
cameraIsStarting = true
cameraStartupGeneration += 1
let startupGeneration = cameraStartupGeneration
switch AVCaptureDevice.authorizationStatus(for: .video) {
case .authorized:
configureAndStartCamera(startupGeneration: startupGeneration)
case .notDetermined:
AVCaptureDevice.requestAccess(for: .video) { [weak self] granted in
DispatchQueue.main.async {
guard
let self,
self.cameraStartupIsCurrent(startupGeneration)
else { return }
if granted {
self.configureAndStartCamera(
startupGeneration: startupGeneration
)
} else {
self.cameraIsStarting = false
self.showCameraUnavailable(
"Camera access is needed to take a photo."
)
}
}
}
default:
cameraIsStarting = false
showCameraUnavailable("Camera access is needed to take a photo.")
}
}
private func configureAndStartCamera(startupGeneration: Int) {
cameraQueue.async { [weak self] in
guard let self else { return }
do {
if !self.cameraConfigured {
self.cameraSession.beginConfiguration()
defer { self.cameraSession.commitConfiguration() }
self.cameraSession.sessionPreset = .photo
guard
let device = AVCaptureDevice.default(
.builtInWideAngleCamera,
for: .video,
position: .back
)
else {
throw NativeAttachmentPopoverError.cameraUnavailable
}
let input = try AVCaptureDeviceInput(device: device)
guard self.cameraSession.canAddInput(input) else {
throw NativeAttachmentPopoverError.cameraUnavailable
}
self.cameraSession.addInput(input)
self.cameraDevice = device
guard self.cameraSession.canAddOutput(self.cameraOutput) else {
throw NativeAttachmentPopoverError.cameraUnavailable
}
self.cameraSession.addOutput(self.cameraOutput)
self.cameraConfigured = true
}
if !self.cameraSession.isRunning {
self.cameraSession.startRunning()
}
DispatchQueue.main.async { [weak self] in
guard
let self,
self.cameraStartupIsCurrent(startupGeneration)
else { return }
self.cameraIsStarting = false
self.installCameraPreview()
}
} catch {
if self.cameraSession.isRunning {
self.cameraSession.stopRunning()
}
DispatchQueue.main.async { [weak self] in
guard
let self,
self.cameraStartupIsCurrent(startupGeneration)
else { return }
self.cameraIsStarting = false
self.showCameraUnavailable("Camera isn’t available here.")
}
}
}
}
private func cameraStartupIsCurrent(_ generation: Int) -> Bool {
generation == cameraStartupGeneration && surface == .camera && !isFinishing
}
private func installCameraPreview() {
guard surface == .camera, let previewView = cameraPreviewView else { return }
previewView.viewWithTag(7001)?.removeFromSuperview()
cameraPreviewLayer?.removeFromSuperlayer()
let layer = AVCaptureVideoPreviewLayer(session: cameraSession)
layer.videoGravity = .resizeAspectFill
layer.frame = previewView.bounds
previewView.layer.insertSublayer(layer, at: 0)
cameraPreviewLayer = layer
if let cameraDevice {
let coordinator = AVCaptureDevice.RotationCoordinator(
device: cameraDevice,
previewLayer: layer
)
cameraRotationCoordinator = coordinator
cameraRotationObservation = coordinator.observe(
\.videoRotationAngleForHorizonLevelPreview,
options: [.initial, .new]
) { [weak layer] coordinator, _ in
guard let connection = layer?.connection else { return }
let angle = coordinator.videoRotationAngleForHorizonLevelPreview
guard connection.isVideoRotationAngleSupported(angle) else { return }
connection.videoRotationAngle = angle
}
}
}
private func stopCamera() {
cameraStartupGeneration += 1
cameraIsStarting = false
cameraRotationObservation?.invalidate()
cameraRotationObservation = nil
cameraRotationCoordinator = nil
cameraPreviewLayer?.removeFromSuperlayer()
cameraPreviewLayer = nil
cameraQueue.async { [weak self] in
guard let self, self.cameraSession.isRunning else { return }
self.cameraSession.stopRunning()
}
}
private func capturePhoto() {
guard !cameraIsCapturing, cameraSession.isRunning else { return }
cameraIsCapturing = true
cameraCaptureButton?.isEnabled = false
cameraCaptureButton?.transform = CGAffineTransform(
scaleX: 0.92,
y: 0.92
)
UIView.animate(
withDuration: 0.12,
delay: 0,
options: [.beginFromCurrentState, .allowUserInteraction]
) {
self.cameraCaptureButton?.transform = .identity
}
if let connection = cameraOutput.connection(with: .video),
let cameraRotationCoordinator
{
let angle =
cameraRotationCoordinator.videoRotationAngleForHorizonLevelCapture
if connection.isVideoRotationAngleSupported(angle) {
connection.videoRotationAngle = angle
}
}
let settings = AVCapturePhotoSettings()
activeCameraCaptureID = settings.uniqueID
cameraOutput.capturePhoto(with: settings, delegate: self)
}
func photoOutput(
_ output: AVCapturePhotoOutput,
didFinishProcessingPhoto photo: AVCapturePhoto,
error: Error?
) {
guard error == nil, let data = photo.fileDataRepresentation() else {
DispatchQueue.main.async { [weak self] in
self?.completeCameraCapture(
captureID: photo.resolvedSettings.uniqueID,
path: nil,
errorMessage: "Unable to capture the photo."
)
}
return
}
DispatchQueue.global(qos: .userInitiated).async {
do {
let destinationURL = FileManager.default.temporaryDirectory
.appendingPathComponent(UUID().uuidString)
.appendingPathExtension("jpg")
try data.write(to: destinationURL, options: .atomic)
DispatchQueue.main.async { [weak self] in
guard let self else {
Self.removeTemporaryFiles([destinationURL.path])
return
}
self.completeCameraCapture(
captureID: photo.resolvedSettings.uniqueID,
path: destinationURL.path,
errorMessage: nil
)
}
} catch {
DispatchQueue.main.async { [weak self] in
self?.completeCameraCapture(
captureID: photo.resolvedSettings.uniqueID,
path: nil,
errorMessage: "Unable to prepare the captured photo."
)
}
}
}
}
@MainActor
private func completeCameraCapture(
captureID: Int64,
path: String?,
errorMessage: String?
) {
guard
captureID == activeCameraCaptureID, surface == .camera, !isFinishing
else {
if let path { Self.removeTemporaryFiles([path]) }
return
}
activeCameraCaptureID = nil
cameraIsCapturing = false
cameraCaptureButton?.isEnabled = true
if let path {
finish(
method: "cameraCaptured",
arguments: path,
temporaryPaths: [path]
)
} else if !isFinishing, let errorMessage {
showError(errorMessage)
}
}
private func invalidateCameraCapture() {
activeCameraCaptureID = nil
cameraIsCapturing = false
cameraCaptureButton?.isEnabled = true
}
private func showCameraUnavailable(_ message: String) {
guard let previewView = cameraPreviewView else { return }
previewView.viewWithTag(7001)?.removeFromSuperview()
let label = UILabel()
label.text = message
label.textColor = .white
label.textAlignment = .center
label.numberOfLines = 0
label.translatesAutoresizingMaskIntoConstraints = false
previewView.addSubview(label)
NSLayoutConstraint.activate([
label.centerXAnchor.constraint(equalTo: previewView.centerXAnchor),
label.centerYAnchor.constraint(equalTo: previewView.centerYAnchor),
label.leadingAnchor.constraint(
greaterThanOrEqualTo: previewView.leadingAnchor,
constant: 28
),
label.trailingAnchor.constraint(
lessThanOrEqualTo: previewView.trailingAnchor,
constant: -28
),
])
}
private func showError(_ message: String) {
let alert = UIAlertController(
title: nil,
message: message,
preferredStyle: .alert
)
alert.addAction(UIAlertAction(title: "OK", style: .default))
present(alert, animated: true)
}
private func removePhotoPicker() {
guard let picker = photoPickerViewController else { return }
picker.willMove(toParent: nil)
picker.view.removeFromSuperview()
picker.removeFromParent()
photoPickerViewController = nil
}
private func finish(
method: String,
arguments: Any? = nil,
temporaryPaths: [String] = []
) {
guard !isFinishing else {
Self.removeTemporaryFiles(temporaryPaths)
return
}
isFinishing = true
view.isUserInteractionEnabled = false
selectionGeneration += 1
selectionTask?.cancel()
selectionTask = nil
stopCamera()
dismiss(animated: true) { [weak self] in
guard let self else {
Self.removeTemporaryFiles(temporaryPaths)
return
}
self.channel.invokeMethod(method, arguments: arguments) { _ in
Self.removeTemporaryFiles(temporaryPaths)
}
self.notifyDismissalIfNeeded()
}
}
func dismissAndNotify() {
guard !isFinishing else { return }
isFinishing = true
view.isUserInteractionEnabled = false
selectionGeneration += 1
selectionTask?.cancel()
selectionTask = nil
Self.removeTemporaryFiles(selectedPhotoPaths)
selectedPhotoPaths = []
stopCamera()
dismiss(animated: true) { [weak self] in
self?.notifyDismissalIfNeeded()
}
}
private func notifyDismissalIfNeeded() {
selectionGeneration += 1
selectionTask?.cancel()
selectionTask = nil
Self.removeTemporaryFiles(selectedPhotoPaths)
selectedPhotoPaths = []
guard !didNotifyDismissal else { return }
didNotifyDismissal = true
onDismiss?()
}
}
private enum NativeAttachmentPopoverError: Error {
case cameraUnavailable
case missingFile
case unsupportedImage
}
@@ -0,0 +1,228 @@
import Flutter
import UIKit
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
let convertedRect: CGRect
if let window = sourceView?.window {
convertedRect = sourceView?.convert(sourceRect, from: window) ?? sourceRect
} else {
convertedRect = sourceRect
}
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
}
}
func makeNativeAttachmentMenuButton(
title: String,
symbol: String,
action: UIAction
) -> UIButton {
let button = UIButton(primaryAction: 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 = .preferredFont(forTextStyle: .body)
titleLabel.adjustsFontForContentSizeCategory = true
titleLabel.textAlignment = .left
titleLabel.translatesAutoresizingMaskIntoConstraints = false
button.addSubview(iconView)
button.addSubview(titleLabel)
NSLayoutConstraint.activate([
iconView.leadingAnchor.constraint(equalTo: button.leadingAnchor, constant: 14),
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: -14
),
titleLabel.centerYAnchor.constraint(equalTo: button.centerYAnchor),
])
button.configurationUpdateHandler = { button in
button.alpha = button.isHighlighted ? 0.62 : 1
}
return button
}