9dfa06ffee
Docker image / Build (linux/amd64) (push) Has been cancelled
Docker image / Build (linux/arm64) (push) Has been cancelled
Docker image / Merge release multi-arch manifest (push) Has been cancelled
Docker image / Merge debug multi-arch manifest (push) Has been cancelled
Docker image / Build public push gateway (linux/amd64) (push) Has been cancelled
Docker image / Build public push gateway (linux/arm64) (push) Has been cancelled
Docker image / Publish public push gateway image (push) Has been cancelled
Sprig image / Build (linux/amd64) (push) Has been cancelled
Sprig image / Build (linux/arm64) (push) Has been cancelled
Sprig image / Merge multi-arch manifest (push) Has been cancelled
Harbor Buzz Orchestra / Python tests and lint (push) Has been cancelled
CI / Detect Changed Paths (push) Has been cancelled
CI / Rust Lint (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Desktop Core (push) Has been cancelled
CI / Desktop Smoke E2E (1) (push) Has been cancelled
CI / Desktop Smoke E2E (2) (push) Has been cancelled
CI / Desktop Smoke E2E (3) (push) Has been cancelled
CI / Desktop Smoke E2E (4) (push) Has been cancelled
CI / Desktop (push) Has been cancelled
CI / Desktop E2E Relay (push) Has been cancelled
CI / Desktop E2E Integration (1/2) (push) Has been cancelled
CI / Desktop E2E Integration (2/2) (push) Has been cancelled
CI / Desktop E2E Integration (push) Has been cancelled
CI / Backend Integration (relay e2e) (push) Has been cancelled
CI / Relay E2E (push) Has been cancelled
CI / Web (push) Has been cancelled
CI / Mobile (push) Has been cancelled
CI / Security (push) Has been cancelled
CI / Dead Token Reference Guard (push) Has been cancelled
CI / Server Cross-Compile (aarch64-unknown-linux-musl) (push) Has been cancelled
CI / Server Cross-Compile (x86_64-unknown-linux-musl) (push) Has been cancelled
CI / Windows Rust (x86_64-pc-windows-msvc) (push) Has been cancelled
CI / Desktop Build (macOS) (push) Has been cancelled
helm chart / lint + unittest + render matrix (push) Has been cancelled
helm chart / install on kind (gated) (push) Has been cancelled
helm chart / publish chart to GHCR (push) Has been cancelled
Mesh Lifecycle / Relay-Driven Mesh Lifecycle Smoke (push) Has been cancelled
Sprig / Build (aarch64-unknown-linux-musl) (push) Has been cancelled
Sprig / Build (x86_64-unknown-linux-musl) (push) Has been cancelled
Sprig / Publish rolling release (push) Has been cancelled
Sprig / Publish tagged release (push) Has been cancelled
Signed-off-by: cls_宁波本机 <908705107@qq.com>
1097 lines
34 KiB
Swift
1097 lines
34 KiB
Swift
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 maximumMenuHeight: CGFloat
|
||
private let expandedHeight = NativeAttachmentMenuLayout.maximumHeight
|
||
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
|
||
private var keyboardDismissalOffset: CGFloat = 0
|
||
private var menuStackHeightConstraint: NSLayoutConstraint?
|
||
|
||
var onDismiss: (() -> Void)?
|
||
|
||
private var menuSize: CGSize {
|
||
NativeAttachmentMenuLayout.size(
|
||
compatibleWith: traitCollection,
|
||
maximumHeight: maximumMenuHeight
|
||
)
|
||
}
|
||
|
||
init(
|
||
channel: FlutterMethodChannel,
|
||
expandedWidth: CGFloat,
|
||
maximumMenuHeight: CGFloat = NativeAttachmentMenuLayout.maximumHeight
|
||
) {
|
||
self.channel = channel
|
||
self.expandedWidth = expandedWidth
|
||
self.maximumMenuHeight = maximumMenuHeight
|
||
super.init(nibName: nil, bundle: nil)
|
||
preferredContentSize = NativeAttachmentMenuLayout.size(
|
||
compatibleWith: .current,
|
||
maximumHeight: maximumMenuHeight
|
||
)
|
||
}
|
||
|
||
@available(*, unavailable)
|
||
required init?(coder: NSCoder) {
|
||
fatalError("init(coder:) has not been implemented")
|
||
}
|
||
|
||
override func viewDidLoad() {
|
||
super.viewDidLoad()
|
||
view.backgroundColor = .clear
|
||
view.layer.cornerRadius = NativeAttachmentPopoverStyle.cornerRadius
|
||
view.layer.cornerCurve = .continuous
|
||
view.layer.borderColor = UIColor.black.withAlphaComponent(0.04).cgColor
|
||
view.layer.borderWidth = NativeAttachmentPopoverStyle.borderWidth
|
||
view.layer.shadowColor = UIColor.black.cgColor
|
||
view.layer.shadowOpacity = NativeAttachmentPopoverStyle.shadowOpacity
|
||
view.layer.shadowRadius = NativeAttachmentPopoverStyle.shadowRadius
|
||
view.layer.shadowOffset = NativeAttachmentPopoverStyle.shadowOffset
|
||
view.clipsToBounds = false
|
||
|
||
let glassEffect = UIGlassEffect(style: .regular)
|
||
glassEffect.isInteractive = true
|
||
let glassView = UIVisualEffectView(effect: glassEffect)
|
||
glassView.translatesAutoresizingMaskIntoConstraints = false
|
||
glassView.layer.cornerRadius = NativeAttachmentPopoverStyle.cornerRadius
|
||
glassView.layer.cornerCurve = .continuous
|
||
glassView.clipsToBounds = true
|
||
view.addSubview(glassView)
|
||
|
||
contentHost.translatesAutoresizingMaskIntoConstraints = false
|
||
contentHost.layer.cornerRadius = NativeAttachmentPopoverStyle.cornerRadius
|
||
contentHost.layer.cornerCurve = .continuous
|
||
contentHost.clipsToBounds = true
|
||
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()
|
||
view.layer.shadowPath =
|
||
UIBezierPath(
|
||
roundedRect: view.bounds,
|
||
cornerRadius: NativeAttachmentPopoverStyle.cornerRadius
|
||
).cgPath
|
||
cameraPreviewLayer?.frame = cameraPreviewView?.bounds ?? .zero
|
||
}
|
||
|
||
override func viewWillDisappear(_ animated: Bool) {
|
||
super.viewWillDisappear(animated)
|
||
invalidateCameraCapture()
|
||
stopCamera()
|
||
}
|
||
|
||
override func traitCollectionDidChange(
|
||
_ previousTraitCollection: UITraitCollection?
|
||
) {
|
||
super.traitCollectionDidChange(previousTraitCollection)
|
||
guard
|
||
previousTraitCollection?.preferredContentSizeCategory
|
||
!= traitCollection.preferredContentSizeCategory
|
||
else {
|
||
return
|
||
}
|
||
|
||
updateMenuLayout()
|
||
}
|
||
|
||
func adaptivePresentationStyle(
|
||
for controller: UIPresentationController
|
||
) -> UIModalPresentationStyle {
|
||
.none
|
||
}
|
||
|
||
func presentationControllerDidDismiss(
|
||
_ presentationController: UIPresentationController
|
||
) {
|
||
notifyDismissalIfNeeded()
|
||
}
|
||
|
||
private func makeMenuView() -> UIView {
|
||
let container = UIView()
|
||
container.translatesAutoresizingMaskIntoConstraints = false
|
||
|
||
let scrollView = UIScrollView()
|
||
scrollView.alwaysBounceVertical = false
|
||
scrollView.translatesAutoresizingMaskIntoConstraints = false
|
||
container.addSubview(scrollView)
|
||
|
||
let stack = UIStackView()
|
||
stack.axis = .vertical
|
||
stack.distribution = .fillEqually
|
||
stack.spacing = NativeAttachmentMenuLayout.itemSpacing
|
||
stack.translatesAutoresizingMaskIntoConstraints = false
|
||
scrollView.addSubview(stack)
|
||
let stackHeightConstraint = stack.heightAnchor.constraint(
|
||
equalToConstant: NativeAttachmentMenuLayout.itemsHeight(
|
||
compatibleWith: traitCollection
|
||
)
|
||
)
|
||
menuStackHeightConstraint = stackHeightConstraint
|
||
NSLayoutConstraint.activate([
|
||
scrollView.leadingAnchor.constraint(equalTo: container.leadingAnchor),
|
||
scrollView.trailingAnchor.constraint(equalTo: container.trailingAnchor),
|
||
scrollView.topAnchor.constraint(equalTo: container.topAnchor),
|
||
scrollView.bottomAnchor.constraint(equalTo: container.bottomAnchor),
|
||
stack.leadingAnchor.constraint(
|
||
equalTo: scrollView.frameLayoutGuide.leadingAnchor,
|
||
constant: NativeAttachmentMenuLayout.contentPadding
|
||
),
|
||
stack.trailingAnchor.constraint(
|
||
equalTo: scrollView.frameLayoutGuide.trailingAnchor,
|
||
constant: -NativeAttachmentMenuLayout.contentPadding
|
||
),
|
||
stack.topAnchor.constraint(
|
||
equalTo: scrollView.contentLayoutGuide.topAnchor,
|
||
constant: NativeAttachmentMenuLayout.contentPadding
|
||
),
|
||
stack.bottomAnchor.constraint(
|
||
equalTo: scrollView.contentLayoutGuide.bottomAnchor,
|
||
constant: -NativeAttachmentMenuLayout.contentPadding
|
||
),
|
||
stackHeightConstraint,
|
||
])
|
||
|
||
stack.addArrangedSubview(
|
||
makeNativeAttachmentMenuButton(
|
||
title: "Camera",
|
||
symbol: "camera",
|
||
action: { [weak self] in self?.showCamera() }
|
||
)
|
||
)
|
||
stack.addArrangedSubview(
|
||
makeNativeAttachmentMenuButton(
|
||
title: "Photos",
|
||
symbol: "photo.on.rectangle.angled",
|
||
action: { [weak self] in self?.showPhotos() }
|
||
)
|
||
)
|
||
stack.addArrangedSubview(
|
||
makeNativeAttachmentMenuButton(
|
||
title: "Video",
|
||
symbol: "video",
|
||
action: { [weak self] in
|
||
self?.finish(method: "pickVideo")
|
||
}
|
||
)
|
||
)
|
||
stack.addArrangedSubview(
|
||
makeNativeAttachmentMenuButton(
|
||
title: "Files",
|
||
symbol: "doc",
|
||
action: { [weak self] in
|
||
self?.finish(method: "pickFiles")
|
||
}
|
||
)
|
||
)
|
||
return container
|
||
}
|
||
|
||
private func updateMenuLayout() {
|
||
menuStackHeightConstraint?.constant =
|
||
NativeAttachmentMenuLayout.itemsHeight(
|
||
compatibleWith: traitCollection
|
||
)
|
||
if surface == .menu {
|
||
preferredContentSize = menuSize
|
||
}
|
||
}
|
||
|
||
private func showPhotos() {
|
||
guard surface != .photos else { return }
|
||
prepareForExpandedSurface()
|
||
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
|
||
container.clipsToBounds = true
|
||
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),
|
||
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: { [weak self] in self?.showMenu() }
|
||
)
|
||
let actionButton = makeGlassControl(
|
||
title: "All Photos",
|
||
symbol: nil,
|
||
accessibilityLabel: "All Photos",
|
||
prominent: true,
|
||
action: { [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 }
|
||
prepareForExpandedSurface()
|
||
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: { [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,
|
||
completion: { [weak self] in
|
||
self?.removePhotoPicker()
|
||
}
|
||
)
|
||
}
|
||
|
||
private func prepareForExpandedSurface() {
|
||
if let sourceHost = popoverPresentationController?.sourceView?.superview {
|
||
keyboardDismissalOffset = max(
|
||
keyboardDismissalOffset,
|
||
NativeAttachmentExpandedSurfaceBehavior.keyboardOverlap(
|
||
containerBounds: sourceHost.bounds,
|
||
keyboardLayoutFrame: sourceHost.keyboardLayoutGuide.layoutFrame
|
||
)
|
||
)
|
||
}
|
||
NativeAttachmentExpandedSurfaceBehavior.dismissKeyboard(in: view.window)
|
||
}
|
||
|
||
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 shouldAnimate = !UIAccessibility.isReduceMotionEnabled
|
||
// Do not expose embedded surfaces while the popover changes size. In
|
||
// particular, PHPicker visibly reflows its grid from the compact menu
|
||
// width to the expanded width if it is allowed to paint during this step.
|
||
nextView.alpha = 0
|
||
visibleContentView = nextView
|
||
surface = nextSurface
|
||
|
||
let duration = shouldAnimate ? (isExpanding ? 0.24 : 0.2) : 0
|
||
UIView.animate(
|
||
withDuration: duration,
|
||
delay: 0,
|
||
options: [
|
||
.beginFromCurrentState,
|
||
.allowUserInteraction,
|
||
.curveEaseInOut,
|
||
]
|
||
) {
|
||
self.preferredContentSize = targetSize
|
||
if let popover = self.popoverPresentationController,
|
||
let sourceView = popover.sourceView
|
||
{
|
||
popover.sourceRect = NativeAttachmentPopoverAnchorLayout.sourceRect(
|
||
anchorBounds: sourceView.bounds,
|
||
keyboardDismissalOffset: self.keyboardDismissalOffset,
|
||
isExpanded: isExpanding
|
||
)
|
||
}
|
||
previousView?.alpha = 0
|
||
self.view.layoutIfNeeded()
|
||
} completion: { _ in
|
||
previousView?.removeFromSuperview()
|
||
self.view.layoutIfNeeded()
|
||
|
||
let reveal = {
|
||
UIView.animate(
|
||
withDuration: shouldAnimate ? 0.14 : 0,
|
||
delay: 0,
|
||
options: [
|
||
.beginFromCurrentState,
|
||
.allowUserInteraction,
|
||
.curveEaseOut,
|
||
]
|
||
) {
|
||
nextView.alpha = 1
|
||
} completion: { _ in
|
||
completion?()
|
||
}
|
||
}
|
||
|
||
reveal()
|
||
}
|
||
}
|
||
|
||
private func makeGlassControl(
|
||
title: String?,
|
||
symbol: String?,
|
||
accessibilityLabel: String,
|
||
prominent: Bool = false,
|
||
action: @escaping () -> Void
|
||
) -> 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.titleTextAttributesTransformer =
|
||
UIConfigurationTextAttributesTransformer { attributes in
|
||
var interAttributes = attributes
|
||
interAttributes.font = NativeAttachmentMenuTypography.font(
|
||
forTextStyle: .body
|
||
)
|
||
return interAttributes
|
||
}
|
||
configuration.contentInsets = NSDirectionalEdgeInsets(
|
||
top: 11,
|
||
leading: 15,
|
||
bottom: 11,
|
||
trailing: 15
|
||
)
|
||
let button = UIButton(
|
||
configuration: configuration,
|
||
primaryAction: UIAction { _ in
|
||
UISelectionFeedbackGenerator().selectionChanged()
|
||
action()
|
||
}
|
||
)
|
||
button.accessibilityLabel = accessibilityLabel
|
||
return button
|
||
}
|
||
|
||
private func makeCameraCaptureButton() -> UIButton {
|
||
let button = UIButton(
|
||
primaryAction: UIAction { [weak self] _ in
|
||
UISelectionFeedbackGenerator().selectionChanged()
|
||
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
|
||
}
|
||
NativeAttachmentExpandedSurfaceBehavior.dismissKeyboard(in: view.window)
|
||
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
|
||
}
|