mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
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:
@@ -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 */,
|
||||
);
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user