Path: blob/main/Signal/src/ViewControllers/Photos/PhotoCaptureViewController.swift
1 views
//
// Copyright 2019 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//
import AVFoundation
import Foundation
import LibSignalClient
import Lottie
import Photos
import SignalServiceKit
import SignalUI
import UIKit
protocol PhotoCaptureViewControllerDelegate: AnyObject {
func photoCaptureViewControllerDidFinish(_ photoCaptureViewController: PhotoCaptureViewController)
func photoCaptureViewController(
_ photoCaptureViewController: PhotoCaptureViewController,
didFinishWithTextAttachment textAttachment: UnsentTextAttachment,
)
func photoCaptureViewControllerDidCancel(_ photoCaptureViewController: PhotoCaptureViewController)
func photoCaptureViewControllerDidTryToCaptureTooMany(_ photoCaptureViewController: PhotoCaptureViewController)
func photoCaptureViewControllerViewWillAppear(_ photoCaptureViewController: PhotoCaptureViewController)
func photoCaptureViewControllerCanCaptureMoreItems(_ photoCaptureViewController: PhotoCaptureViewController) -> Bool
func photoCaptureViewControllerDidRequestPresentPhotoLibrary(_ photoCaptureViewController: PhotoCaptureViewController)
func photoCaptureViewController(
_ photoCaptureViewController: PhotoCaptureViewController,
didRequestSwitchCaptureModeTo captureMode: PhotoCaptureViewController.CaptureMode,
completion: @escaping (Bool) -> Void,
)
func photoCaptureViewControllerCanShowTextEditor(_ photoCaptureViewController: PhotoCaptureViewController) -> Bool
}
protocol PhotoCaptureViewControllerDataSource: AnyObject {
var numberOfMediaItems: Int { get }
func addMedia(attachment: PreviewableAttachment)
}
class PhotoCaptureViewController: OWSViewController, OWSNavigationChildController {
private let attachmentLimits: OutgoingAttachmentLimits
init(attachmentLimits: OutgoingAttachmentLimits) {
self.attachmentLimits = attachmentLimits
super.init()
}
weak var delegate: PhotoCaptureViewControllerDelegate?
weak var dataSource: PhotoCaptureViewControllerDataSource?
private var interactiveDismiss: PhotoCaptureInteractiveDismiss?
private lazy var qrCodeSampleBufferScanner = QRCodeSampleBufferScanner(delegate: self)
private lazy var cameraCaptureSession = CameraCaptureSession(
delegate: self,
attachmentLimits: attachmentLimits,
qrCodeSampleBufferScanner: qrCodeSampleBufferScanner,
)
private var qrCodeScanned = false {
didSet {
updateShouldProcessQRCodes()
}
}
/// The underlying stored atomic for `shouldProcessQRCodes`.
/// Update its value by calling `updateShouldProcessQRCodes`.
private let _shouldProcessQRCodes = AtomicBool(false, lock: .init())
private func updateShouldProcessQRCodes() {
_shouldProcessQRCodes.set(!qrCodeScanned && !isRecordingVideo && isViewVisible)
}
private let sleepBlock = DeviceSleepBlockObject(blockReason: "Photo Capture")
private var isCameraReady = false {
didSet {
guard isCameraReady != oldValue else { return }
if isCameraReady {
cameraCaptureSession.beginObservingVolumeButtons()
DependenciesBridge.shared.deviceSleepManager!.addBlock(blockObject: sleepBlock)
} else {
cameraCaptureSession.stopObservingVolumeButtons()
DependenciesBridge.shared.deviceSleepManager!.removeBlock(blockObject: sleepBlock)
}
}
}
private var hasCameraStarted = false {
didSet {
isCameraReady = isViewVisible && hasCameraStarted
}
}
private var isViewVisible = false {
didSet {
isCameraReady = isViewVisible && hasCameraStarted
updateShouldProcessQRCodes()
}
}
deinit {
cameraCaptureSession.stop().done {
Logger.debug("stopCapture completed")
}
}
// MARK: - Overrides
override func viewDidLoad() {
super.viewDidLoad()
definesPresentationContext = true
view.backgroundColor = Theme.darkThemeBackgroundColor
view.preservesSuperviewLayoutMargins = true
initializeUI()
setupPhotoCapture()
updateFlashModeControl(animated: false)
if let navigationController {
let interactiveDismiss = PhotoCaptureInteractiveDismiss(viewController: navigationController)
interactiveDismiss.interactiveDismissDelegate = self
interactiveDismiss.addGestureRecognizer(to: view)
self.interactiveDismiss = interactiveDismiss
}
}
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
delegate?.photoCaptureViewControllerViewWillAppear(self)
let previewOrientation: AVCaptureVideoOrientation
if UIDevice.current.isIPad, let windowScene = view.window?.windowScene {
previewOrientation = AVCaptureVideoOrientation(interfaceOrientation: windowScene.interfaceOrientation) ?? .portrait
} else {
previewOrientation = .portrait
}
UIViewController.attemptRotationToDeviceOrientation()
cameraCaptureSession.updateVideoPreviewConnection(toOrientation: previewOrientation)
updateIconOrientations(isAnimated: false, captureOrientation: previewOrientation)
NotificationCenter.default.addObserver(
self,
selector: #selector(sessionWasInterrupted),
name: AVCaptureSession.wasInterruptedNotification,
object: nil,
)
resumePhotoCapture()
if let dataSource, dataSource.numberOfMediaItems > 0 {
captureMode = .multi
}
updateDoneButtonAppearance()
}
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
isViewVisible = true
cameraCaptureSession.updateVideoCaptureOrientation()
}
override func viewWillDisappear(_ animated: Bool) {
super.viewWillDisappear(animated)
isViewVisible = false
pausePhotoCapture()
}
override var prefersStatusBarHidden: Bool {
!UIDevice.current.hasIPhoneXNotch && !UIDevice.current.isIPad && AppEnvironment.shared.callService.callServiceState.currentCall == nil
}
override var preferredStatusBarStyle: UIStatusBarStyle {
.lightContent
}
var prefersNavigationBarHidden: Bool {
return true
}
override var supportedInterfaceOrientations: UIInterfaceOrientationMask { .portrait }
override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) {
super.viewWillTransition(to: size, with: coordinator)
guard UIDevice.current.isIPad else { return }
// Since we support iPad multitasking, we cannot *disable* rotation of our views.
// Rotating the preview layer is really distracting, so we fade out the preview layer
// while the rotation occurs.
self.previewView.alpha = 0
coordinator.animate(
alongsideTransition: { _ in },
completion: { _ in
UIView.animate(withDuration: 0.1) {
self.previewView.alpha = 1
}
},
)
}
override func viewSafeAreaInsetsDidChange() {
super.viewSafeAreaInsetsDidChange()
// Safe area insets will change during interactive dismiss - ignore those changes.
guard !(interactiveDismiss?.interactionInProgress ?? false) else { return }
if let contentLayoutGuideTop = previewViewContentLayoutGuideTop {
contentLayoutGuideTop.constant = view.safeAreaInsets.top
// Rounded corners if preview view isn't full-screen.
previewView.previewLayer.cornerRadius = view.safeAreaInsets.top > 0 ? 18 : 0
}
if let bottomBarControlsLayoutGuideBottom {
bottomBarControlsLayoutGuideBottom.constant = -view.safeAreaInsets.bottom
}
}
override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
super.traitCollectionDidChange(previousTraitCollection)
isIPadUIInRegularMode = traitCollection.horizontalSizeClass == .regular && traitCollection.verticalSizeClass == .regular
}
// MARK: - Layout Code
private var isIPadUIInRegularMode = false {
didSet {
guard oldValue != isIPadUIInRegularMode else { return }
updateIPadInterfaceLayout()
}
}
private let previewViewLayoutGuide = UILayoutGuide()
private var previewViewContentLayoutGuideTop: NSLayoutConstraint? // controls vertical position of `previewViewLayoutGuide` on iPhones.
// Values match ContentTypeSelectionControl.selectedSegmentIndex.
private enum ComposerMode: Int {
case camera = 0
case text
}
private var _internalComposerMode: ComposerMode = .camera
private var composerMode: ComposerMode { _internalComposerMode }
private func setComposerMode(_ composerMode: ComposerMode, animated: Bool) {
owsAssertDebug(!isRecordingVideo, "Invalid state - should not be recording video")
guard _internalComposerMode != composerMode else { return }
_internalComposerMode = composerMode
if composerMode == .text {
startObservingKeyboardNotifications()
initializeTextEditorUIIfNecessary()
}
updateTopBarAppearance(animated: animated)
// No need to update bottom bar's visibility because it's always visible if CAMERA|TEXT switch is accessible.
bottomBar.setMode(composerMode == .text ? .text : .camera, animated: animated)
updateSideBarVisibility(animated: animated)
// Show / hide camera controls and viewfinder.
let hideCameraUI = composerMode != .camera
let isFrontCamera = cameraCaptureSession.desiredPosition == .front
frontCameraZoomControl?.setIsHidden(hideCameraUI || !isFrontCamera, animated: animated)
rearCameraZoomControl?.setIsHidden(hideCameraUI || isFrontCamera, animated: animated)
previewView.setIsHidden(hideCameraUI, animated: animated)
doneButton.setIsHidden(shouldHideDoneButton, animated: animated)
// Show / hide text editor controls.
let hideTextComposerUI = composerMode != .text
textStoryComposerView.setIsHidden(hideTextComposerUI, animated: animated)
textEditorToolbar.setIsHidden(hideTextComposerUI, animated: animated)
// Stop / start camera as necessary.
switch composerMode {
case .camera: resumePhotoCapture()
case .text: pausePhotoCapture()
}
// Update CAMERA | TEXT switch if necessary.
if bottomBar.contentTypeSelectionControl.selectedSegmentIndex != composerMode.rawValue {
bottomBar.contentTypeSelectionControl.selectedSegmentIndex = composerMode.rawValue
}
}
private var _internalIsRecordingVideo = false
private var isRecordingVideo: Bool { _internalIsRecordingVideo }
private func setIsRecordingVideo(_ isRecordingVideo: Bool, animated: Bool) {
guard _internalIsRecordingVideo != isRecordingVideo else { return }
_internalIsRecordingVideo = isRecordingVideo
updateShouldProcessQRCodes()
updateTopBarAppearance(animated: animated)
topBar.recordingTimerView.isRecordingInProgress = isRecordingVideo
if isRecordingVideo {
topBar.recordingTimerView.duration = 0
let captureControlState: CameraCaptureControl.State = UIAccessibility.isVoiceOverRunning ? .recordingUsingVoiceOver : .recording
let animationDuration: TimeInterval = animated ? 0.4 : 0
bottomBar.captureControl.setState(captureControlState, animationDuration: animationDuration)
if let sideBar {
sideBar.cameraCaptureControl.setState(captureControlState, animationDuration: animationDuration)
}
} else {
let animationDuration: TimeInterval = animated ? 0.2 : 0
bottomBar.captureControl.setState(.initial, animationDuration: animationDuration)
if let sideBar {
sideBar.cameraCaptureControl.setState(.initial, animationDuration: animationDuration)
}
}
bottomBar.setMode(isRecordingVideo ? .videoRecording : .camera, animated: animated)
if let sideBar {
sideBar.isRecordingVideo = isRecordingVideo
}
doneButton.setIsHidden(shouldHideDoneButton, animated: animated)
}
enum CaptureMode {
case single
case multi
}
var captureMode: CaptureMode = .single {
didSet {
topBar.batchModeButton.setCaptureMode(captureMode, animated: true)
if let sideBar {
sideBar.batchModeButton.setCaptureMode(captureMode, animated: true)
}
}
}
private let topBar = CameraTopBar(frame: .zero)
private func updateTopBarAppearance(animated: Bool) {
let mode: CameraTopBar.Mode = {
if isRecordingVideo {
return .videoRecording
}
if composerMode == .text {
return .closeButton
}
if isIPadUIInRegularMode {
return .closeButton
}
return .cameraControls
}()
topBar.setMode(mode, animated: animated)
}
private lazy var bottomBar = CameraBottomBar(isContentTypeSelectionControlAvailable: delegate?.photoCaptureViewControllerCanShowTextEditor(self) ?? false)
private var bottomBarControlsLayoutGuideBottom: NSLayoutConstraint?
private func updateBottomBarVisibility(animated: Bool) {
let isBarHidden: Bool = {
if textEditorUIInitialized {
return textStoryComposerView.isEditing
}
if bottomBar.isContentTypeSelectionControlAvailable {
return false
}
return isIPadUIInRegularMode
}()
bottomBar.setIsHidden(isBarHidden, animated: animated)
}
private var sideBar: CameraSideBar? // Optional because most devices are iPhones and will never need this.
private func updateSideBarVisibility(animated: Bool) {
guard let sideBar else { return }
sideBar.setIsHidden(composerMode == .text || !isIPadUIInRegularMode, animated: true)
}
// MARK: - Camera Controls
private var frontCameraZoomControl: CameraZoomSelectionControl?
private var rearCameraZoomControl: CameraZoomSelectionControl?
private var cameraZoomControlIPhoneConstraints: [NSLayoutConstraint]?
private var cameraZoomControlIPadConstraints: [NSLayoutConstraint]?
private lazy var tapToFocusView: LottieAnimationView = {
let view = LottieAnimationView(name: "tap_to_focus")
view.animationSpeed = 1
view.backgroundBehavior = .forceFinish
view.contentMode = .scaleAspectFit
view.isUserInteractionEnabled = false
view.autoSetDimensions(to: CGSize(square: 150))
view.setContentHuggingHigh()
return view
}()
private lazy var tapToFocusCenterXConstraint = tapToFocusView.centerXAnchor.constraint(equalTo: previewView.leftAnchor)
private lazy var tapToFocusCenterYConstraint = tapToFocusView.centerYAnchor.constraint(equalTo: previewView.topAnchor)
private var lastUserFocusTapPoint: CGPoint?
private var previewView: CapturePreviewView {
cameraCaptureSession.previewView
}
// MARK: - Text Editor
private var textEditorUIInitialized = false
private var textEditoriPhoneConstraints = [NSLayoutConstraint]()
private var textEditoriPadConstraints = [NSLayoutConstraint]()
private lazy var textStoryComposerView = TextStoryComposerView(text: "")
private lazy var textEditorToolbar: UIView = {
let stackView = UIStackView(arrangedSubviews: [textBackgroundSelectionButton, textViewAttachLinkButton])
stackView.axis = .horizontal
stackView.spacing = 16
stackView.translatesAutoresizingMaskIntoConstraints = false
return stackView
}()
private lazy var textBackgroundSelectionButton = RoundGradientButton()
private lazy var textViewAttachLinkButton: UIButton = {
let button = RoundMediaButton(image: UIImage(imageLiteralResourceName: "link"), backgroundStyle: .blur)
button.ows_contentEdgeInsets = UIEdgeInsets(margin: 3)
button.layoutMargins = .zero
return button
}()
// This constraint gets updated when onscreen keyboard appears/disappears.
private var textStoryComposerContentLayoutGuideBottomIphone: NSLayoutConstraint?
private var textStoryComposerContentLayoutGuideBottomIpad: NSLayoutConstraint?
private var observingKeyboardNotifications = false
private lazy var doneButton: MediaDoneButton = {
let button = MediaDoneButton(type: .custom)
button.badgeNumber = 0
button.overrideUserInterfaceStyle = .dark
return button
}()
private var shouldHideDoneButton: Bool {
isRecordingVideo || composerMode == .text || doneButton.badgeNumber == 0
}
private lazy var doneButtonIPhoneConstraints = [
doneButton.trailingAnchor.constraint(equalTo: view.layoutMarginsGuide.trailingAnchor),
doneButton.centerYAnchor.constraint(equalTo: bottomBar.shutterButtonLayoutGuide.centerYAnchor),
]
private var doneButtonIPadConstraints: [NSLayoutConstraint]?
private func initializeUI() {
// `previewViewLayoutGuide` defines area occupied by the content:
// either camera viewfinder or text story composing area.
view.addLayoutGuide(previewViewLayoutGuide)
// Always full-width.
view.addConstraints([
previewViewLayoutGuide.leadingAnchor.constraint(equalTo: view.leadingAnchor),
previewViewLayoutGuide.trailingAnchor.constraint(equalTo: view.trailingAnchor),
])
if UIDevice.current.isIPad {
// Full-height on iPads.
view.addConstraints([
previewViewLayoutGuide.topAnchor.constraint(equalTo: view.topAnchor),
previewViewLayoutGuide.bottomAnchor.constraint(equalTo: view.bottomAnchor),
])
} else {
// 9:16 aspect ratio on iPhones.
// Note that there's no constraint on the bottom edge of the `previewViewLayoutGuide`.
// This works because all iPhones have screens 9:16 or taller.
view.addConstraint(previewViewLayoutGuide.heightAnchor.constraint(equalTo: previewViewLayoutGuide.widthAnchor, multiplier: 16 / 9))
// Constrain to the top of the view now and update offset with the height of top safe area later.
// Can't constrain to the safe area layout guide because safe area insets changes during interactive dismiss.
let constraint = previewViewLayoutGuide.topAnchor.constraint(equalTo: view.topAnchor)
view.addConstraint(constraint)
previewViewContentLayoutGuideTop = constraint
}
// Step 1. Initialize all UI elements for iPhone layout (which can also be used on an iPad).
// Camera Viewfinder - simply occupies the entire frame of `previewViewLayoutGuide`.
previewView.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(previewView)
view.addConstraints([
previewView.leadingAnchor.constraint(equalTo: previewViewLayoutGuide.leadingAnchor),
previewView.topAnchor.constraint(equalTo: previewViewLayoutGuide.topAnchor),
previewView.trailingAnchor.constraint(equalTo: previewViewLayoutGuide.trailingAnchor),
previewView.bottomAnchor.constraint(equalTo: previewViewLayoutGuide.bottomAnchor),
])
configureCameraGestures()
// Top Bar
view.addSubview(topBar)
topBar.closeButton.addTarget(self, action: #selector(didTapClose), for: .touchUpInside)
topBar.batchModeButton.addTarget(self, action: #selector(didTapBatchMode), for: .touchUpInside)
topBar.flashModeButton.addTarget(self, action: #selector(didTapFlashMode), for: .touchUpInside)
topBar.autoPinWidthToSuperview()
if UIDevice.current.isIPad {
topBar.autoPinEdge(toSuperviewSafeArea: .top)
} else {
topBar.topAnchor.constraint(equalTo: previewViewLayoutGuide.topAnchor).isActive = true
}
// Bottom Bar (contains shutter button)
view.addSubview(bottomBar)
bottomBar.isCompactHeightLayout = !UIDevice.current.hasIPhoneXNotch
bottomBar.switchCameraButton.addTarget(self, action: #selector(didTapSwitchCamera), for: .touchUpInside)
bottomBar.photoLibraryButton.addTarget(self, action: #selector(didTapPhotoLibrary), for: .touchUpInside)
if bottomBar.isContentTypeSelectionControlAvailable {
bottomBar.contentTypeSelectionControl.selectedSegmentIndex = 0
bottomBar.contentTypeSelectionControl.addTarget(self, action: #selector(contentTypeChanged), for: .valueChanged)
}
bottomBar.autoPinWidthToSuperview()
if bottomBar.isCompactHeightLayout {
// On devices with home button and iPads bar is simply pinned to the bottom of the screen
// with a fixed margin that defines space under the shutter button or CAMERA|TEXT switch.
bottomBar.bottomAnchor.constraint(equalTo: view.bottomAnchor, constant: -32).isActive = true
} else {
// On `notch` devices:
// i. Shutter button is placed 16 pts above the bottom edge of the preview view.
bottomBar.shutterButtonLayoutGuide.bottomAnchor.constraint(equalTo: previewViewLayoutGuide.bottomAnchor, constant: -16).isActive = true
// ii. Other buttons are centered vertically in the black box between bottom of the preview view and top of bottom safe area.
bottomBar.controlButtonsLayoutGuide.topAnchor.constraint(equalTo: previewViewLayoutGuide.bottomAnchor).isActive = true
// Constrain to the bottom of the view now and update offset with the height of bottom safe area later.
// Can't constrain to the safe area layout guide because safe area insets changes during interactive dismiss.
let constraint = bottomBar.bottomAnchor.constraint(equalTo: view.bottomAnchor)
view.addConstraint(constraint)
bottomBarControlsLayoutGuideBottom = constraint
}
// Camera Zoom Controls
cameraZoomControlIPhoneConstraints = []
let availableFrontCameras = cameraCaptureSession.cameraZoomFactorMap(forPosition: .front)
if availableFrontCameras.count > 0 {
let cameras = availableFrontCameras.sorted { $0.0 < $1.0 }.map { ($0.0, $0.1) }
let cameraZoomControl = CameraZoomSelectionControl(availableCameras: cameras)
cameraZoomControl.delegate = self
view.addSubview(cameraZoomControl)
self.frontCameraZoomControl = cameraZoomControl
let cameraZoomControlConstraints =
[
cameraZoomControl.centerXAnchor.constraint(equalTo: bottomBar.shutterButtonLayoutGuide.centerXAnchor),
cameraZoomControl.bottomAnchor.constraint(equalTo: bottomBar.shutterButtonLayoutGuide.topAnchor, constant: -32),
]
view.addConstraints(cameraZoomControlConstraints)
cameraZoomControlIPhoneConstraints?.append(contentsOf: cameraZoomControlConstraints)
}
let availableRearCameras = cameraCaptureSession.cameraZoomFactorMap(forPosition: .back)
if availableRearCameras.count > 0 {
let cameras = availableRearCameras.sorted { $0.0 < $1.0 }.map { ($0.0, $0.1) }
let cameraZoomControl = CameraZoomSelectionControl(availableCameras: cameras)
cameraZoomControl.delegate = self
view.addSubview(cameraZoomControl)
self.rearCameraZoomControl = cameraZoomControl
let cameraZoomControlConstraints =
[
cameraZoomControl.centerXAnchor.constraint(equalTo: bottomBar.shutterButtonLayoutGuide.centerXAnchor),
cameraZoomControl.bottomAnchor.constraint(equalTo: bottomBar.shutterButtonLayoutGuide.topAnchor, constant: -32),
]
view.addConstraints(cameraZoomControlConstraints)
cameraZoomControlIPhoneConstraints?.append(contentsOf: cameraZoomControlConstraints)
}
updateUIOnCameraPositionChange()
// Done Button
view.addSubview(doneButton)
doneButton.isHidden = true
doneButton.translatesAutoresizingMaskIntoConstraints = false
view.addConstraints(doneButtonIPhoneConstraints)
doneButton.addTarget(self, action: #selector(didTapDoneButton), for: .touchUpInside)
// Focusing frame
previewView.addSubview(tapToFocusView)
previewView.addConstraints([tapToFocusCenterXConstraint, tapToFocusCenterYConstraint])
// Step 2. Check if we're running on an iPad and update UI accordingly.
// Note that `traitCollectionDidChange` won't be called during initial view loading process.
isIPadUIInRegularMode = traitCollection.horizontalSizeClass == .regular && traitCollection.verticalSizeClass == .regular
// This background footer doesn't let view controller underneath current VC
// to be visible at the bottom of the screen during interactive dismiss.
if UIDevice.current.hasIPhoneXNotch {
let blackFooter = UIView()
blackFooter.backgroundColor = view.backgroundColor
view.insertSubview(blackFooter, at: 0)
blackFooter.autoPinWidthToSuperview()
blackFooter.autoPinEdge(toSuperviewEdge: .bottom)
blackFooter.heightAnchor.constraint(equalTo: view.heightAnchor, multiplier: 0.5).isActive = true
}
}
private func initializeIPadSpecificUIIfNecessary() {
guard sideBar == nil else { return }
let sideBar = CameraSideBar(frame: .zero)
sideBar.cameraCaptureControl.delegate = cameraCaptureSession
sideBar.batchModeButton.addTarget(self, action: #selector(didTapBatchMode), for: .touchUpInside)
sideBar.flashModeButton.addTarget(self, action: #selector(didTapFlashMode), for: .touchUpInside)
sideBar.switchCameraButton.addTarget(self, action: #selector(didTapSwitchCamera), for: .touchUpInside)
sideBar.photoLibraryButton.addTarget(self, action: #selector(didTapPhotoLibrary), for: .touchUpInside)
view.addSubview(sideBar)
sideBar.autoPinTrailingToSuperviewMargin(withInset: 12)
sideBar.cameraCaptureControl.shutterButtonLayoutGuide.centerYAnchor.constraint(equalTo: view.centerYAnchor).isActive = true
self.sideBar = sideBar
sideBar.batchModeButton.setImage(topBar.batchModeButton.image(for: .normal), for: .normal)
updateFlashModeControl(animated: false)
doneButtonIPadConstraints = [
doneButton.centerXAnchor.constraint(equalTo: sideBar.centerXAnchor),
doneButton.bottomAnchor.constraint(equalTo: sideBar.topAnchor, constant: -8),
]
cameraZoomControlIPadConstraints = []
if let cameraZoomControl = frontCameraZoomControl {
let constraints = [
cameraZoomControl.centerYAnchor.constraint(equalTo: sideBar.cameraCaptureControl.shutterButtonLayoutGuide.centerYAnchor),
cameraZoomControl.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 32),
]
cameraZoomControlIPadConstraints?.append(contentsOf: constraints)
}
if let cameraZoomControl = rearCameraZoomControl {
let constraints = [
cameraZoomControl.centerYAnchor.constraint(equalTo: sideBar.cameraCaptureControl.shutterButtonLayoutGuide.centerYAnchor),
cameraZoomControl.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 32),
]
cameraZoomControlIPadConstraints?.append(contentsOf: constraints)
}
if textEditorUIInitialized {
initializeTextEditoriPadUI()
}
}
private func updateIPadInterfaceLayout() {
owsAssertDebug(UIDevice.current.isIPad)
if isIPadUIInRegularMode {
initializeIPadSpecificUIIfNecessary()
view.removeConstraints(doneButtonIPhoneConstraints)
if let doneButtonIPadConstraints {
view.addConstraints(doneButtonIPadConstraints)
}
} else {
if let doneButtonIPadConstraints {
view.removeConstraints(doneButtonIPadConstraints)
}
view.addConstraints(doneButtonIPhoneConstraints)
}
if let cameraZoomControl = frontCameraZoomControl {
cameraZoomControl.axis = isIPadUIInRegularMode ? .vertical : .horizontal
}
if let cameraZoomControl = rearCameraZoomControl {
cameraZoomControl.axis = isIPadUIInRegularMode ? .vertical : .horizontal
}
if
let iPhoneConstraints = cameraZoomControlIPhoneConstraints,
let iPadConstraints = cameraZoomControlIPadConstraints
{
if isIPadUIInRegularMode {
view.removeConstraints(iPhoneConstraints)
view.addConstraints(iPadConstraints)
} else {
view.removeConstraints(iPadConstraints)
view.addConstraints(iPhoneConstraints)
}
}
updateTopBarAppearance(animated: true)
updateBottomBarVisibility(animated: true)
bottomBar.setLayout(isIPadUIInRegularMode ? .iPad : .iPhone, animated: true)
updateSideBarVisibility(animated: true)
if textEditorUIInitialized {
textStoryComposerView.layer.cornerRadius = isIPadUIInRegularMode || UIDevice.current.hasIPhoneXNotch ? 18 : 0
if isIPadUIInRegularMode {
view.removeConstraints(textEditoriPhoneConstraints)
view.addConstraints(textEditoriPadConstraints)
bottomBar.constrainControlButtonsLayoutGuideHorizontallyTo(
leadingAnchor: textStoryComposerView.leadingAnchor,
trailingAnchor: textStoryComposerView.trailingAnchor,
)
} else {
view.removeConstraints(textEditoriPadConstraints)
view.addConstraints(textEditoriPhoneConstraints)
bottomBar.constrainControlButtonsLayoutGuideHorizontallyTo(leadingAnchor: nil, trailingAnchor: nil)
}
}
}
func updateDoneButtonAppearance() {
doneButton.badgeNumber = dataSource?.numberOfMediaItems ?? 0
doneButton.isHidden = shouldHideDoneButton
if bottomBar.isCompactHeightLayout {
bottomBar.switchCameraButton.isHidden = !doneButton.isHidden
}
}
private func updateUIOnCameraPositionChange(animated: Bool = false) {
let isFrontCamera = cameraCaptureSession.desiredPosition == .front
frontCameraZoomControl?.setIsHidden(!isFrontCamera, animated: animated)
rearCameraZoomControl?.setIsHidden(isFrontCamera, animated: animated)
bottomBar.switchCameraButton.isFrontCameraActive = isFrontCamera
if let sideBar {
sideBar.switchCameraButton.isFrontCameraActive = isFrontCamera
}
}
private func updateIconOrientations(isAnimated: Bool, captureOrientation: AVCaptureVideoOrientation) {
guard !UIDevice.current.isIPad else { return }
let transformFromOrientation: CGAffineTransform
switch captureOrientation {
case .portrait:
transformFromOrientation = .identity
case .portraitUpsideDown:
transformFromOrientation = CGAffineTransform(rotationAngle: .pi)
case .landscapeRight:
transformFromOrientation = CGAffineTransform(rotationAngle: .halfPi)
case .landscapeLeft:
transformFromOrientation = CGAffineTransform(rotationAngle: -1 * .halfPi)
@unknown default:
owsFailDebug("unexpected captureOrientation: \(captureOrientation.rawValue)")
transformFromOrientation = .identity
}
// Don't "unrotate" the switch camera icon if the front facing camera had been selected.
let transformFromCameraType: CGAffineTransform = cameraCaptureSession.desiredPosition == .front ? CGAffineTransform(rotationAngle: -.pi) : .identity
var buttonsToUpdate: [UIView] = [topBar.batchModeButton, topBar.flashModeButton, bottomBar.photoLibraryButton]
if let cameraZoomControl = frontCameraZoomControl {
buttonsToUpdate.append(contentsOf: cameraZoomControl.cameraZoomLevelIndicators)
}
if let cameraZoomControl = rearCameraZoomControl {
buttonsToUpdate.append(contentsOf: cameraZoomControl.cameraZoomLevelIndicators)
}
let updateOrientation = {
buttonsToUpdate.forEach { $0.transform = transformFromOrientation }
self.bottomBar.switchCameraButton.transform = transformFromOrientation.concatenating(transformFromCameraType)
}
if isAnimated {
UIView.animate(withDuration: 0.3, animations: updateOrientation)
} else {
updateOrientation()
}
}
}
// MARK: - Text Editor
extension PhotoCaptureViewController {
private func initializeTextEditorUIIfNecessary() {
guard !textEditorUIInitialized else { return }
// Connect button actions.
bottomBar.proceedButton.addTarget(self, action: #selector(didTapTextStoryProceedButton), for: .touchUpInside)
textBackgroundSelectionButton.addTarget(self, action: #selector(didTapTextBackgroundButton), for: .touchUpInside)
textViewAttachLinkButton.addTarget(self, action: #selector(didTapAttachLinkPreviewButton), for: .touchUpInside)
updateTextBackgroundSelectionButton()
// Set up composer view.
textStoryComposerView.delegate = self
textStoryComposerView.translatesAutoresizingMaskIntoConstraints = false
textStoryComposerView.layer.cornerRadius = isIPadUIInRegularMode || UIDevice.current.hasIPhoneXNotch ? 18 : 0
view.insertSubview(textStoryComposerView, aboveSubview: previewView)
textEditoriPhoneConstraints.append(contentsOf: [
textStoryComposerView.leadingAnchor.constraint(equalTo: previewViewLayoutGuide.leadingAnchor),
textStoryComposerView.topAnchor.constraint(equalTo: previewViewLayoutGuide.topAnchor),
textStoryComposerView.trailingAnchor.constraint(equalTo: previewViewLayoutGuide.trailingAnchor),
textStoryComposerView.bottomAnchor.constraint(equalTo: previewViewLayoutGuide.bottomAnchor),
])
// Swipe right to switch to camera.
let swipeGesture = UISwipeGestureRecognizer(target: self, action: #selector(didSwipeToCamera(gesture:)))
swipeGesture.direction = CurrentAppContext().isRTL ? .left : .right
textStoryComposerView.addGestureRecognizer(swipeGesture)
// Choose Background and Attach Link buttons.
// Toolbar is added to VC's view because it might be located outside of the textStoryComposerView.
view.addSubview(textEditorToolbar)
// Align leading edge of Background button to leading edge of the content area of the `bottomBar`,
// which is in turn might constrained to the leading edge of text editor "card".
view.addConstraint(textEditorToolbar.leadingAnchor.constraint(equalTo: bottomBar.controlButtonsLayoutGuide.leadingAnchor))
if bottomBar.isCompactHeightLayout {
// On devices without top and bottom safe areas buttons are placed above CAMERA | TEXT controls.
textEditoriPhoneConstraints.append(
textEditorToolbar.bottomAnchor.constraint(equalTo: bottomBar.controlButtonsLayoutGuide.topAnchor),
)
} else {
// On devices with bottom safe area buttons are pinned to the bottom edge of the colored background,
// which always clears CAMERA | TEXT controls.
textEditoriPhoneConstraints.append(
textEditorToolbar.bottomAnchor.constraint(equalTo: textStoryComposerView.bottomAnchor, constant: -16),
)
}
// This constraint defines bottom edge of the area that contains text view and link preview inside of the `textStoryComposerView`.
// Initially the bottom edge is pinned to the top of `textEditorToolbar`.
// If on-screen keyboard appears the constraint is updated so that content clears the keyboard.
textStoryComposerContentLayoutGuideBottomIphone = textStoryComposerView.contentLayoutGuide.bottomAnchor.constraint(
equalTo: textEditorToolbar.bottomAnchor,
)
textEditoriPhoneConstraints.append(textStoryComposerContentLayoutGuideBottomIphone!)
if isIPadUIInRegularMode {
initializeTextEditoriPadUI()
} else {
view.addConstraints(textEditoriPhoneConstraints)
}
view.setNeedsLayout()
UIView.performWithoutAnimation {
self.view.layoutIfNeeded()
}
textEditorUIInitialized = true
}
private func initializeTextEditoriPadUI() {
owsAssertDebug(textEditoriPadConstraints.isEmpty)
// Container - 16:9 aspect ratio, constrained vertically, centered on the screen horizontally.
textEditoriPadConstraints.append(contentsOf: [
textStoryComposerView.topAnchor.constraint(equalTo: topBar.bottomAnchor, constant: -8),
textStoryComposerView.bottomAnchor.constraint(equalTo: bottomBar.controlButtonsLayoutGuide.topAnchor, constant: -24),
textStoryComposerView.centerXAnchor.constraint(equalTo: previewViewLayoutGuide.centerXAnchor),
textStoryComposerView.widthAnchor.constraint(equalTo: textStoryComposerView.heightAnchor, multiplier: 9 / 16),
])
// This constraint defines bottom edge of the text content area
// and would allow to resize content to clear onscreen keyboard.
textStoryComposerContentLayoutGuideBottomIpad = textStoryComposerView.contentLayoutGuide.bottomAnchor.constraint(
equalTo: textStoryComposerView.bottomAnchor,
constant: -8,
)
textEditoriPadConstraints.append(textStoryComposerContentLayoutGuideBottomIpad!)
// Background and Add Link buttons are vertically centered with CAMERA|TEXT switch and Proceed button.
textEditoriPadConstraints.append(
textEditorToolbar.centerYAnchor.constraint(equalTo: bottomBar.controlButtonsLayoutGuide.centerYAnchor),
)
// Additional constraint that will at least 20 dp between Add Link button and CAMERA|TEXT switch.
// This constraint will override
textEditoriPadConstraints.append(
textEditorToolbar.trailingAnchor.constraint(
lessThanOrEqualTo: bottomBar.contentTypeSelectionControl.leadingAnchor,
constant: -20,
),
)
if isIPadUIInRegularMode {
bottomBar.constrainControlButtonsLayoutGuideHorizontallyTo(
leadingAnchor: textStoryComposerView.leadingAnchor,
trailingAnchor: textStoryComposerView.trailingAnchor,
)
}
view.addConstraints(textEditoriPadConstraints)
}
private func updateTextEditorToolbarVisibility(animated: Bool) {
textEditorToolbar.setIsHidden(textStoryComposerView.isEditing || composerMode != .text, animated: animated)
}
// Update background of the background selection button to match the editor.
private func updateTextBackgroundSelectionButton() {
switch textStoryComposerView.background {
case .color(let color):
textBackgroundSelectionButton.gradientView.colors = [color, color]
case .gradient(let gradient):
textBackgroundSelectionButton.gradientView.colors = gradient.colors
textBackgroundSelectionButton.gradientView.locations = gradient.locations
textBackgroundSelectionButton.gradientView.setAngle(gradient.angle)
}
}
// MARK: - Keyboard Handling
private func startObservingKeyboardNotifications() {
guard !observingKeyboardNotifications else { return }
NotificationCenter.default.addObserver(
self,
selector: #selector(handleKeyboardNotification(_:)),
name: UIResponder.keyboardWillShowNotification,
object: nil,
)
NotificationCenter.default.addObserver(
self,
selector: #selector(handleKeyboardNotification(_:)),
name: UIResponder.keyboardWillHideNotification,
object: nil,
)
NotificationCenter.default.addObserver(
self,
selector: #selector(handleKeyboardNotification(_:)),
name: UIResponder.keyboardWillChangeFrameNotification,
object: nil,
)
observingKeyboardNotifications = true
}
@objc
private func handleKeyboardNotification(_ notification: Notification) {
guard composerMode == .text else { return }
guard let iPhoneConstraint = textStoryComposerContentLayoutGuideBottomIphone else { return }
let iPadConstraint = textStoryComposerContentLayoutGuideBottomIpad
guard
let userInfo = notification.userInfo,
let endFrame = userInfo[UIResponder.keyboardFrameEndUserInfoKey] as? CGRect else { return }
// Detect floating keyboards - those should not adjust bottom inset for text input area.
// Note that floating keyboard could co-exist with iPhone-like layouts.
let keyboardFrame = textStoryComposerView.convert(endFrame, from: nil)
let isNonFloatingKeyboardVisible = keyboardFrame.height > 0 &&
keyboardFrame.minX <= textStoryComposerView.bounds.minX &&
keyboardFrame.maxX >= textStoryComposerView.bounds.maxX
let iPhoneInset: CGFloat
let iPadInset: CGFloat
if isNonFloatingKeyboardVisible {
let convertedKeyboardFrame = textEditorToolbar.convert(keyboardFrame, from: textStoryComposerView)
iPhoneInset = convertedKeyboardFrame.minY - textEditorToolbar.bounds.maxY
iPadInset = keyboardFrame.minY - textStoryComposerView.bounds.maxY
} else {
iPhoneInset = textEditorToolbar.bounds.height
iPadInset = 0
}
let layoutUpdateBlock = {
iPhoneConstraint.constant = min(iPhoneInset, 0) - 8
iPadConstraint?.constant = min(iPadInset, 0) - 8
}
if
let animationDuration = userInfo[UIResponder.keyboardAnimationDurationUserInfoKey] as? TimeInterval,
let rawAnimationCurve = userInfo[UIResponder.keyboardAnimationCurveUserInfoKey] as? Int,
let animationCurve = UIView.AnimationCurve(rawValue: rawAnimationCurve)
{
UIView.animate(
withDuration: animationDuration,
delay: 0,
options: animationCurve.asAnimationOptions,
animations: { [self] in
layoutUpdateBlock()
view.setNeedsLayout()
view.layoutIfNeeded()
},
)
} else {
UIView.performWithoutAnimation {
layoutUpdateBlock()
}
}
}
// MARK: - Background
private class RoundGradientButton: RoundMediaButton {
let gradientView = GradientView(colors: [])
init() {
let gradientCircleView = PillView()
gradientCircleView.isUserInteractionEnabled = false
gradientCircleView.layer.borderWidth = 2
gradientCircleView.layer.borderColor = UIColor.white.cgColor
gradientCircleView.addSubview(gradientView)
gradientCircleView.autoSetDimensions(to: CGSize(square: 28))
gradientView.autoPinEdgesToSuperviewEdges()
super.init(image: nil, backgroundStyle: .blur, customView: gradientCircleView)
ows_contentEdgeInsets = .zero
layoutMargins = .zero
}
override var intrinsicContentSize: CGSize { CGSize(square: 44) }
}
// MARK: - Button Actions
@objc
private func didTapTextBackgroundButton() {
textStoryComposerView.switchToNextBackground()
updateTextBackgroundSelectionButton()
}
@objc
private func didTapAttachLinkPreviewButton() {
let linkPreviewViewController = LinkPreviewAttachmentViewController(textStoryComposerView.linkPreviewDraft)
linkPreviewViewController.delegate = self
present(linkPreviewViewController, animated: true)
}
@objc
private func didTapTextStoryProceedButton() {
let body: StyleOnlyMessageBody
let textStyle: TextAttachment.TextStyle
switch textStoryComposerView.textContent {
case .empty:
body = StyleOnlyMessageBody(plaintext: "")
textStyle = .regular
case .styledRanges(let contentBody):
body = contentBody
textStyle = .regular
case .styled(let text, let style):
body = StyleOnlyMessageBody(plaintext: text)
textStyle = style
}
let textForegroundColor = textStoryComposerView.textForegroundColor
let textBackgroundColor = textStoryComposerView.textBackgroundColor
let background = textStoryComposerView.background
// Styles are used only when forwading; we only get plaintext here.
let unsentTextAttachment = UnsentTextAttachment(
body: body,
textStyle: textStyle,
textForegroundColor: textForegroundColor,
textBackgroundColor: textBackgroundColor,
background: background,
linkPreviewDraft: textStoryComposerView.linkPreviewDraft,
)
delegate?.photoCaptureViewController(self, didFinishWithTextAttachment: unsentTextAttachment)
}
@objc
func didSwipeToCamera(gesture: UISwipeGestureRecognizer) {
guard composerMode == .text else { return }
setComposerMode(.camera, animated: true)
}
}
extension PhotoCaptureViewController: TextStoryComposerViewDelegate {
fileprivate func textStoryComposerDidBeginEditing(_ textStoryComposer: TextStoryComposerView) {
updateBottomBarVisibility(animated: true)
updateTextEditorToolbarVisibility(animated: true)
}
fileprivate func textStoryComposerDidEndEditing(_ textStoryComposer: TextStoryComposerView) {
updateBottomBarVisibility(animated: true)
updateTextEditorToolbarVisibility(animated: true)
}
fileprivate func textStoryComposerDidChange(_ textStoryComposer: TextStoryComposerView) {
bottomBar.proceedButton.isEnabled = !textStoryComposer.isEmpty
}
}
extension PhotoCaptureViewController: LinkPreviewAttachmentViewControllerDelegate {
func linkPreviewAttachmentViewController(
_ viewController: LinkPreviewAttachmentViewController,
didFinishWith linkPreview: OWSLinkPreviewDraft,
) {
textStoryComposerView.linkPreviewDraft = linkPreview
viewController.dismiss(animated: true)
}
}
// MARK: - Button Actions
extension PhotoCaptureViewController {
@objc
private func didTapClose() {
delegate?.photoCaptureViewControllerDidCancel(self)
}
@objc
private func didTapSwitchCamera() {
switchCameraPosition()
}
private func switchCameraPosition() {
if let switchCameraButton = isIPadUIInRegularMode ? sideBar?.switchCameraButton : bottomBar.switchCameraButton {
switchCameraButton.performSwitchAnimation()
}
cameraCaptureSession.switchCameraPosition().done { [weak self] in
self?.updateUIOnCameraPositionChange(animated: true)
self?.cameraCaptureSession.updateVideoCaptureOrientation()
}.catch { error in
self.showFailureUI(error: error)
}
}
@objc
private func didTapFlashMode() {
cameraCaptureSession.toggleFlashMode().done {
self.updateFlashModeControl(animated: true)
}.catch { error in
owsFailDebug("Error: \(error)")
}
}
@objc
private func didTapBatchMode() {
guard let delegate else {
return
}
let targetMode: CaptureMode = {
switch captureMode {
case .single: return .multi
case .multi: return .single
}
}()
delegate.photoCaptureViewController(self, didRequestSwitchCaptureModeTo: targetMode) { approved in
if approved {
self.captureMode = targetMode
self.updateDoneButtonAppearance()
}
}
}
@objc
private func didTapPhotoLibrary() {
delegate?.photoCaptureViewControllerDidRequestPresentPhotoLibrary(self)
}
@objc
private func didTapDoneButton() {
delegate?.photoCaptureViewControllerDidFinish(self)
}
@objc
private func contentTypeChanged() {
guard let newComposerMode = ComposerMode(rawValue: bottomBar.contentTypeSelectionControl.selectedSegmentIndex) else { return }
setComposerMode(newComposerMode, animated: true)
}
}
// MARK: - Camera Gesture Recognizers
extension PhotoCaptureViewController {
private func configureCameraGestures() {
previewView.addGestureRecognizer(UIPinchGestureRecognizer(target: self, action: #selector(didPinchZoom(pinchGesture:))))
let doubleTapToSwitchCameraGesture = UITapGestureRecognizer(target: self, action: #selector(didDoubleTapToSwitchCamera(tapGesture:)))
doubleTapToSwitchCameraGesture.numberOfTapsRequired = 2
previewView.addGestureRecognizer(doubleTapToSwitchCameraGesture)
let tapToFocusGesture = UITapGestureRecognizer(target: self, action: #selector(didTapFocusExpose(tapGesture:)))
tapToFocusGesture.require(toFail: doubleTapToSwitchCameraGesture)
previewView.addGestureRecognizer(tapToFocusGesture)
// Swipe left to switch to text story composer.
if bottomBar.isContentTypeSelectionControlAvailable {
let swipeGesture = UISwipeGestureRecognizer(target: self, action: #selector(didSwipeToTextComposer(gesture:)))
swipeGesture.direction = CurrentAppContext().isRTL ? .right : .left
previewView.addGestureRecognizer(swipeGesture)
}
}
@objc
private func didPinchZoom(pinchGesture: UIPinchGestureRecognizer) {
switch pinchGesture.state {
case .began:
cameraCaptureSession.beginPinchZoom()
fallthrough
case .changed:
cameraCaptureSession.updatePinchZoom(withScale: pinchGesture.scale)
case .ended:
cameraCaptureSession.completePinchZoom(withScale: pinchGesture.scale)
default:
break
}
}
@objc
private func didDoubleTapToSwitchCamera(tapGesture: UITapGestureRecognizer) {
guard !isRecordingVideo else {
// - Orientation gets out of sync when switching cameras mid movie.
// - Audio gets out of sync when switching cameras mid movie
// https://stackoverflow.com/questions/13951182/audio-video-out-of-sync-after-switch-camera
return
}
switchCameraPosition()
}
@objc
private func didTapFocusExpose(tapGesture: UITapGestureRecognizer) {
let viewLocation = tapGesture.location(in: previewView)
let devicePoint = previewView.previewLayer.captureDevicePointConverted(fromLayerPoint: viewLocation)
cameraCaptureSession.focus(with: .autoFocus, exposureMode: .autoExpose, at: devicePoint, monitorSubjectAreaChange: true)
lastUserFocusTapPoint = devicePoint
if let focusFrameSuperview = tapToFocusView.superview {
positionTapToFocusView(center: tapGesture.location(in: focusFrameSuperview))
startFocusAnimation()
}
}
@objc
private func didSwipeToTextComposer(gesture: UISwipeGestureRecognizer) {
guard composerMode == .camera else { return }
guard bottomBar.captureControl.state == .initial else { return }
setComposerMode(.text, animated: true)
}
}
// MARK: - Tap to Focus
extension PhotoCaptureViewController {
private func positionTapToFocusView(center: CGPoint) {
tapToFocusCenterXConstraint.constant = center.x
tapToFocusCenterYConstraint.constant = center.y
}
private func startFocusAnimation() {
tapToFocusView.stop()
tapToFocusView.play(fromProgress: 0.0, toProgress: 0.9)
}
private func completeFocusAnimation(forFocusPoint focusPoint: CGPoint) {
guard let lastUserFocusTapPoint else { return }
guard lastUserFocusTapPoint.within(0.005, of: focusPoint) else {
return
}
tapToFocusView.play(toProgress: 1.0)
}
}
// MARK: - Photo Capture
extension PhotoCaptureViewController {
private func setupPhotoCapture() {
bottomBar.captureControl.delegate = cameraCaptureSession
if let sideBar {
sideBar.cameraCaptureControl.delegate = cameraCaptureSession
}
// If the session is already running, we're good to go.
guard !cameraCaptureSession.avCaptureSession.isRunning else {
self.hasCameraStarted = true
return
}
firstly {
cameraCaptureSession.prepare()
}.catch { [weak self] error in
guard let self else { return }
self.showFailureUI(error: error)
}
}
private func pausePhotoCapture() {
guard cameraCaptureSession.avCaptureSession.isRunning else { return }
cameraCaptureSession.stop().done { [weak self] in
self?.hasCameraStarted = false
}.catch { [weak self] error in
self?.showFailureUI(error: error)
}
}
private func resumePhotoCapture() {
guard !cameraCaptureSession.avCaptureSession.isRunning else { return }
cameraCaptureSession.resume().done { [weak self] in
self?.hasCameraStarted = true
}.catch { [weak self] error in
self?.showFailureUI(error: error)
}
}
private func showFailureUI(error: Error) {
Logger.warn("error: \(error)")
OWSActionSheets.showActionSheet(
title: nil,
message: error.userErrorDescription,
buttonTitle: CommonStrings.dismissButton,
buttonAction: { [weak self] _ in self?.dismiss(animated: true) },
)
}
private func updateFlashModeControl(animated: Bool) {
topBar.flashModeButton.setFlashMode(cameraCaptureSession.flashMode, animated: animated)
if let sideBar {
sideBar.flashModeButton.setFlashMode(cameraCaptureSession.flashMode, animated: animated)
}
}
}
extension PhotoCaptureViewController: InteractiveDismissDelegate {
func interactiveDismissDidBegin(_ interactiveDismiss: UIPercentDrivenInteractiveTransition) {
view.backgroundColor = .clear
}
func interactiveDismiss(
_ interactiveDismiss: UIPercentDrivenInteractiveTransition,
didChangeProgress: CGFloat,
touchOffset: CGPoint,
) { }
func interactiveDismissDidFinish(_ interactiveDismiss: UIPercentDrivenInteractiveTransition) {
dismiss(animated: true)
}
func interactiveDismissDidCancel(_ interactiveDismiss: UIPercentDrivenInteractiveTransition) {
view.backgroundColor = Theme.darkThemeBackgroundColor
}
}
extension PhotoCaptureViewController: CameraZoomSelectionControlDelegate {
func cameraZoomControl(_ cameraZoomControl: CameraZoomSelectionControl, didSelect camera: CameraCaptureSession.CameraType) {
let position: AVCaptureDevice.Position = cameraZoomControl == frontCameraZoomControl ? .front : .back
cameraCaptureSession.switchCamera(to: camera, at: position, animated: true)
}
func cameraZoomControl(_ cameraZoomControl: CameraZoomSelectionControl, didChangeZoomFactor zoomFactor: CGFloat) {
cameraCaptureSession.changeVisibleZoomFactor(to: zoomFactor, animated: true)
}
}
// MARK: - QRCodeSampleBufferScannerDelegate
extension PhotoCaptureViewController: QRCodeSampleBufferScannerDelegate {
var shouldProcessQRCodes: Bool {
_shouldProcessQRCodes.get()
}
func qrCodeFound(string qrCodeString: String?, data qrCodeData: Data?) {
guard let qrCodeString else {
return
}
if
let url = URL(string: qrCodeString),
let usernameLink = Usernames.UsernameLink(usernameLinkUrl: url)
{
qrCodeScanned = true
Task {
guard
let (username, aci) = await UsernameQuerier().queryForUsernameLink(
link: usernameLink,
fromViewController: self,
failureSheetDismissalDelegate: self,
)
else {
return
}
showUsernameLinkSheet(username: username, aci: aci)
}
} else if let provisioningURL = DeviceProvisioningURL(urlString: qrCodeString) {
let tsAccountManager = DependenciesBridge.shared.tsAccountManager
let registeredState = try? tsAccountManager.registeredStateWithMaybeSneakyTransaction()
switch provisioningURL.linkType {
case .linkDevice where registeredState?.isPrimary == true:
qrCodeScanned = true
let linkDeviceWarningActionSheet = ActionSheetController(
message: OWSLocalizedString(
"LINKED_DEVICE_URL_OPENED_ACTION_SHEET_IN_APP_CAMERA_MESSAGE",
comment: "Message for an action sheet telling users how to link a device, when trying to open a device-linking URL from the in-app camera.",
),
)
let showLinkedDevicesAction = ActionSheetAction(title: CommonStrings.continueButton) { _ in
self.dismiss(animated: true) {
SignalApp.shared.showAppSettings(mode: .linkedDevices)
}
}
let cancelAction = ActionSheetAction(title: CommonStrings.cancelButton) { _ in
self.qrCodeScanned = false
}
linkDeviceWarningActionSheet.addAction(showLinkedDevicesAction)
linkDeviceWarningActionSheet.addAction(cancelAction)
presentActionSheet(linkDeviceWarningActionSheet)
case .quickRestore:
qrCodeScanned = true
let presentBlock = {
self.dismiss(animated: true) {
AppEnvironment.shared.outgoingDeviceRestorePresenter.present(
provisioningURL: provisioningURL,
presentingViewController: CurrentAppContext().frontmostViewController()!,
animated: true,
)
}
}
// If anything is presented over the phone capture view, dismiss it first -
// then dismiss the photo view and present the restore UI
if navigationController?.presentedViewController != nil {
self.navigationController?.presentedViewController?.dismiss(animated: true) {
presentBlock()
}
} else {
presentBlock()
}
case .linkDevice:
Logger.warn("Scanned linkDevice provisioning URL, but not a registered primary.")
}
}
}
func scanFailed(error: Error) {
self.showFailureUI(error: error)
}
private func showUsernameLinkSheet(
username: String,
aci: Aci,
) {
// `shouldProcessQRCodes` should prevent QR codes being scanned after a
// recording is done, but a race condition between the recording ending
// and this view hiding can allow a scan to slip through, so do an extra
// check after the username is queried before showing the sheet.
guard isViewVisible else { return }
OWSActionSheets.showConfirmationAlert(
title: String.nonPluralLocalizedStringWithFormat(
OWSLocalizedString(
"PHOTO_CAPTURE_USERNAME_QR_CODE_FOUND_TITLE_FORMAT",
comment: "Title for sheet presented from photo capture view indicating that a username QR code was found. Embeds {{username}}.",
),
username,
),
message: String.nonPluralLocalizedStringWithFormat(
OWSLocalizedString(
"PHOTO_CAPTURE_USERNAME_QR_CODE_FOUND_MESSAGE_FORMAT",
comment: "Message for a sheet presented from photo capture view indicating that a username QR code was found. Embeds {{username}}.",
),
username,
),
proceedTitle: OWSLocalizedString(
"PHOTO_CAPTURE_USERNAME_QR_CODE_FOUND_CTA",
comment: "Button label for opening the chat on a sheet presented from photo capture view indicating that a username QR code was found.",
),
proceedAction: { [weak self] _ in
SignalApp.shared.presentConversationForAddress(
SignalServiceAddress(aci),
animated: false,
)
self?.dismiss(animated: true)
},
fromViewController: self,
dismissalDelegate: self,
)
}
}
// MARK: - SheetDismissalDelegate
extension PhotoCaptureViewController: SheetDismissalDelegate {
func didDismissPresentedSheet() {
// Allow another QR code to be scanned
qrCodeScanned = false
}
}
// MARK: - CameraCaptureSessionDelegate
extension PhotoCaptureViewController: CameraCaptureSessionDelegate {
// MARK: - Photo
func cameraCaptureSessionDidStart(_ session: CameraCaptureSession) {
let captureFeedbackView = UIView()
captureFeedbackView.backgroundColor = .black
view.insertSubview(captureFeedbackView, aboveSubview: previewView)
captureFeedbackView.autoPinEdgesToSuperviewEdges()
// Ensure the capture feedback is laid out before we remove it,
// depending on where we're coming from a layout pass might not
// trigger in 0.05 seconds otherwise.
view.setNeedsLayout()
view.layoutIfNeeded()
DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) {
captureFeedbackView.removeFromSuperview()
}
}
func cameraCaptureSession(_ session: CameraCaptureSession, didFinishProcessing attachment: PreviewableAttachment) {
dataSource?.addMedia(attachment: attachment)
updateDoneButtonAppearance()
if captureMode == .multi {
resumePhotoCapture()
} else {
delegate?.photoCaptureViewControllerDidFinish(self)
}
}
func cameraCaptureSession(_ session: CameraCaptureSession, didFailWith error: Error) {
setIsRecordingVideo(false, animated: true)
if error is VideoCaptureFailedError {
// Don't show an error if the user aborts recording before video
// recording has begun.
return
}
showFailureUI(error: error)
}
func cameraCaptureSessionCanCaptureMoreItems(_ session: CameraCaptureSession) -> Bool {
return delegate?.photoCaptureViewControllerCanCaptureMoreItems(self) ?? false
}
func photoCaptureDidTryToCaptureTooMany(_ session: CameraCaptureSession) {
delegate?.photoCaptureViewControllerDidTryToCaptureTooMany(self)
}
// MARK: - Video
func cameraCaptureSessionWillStartVideoRecording(_ session: CameraCaptureSession) {
setIsRecordingVideo(true, animated: true)
}
func cameraCaptureSessionDidStartVideoRecording(_ session: CameraCaptureSession) {
}
func cameraCaptureSessionDidStopVideoRecording(_ session: CameraCaptureSession) {
setIsRecordingVideo(false, animated: true)
}
func cameraCaptureSession(_ session: CameraCaptureSession, videoRecordingDurationChanged duration: TimeInterval) {
topBar.recordingTimerView.duration = duration
}
// MARK: -
var zoomScaleReferenceDistance: CGFloat? {
if isIPadUIInRegularMode {
return previewView.bounds.width / 2
}
return previewView.bounds.height / 2
}
func cameraCaptureSession(_ session: CameraCaptureSession, didChangeZoomFactor zoomFactor: CGFloat, forCameraPosition position: AVCaptureDevice.Position) {
guard let cameraZoomControl = position == .front ? frontCameraZoomControl : rearCameraZoomControl else { return }
cameraZoomControl.currentZoomFactor = zoomFactor
}
func beginCaptureButtonAnimation(_ duration: TimeInterval) {
bottomBar.captureControl.setState(.recording, animationDuration: duration)
if let sideBar {
sideBar.cameraCaptureControl.setState(.recording, animationDuration: duration)
}
}
func endCaptureButtonAnimation(_ duration: TimeInterval) {
bottomBar.captureControl.setState(.initial, animationDuration: duration)
if let sideBar {
sideBar.cameraCaptureControl.setState(.initial, animationDuration: duration)
}
}
func cameraCaptureSession(_ session: CameraCaptureSession, didChangeOrientation orientation: AVCaptureVideoOrientation) {
updateIconOrientations(isAnimated: true, captureOrientation: orientation)
if UIDevice.current.isIPad {
session.updateVideoPreviewConnection(toOrientation: orientation)
}
}
func cameraCaptureSession(_ session: CameraCaptureSession, didFinishFocusingAt focusPoint: CGPoint) {
completeFocusAnimation(forFocusPoint: focusPoint)
}
@objc
func sessionWasInterrupted(notification: Notification) {
if let userInfo = notification.userInfo {
guard
let reasonValue = userInfo[AVCaptureSessionInterruptionReasonKey] as? NSNumber,
let reason = AVCaptureSession.InterruptionReason(rawValue: reasonValue.intValue)
else {
Logger.info("session was interrupted for no apparent reason")
return
}
Logger.info("session was interrupted with reason code: \(reason.rawValue)")
}
}
}
private protocol TextStoryComposerViewDelegate: AnyObject {
func textStoryComposerDidBeginEditing(_ textStoryComposer: TextStoryComposerView)
func textStoryComposerDidEndEditing(_ textStoryComposer: TextStoryComposerView)
func textStoryComposerDidChange(_ textStoryComposer: TextStoryComposerView)
}
private class TextStoryComposerView: TextAttachmentView, UITextViewDelegate {
weak var delegate: TextStoryComposerViewDelegate?
init(text: String) {
super.init(
text: text,
style: .regular,
textForegroundColor: .white,
textBackgroundColor: nil,
background: TextStoryComposerView.defaultBackground,
)
// Placeholder Label
textPlaceholderLabel.translatesAutoresizingMaskIntoConstraints = false
addSubview(textPlaceholderLabel)
addConstraints([
textPlaceholderLabel.leadingAnchor.constraint(equalTo: contentLayoutGuide.leadingAnchor),
textPlaceholderLabel.topAnchor.constraint(equalTo: contentLayoutGuide.topAnchor),
textPlaceholderLabel.trailingAnchor.constraint(equalTo: contentLayoutGuide.trailingAnchor),
textPlaceholderLabel.bottomAnchor.constraint(equalTo: contentLayoutGuide.bottomAnchor),
])
// Prepare text styling toolbar - attached to keyboard.
let toolbarSize = textViewAccessoryToolbar.systemLayoutSizeFitting(
CGSize(width: UIScreen.main.bounds.width, height: .greatestFiniteMagnitude),
withHorizontalFittingPriority: .required,
verticalFittingPriority: .fittingSizeLevel,
)
textViewAccessoryToolbar.bounds.size = toolbarSize
textView.inputAccessoryView = textViewAccessoryToolbar
// Text View
textViewBackgroundView.layer.cornerRadius = LayoutConstants.textBackgroundCornerRadius
textViewBackgroundView.addSubview(textView)
addSubview(textViewBackgroundView)
updateTextViewAttributes()
updateVisibilityOfComponents(animated: false)
addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(placeholderTapped)))
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
// Slightly smaller vertical margins for UITextView because UITextView
// has larger embedded padding above and below the text.
private static let textViewBackgroundVMargin = LayoutConstants.textBackgroundVMargin - 8
private static let textViewBackgroundHMargin = LayoutConstants.textBackgroundHMargin
override func layoutSubviews() {
super.layoutSubviews()
let contentWidth = layoutMarginsGuide.layoutFrame.width
if
let contentWidthConstraint = textViewAccessoryToolbar.contentWidthConstraint,
contentWidthConstraint.constant != contentWidth,
contentWidth > 0
{
contentWidthConstraint.constant = contentWidth
}
}
override func layoutTextContentAndLinkPreview() {
super.layoutTextContentAndLinkPreview()
var textViewSize = textContentSize
// Min dimensions for an empty text view.
textViewSize.width = max(textViewSize.width, 20)
textViewSize.height = max(textViewSize.height, 48)
// Limit text view height to available content height, deducting link preview area height if needed.
var linkPreviewAreaHeight: CGFloat = 0
if linkPreviewView != nil {
linkPreviewAreaHeight = linkPreviewWrapperView.frame.height + LayoutConstants.linkPreviewAreaTopMargin
}
textViewSize.height = min(
textViewSize.height,
contentLayoutGuide.layoutFrame.height - linkPreviewAreaHeight - 2 * TextStoryComposerView.textViewBackgroundVMargin,
)
// Enable / disable vertical text scrolling if all text doesn't fit the available screen space.
if textContentSize.height > textViewSize.height {
textView.isScrollEnabled = true
} else {
textView.isScrollEnabled = false
}
textView.bounds.size = textViewSize
textViewBackgroundView.bounds.size = CGSize(
width: textViewSize.width + 2 * TextStoryComposerView.textViewBackgroundHMargin,
height: textViewSize.height + 2 * TextStoryComposerView.textViewBackgroundVMargin,
)
textViewBackgroundView.center = CGPoint(
x: contentLayoutGuide.layoutFrame.center.x,
y: contentLayoutGuide.layoutFrame.center.y - 0.5 * linkPreviewAreaHeight,
)
textView.center = textViewBackgroundView.bounds.center
linkPreviewWrapperView.center = CGPoint(
x: linkPreviewWrapperView.center.x,
y: textViewBackgroundView.frame.maxY + LayoutConstants.linkPreviewAreaTopMargin + 0.5 * linkPreviewWrapperView.bounds.height,
)
}
override func calculateTextContentSize() -> CGSize {
guard isEditing else {
return super.calculateTextContentSize()
}
let maxTextViewSize = contentLayoutGuide.layoutFrame.insetBy(
dx: LayoutConstants.textBackgroundHMargin,
dy: TextStoryComposerView.textViewBackgroundVMargin,
).size
return textView.systemLayoutSizeFitting(
maxTextViewSize,
withHorizontalFittingPriority: .required,
verticalFittingPriority: .fittingSizeLevel,
)
}
// MARK: -
override var isEditing: Bool { textView.isFirstResponder }
private var text: String? {
get {
switch super.textContent {
case .empty:
return nil
case .styledRanges(let body):
owsFailDebug("Should not have styled ranges in story text composer")
return body.text
case .styled(let body, _):
return body
}
}
set {
super.textContent = .styled(body: newValue ?? "", style: textStyle)
}
}
private var textStyle: TextAttachment.TextStyle = .regular {
didSet {
guard let text else {
return
}
super.textContent = .styled(body: text, style: self.textStyle)
}
}
var isEmpty: Bool {
guard let text else { return true }
return text.isEmpty && linkPreview == nil
}
// MARK: - Text View
private lazy var textView: MediaTextView = {
let textView = MediaTextView()
textView.delegate = self
textView.showsVerticalScrollIndicator = false
return textView
}()
private let textViewBackgroundView = UIView()
private lazy var textViewAccessoryToolbar: TextStylingToolbar = {
let toolbar = TextStylingToolbar()
toolbar.preservesSuperviewLayoutMargins = true
toolbar.addTarget(self, action: #selector(didChangeTextColor), for: .valueChanged)
toolbar.textStyleButton.addTarget(self, action: #selector(didTapTextStyleButton), for: .touchUpInside)
toolbar.decorationStyleButton.addTarget(self, action: #selector(didTapDecorationStyleButton), for: .touchUpInside)
toolbar.doneButton.addTarget(self, action: #selector(didTapTextViewDoneButton), for: .touchUpInside)
return toolbar
}()
private let textPlaceholderLabel: UILabel = {
let label = UILabel()
label.textAlignment = .center
label.numberOfLines = 0
label.textColor = .ows_whiteAlpha60
label.font = .dynamicTypeLargeTitle1Clamped
label.text = OWSLocalizedString(
"STORY_COMPOSER_TAP_ADD_TEXT",
comment: "Placeholder text in text stories compose UI",
)
return label
}()
override func updateVisibilityOfComponents(animated: Bool) {
super.updateVisibilityOfComponents(animated: animated)
let isEditing = isEditing
textPlaceholderLabel.setIsHidden(isEditing || !isEmpty, animated: animated)
textViewBackgroundView.setIsHidden(!isEditing, animated: animated)
}
private func updateTextViewAttributes() {
let selectedTextRange = textView.selectedTextRange
let text = text ?? ""
textView.text = transformedText(text, for: textStyle)
let (fontPointSize, textAlignment) = sizeAndAlignment(forText: text)
textView.updateWith(
textForegroundColor: textForegroundColor,
font: .font(for: textStyle, withPointSize: fontPointSize),
textAlignment: textAlignment,
textDecorationColor: nil,
decorationStyle: .none,
)
textView.selectedTextRange = selectedTextRange
textViewBackgroundView.backgroundColor = textBackgroundColor
}
private func adjustFontSizeIfNecessary() {
guard let currentFontSize = textView.font?.pointSize else { return }
let text = text?.stripped ?? ""
let desiredFontSize = sizeAndAlignment(forText: text).fontPointSize
guard desiredFontSize != currentFontSize else { return }
updateTextAttributes()
updateTextViewAttributes()
}
private func validateTextViewAttributes() {
guard let attributedString = textView.attributedText else { return }
// Re-apply attributes to the entire text view's text if more than one font style is detected.
// That could happen as a result of undo / redo operation.
var shouldReapplyAttributes = false
var previousFont: UIFont?
attributedString.enumerateAttribute(.font, in: attributedString.entireRange) { attributeValue, range, stop in
guard let font = attributeValue as? UIFont else { return }
if let previousFont, !previousFont.isEqual(font) {
shouldReapplyAttributes = true
stop.pointee = true
}
previousFont = font
}
if shouldReapplyAttributes {
updateTextViewAttributes()
}
}
@objc
private func placeholderTapped() {
if textView.isFirstResponder {
textView.acceptAutocorrectSuggestion()
textView.resignFirstResponder()
} else {
textView.becomeFirstResponder()
}
}
@objc
private func didTapTextStyleButton() {
let textStyle = textViewAccessoryToolbar.textStyle.next()
textViewAccessoryToolbar.textStyle = textStyle
self.textStyle = {
switch textStyle {
case .regular: return .regular
case .bold: return .bold
case .serif: return .serif
case .script: return .script
case .condensed: return .condensed
}
}()
updateTextViewAttributes()
}
@objc
private func didTapDecorationStyleButton() {
// "Underline" and "Outline" are not available in text story composer.
var decorationStyle = textViewAccessoryToolbar.decorationStyle.next()
if decorationStyle == .outline || decorationStyle == .underline {
decorationStyle = .none
}
textViewAccessoryToolbar.decorationStyle = decorationStyle
// `textViewAccessoryToolbar` defines both foreground and background color for text based on the decoration style.
let textForegroundColor = textViewAccessoryToolbar.textForegroundColor
let textBackgroundColor = textViewAccessoryToolbar.textBackgroundColor
setTextForegroundColor(textForegroundColor, backgroundColor: textBackgroundColor)
updateTextViewAttributes()
}
@objc
private func didChangeTextColor() {
// Depending on text decoration style color picker changes either color of the text or background color.
// That's why we need to update both.
let textForegroundColor = textViewAccessoryToolbar.textForegroundColor
let textBackgroundColor = textViewAccessoryToolbar.textBackgroundColor
setTextForegroundColor(textForegroundColor, backgroundColor: textBackgroundColor)
updateTextViewAttributes()
}
@objc
private func didTapTextViewDoneButton() {
textView.acceptAutocorrectSuggestion()
textView.resignFirstResponder()
}
// MARK: - UITextViewDelegate
func textViewDidBeginEditing(_ textView: UITextView) {
updateVisibilityOfComponents(animated: true)
delegate?.textStoryComposerDidBeginEditing(self)
setNeedsLayout()
}
func textViewDidEndEditing(_ textView: UITextView) {
text = text?.stripped
textView.text = text
updateTextAttributes()
updateVisibilityOfComponents(animated: true)
delegate?.textStoryComposerDidEndEditing(self)
}
private var updatingTextViewText = false
func textView(_ textView: UITextView, shouldChangeTextIn range: NSRange, replacementText: String) -> Bool {
guard !updatingTextViewText else { return false }
let originalInput = text ?? ""
let (shouldChange, changedString) = TextHelper.shouldChangeCharactersInRange(
with: originalInput,
editingRange: range,
replacementString: replacementText,
maxGlyphCount: 700,
)
if let changedString {
text = changedString
textView.text = transformedText(changedString, for: textStyle)
textView.delegate?.textViewDidChange?(textView)
return false
}
guard shouldChange else {
return false
}
text = (originalInput as NSString).replacingCharacters(in: range, with: replacementText)
let transformedText = transformedText(text ?? "", for: textStyle)
guard text == transformedText else {
// If this method is called as a result of using apple's autocomplete suggestion bar
// there is a bug where setting the UITextView's text will trigger another call of this delegate
// method. Inputting text any other way suppresses calls to this delegate method as a result
// of changes to the text within the method itself. To work around this apple bug, keep track of
// re-entrancy manually and suppress it ourselves.
updatingTextViewText = true
textView.text = transformedText
textView.delegate?.textViewDidChange?(textView)
updatingTextViewText = false
return false
}
return true
}
func textViewDidChange(_ textView: UITextView) {
// If you swipe type, a space is inserted between words, by putting that space
// before the subsequent word in the `shouldChangeTextIn: range:` method.
// If you swipe type and then tap a single letter, `shouldChangeTextIn:` only gets
// the letters, NOT the space, but the NSConcreteTextStorage _somehow_ gets that
// space. In order to avoid this leading to discrepancies between `self.text` and
// the text being displayed, we sync the two up here, after the space has been applied.
self.text = transformedText(textView.text ?? "", for: textStyle)
adjustFontSizeIfNecessary()
validateTextViewAttributes()
delegate?.textStoryComposerDidChange(self)
setNeedsLayout()
}
// MARK: - Link Preview
fileprivate var linkPreviewDraft: OWSLinkPreviewDraft? {
didSet {
if let linkPreviewDraft {
let state: LinkPreviewState
if let callLink = CallLink(url: linkPreviewDraft.url) {
state = LinkPreviewCallLink(previewType: .draft(linkPreviewDraft), callLink: callLink)
} else {
state = LinkPreviewDraft(linkPreviewDraft: linkPreviewDraft)
}
linkPreview = state
} else {
linkPreview = nil
}
delegate?.textStoryComposerDidChange(self)
}
}
private lazy var deleteLinkPreviewButton: UIButton = {
let button = RoundMediaButton(image: Theme.iconImage(.buttonX), backgroundStyle: .blurLight)
button.tintColor = Theme.lightThemePrimaryColor
button.ows_contentEdgeInsets = UIEdgeInsets(margin: 8)
button.layoutMargins = UIEdgeInsets(margin: 2)
button.translatesAutoresizingMaskIntoConstraints = false
button.addTarget(self, action: #selector(didTapDeleteLinkPreviewButton), for: .touchUpInside)
return button
}()
override func reloadLinkPreviewAppearance() {
super.reloadLinkPreviewAppearance()
guard let linkPreviewView else { return }
if deleteLinkPreviewButton.superview == nil {
linkPreviewWrapperView.addSubview(deleteLinkPreviewButton)
}
linkPreviewWrapperView.bringSubviewToFront(deleteLinkPreviewButton)
linkPreviewWrapperView.addConstraints([
deleteLinkPreviewButton.centerXAnchor.constraint(equalTo: linkPreviewView.trailingAnchor, constant: -5),
deleteLinkPreviewButton.centerYAnchor.constraint(equalTo: linkPreviewView.topAnchor, constant: 5),
])
updateVisibilityOfComponents(animated: true)
}
@objc
private func didTapDeleteLinkPreviewButton() {
linkPreviewDraft = nil
}
// MARK: - Background
private var currentBackgroundIndex = 0 {
didSet {
background = TextStoryComposerView.textBackgrounds[currentBackgroundIndex]
}
}
private static var defaultBackground: TextAttachment.Background { textBackgrounds[0] }
private static var textBackgrounds: [TextAttachment.Background] = [
.color(.init(rgbHex: 0x688BD4)),
.color(.init(rgbHex: 0x8687C1)),
.color(.init(rgbHex: 0xB47F8C)),
.color(.init(rgbHex: 0x899188)),
.color(.init(rgbHex: 0x539383)),
.gradient(.init(colors: [.init(rgbHex: 0x19A9FA), .init(rgbHex: 0x7097D7), .init(rgbHex: 0xD1998D), .init(rgbHex: 0xFFC369)])),
.gradient(.init(colors: [.init(rgbHex: 0x4437D8), .init(rgbHex: 0x6B70DE), .init(rgbHex: 0xB774E0), .init(rgbHex: 0xFF8E8E)])),
.gradient(.init(colors: [.init(rgbHex: 0x004044), .init(rgbHex: 0x2C5F45), .init(rgbHex: 0x648E52), .init(rgbHex: 0x93B864)])),
]
func switchToNextBackground() {
var nextBackgroundIndex = currentBackgroundIndex + 1
if nextBackgroundIndex > TextStoryComposerView.textBackgrounds.count - 1 {
nextBackgroundIndex = 0
}
currentBackgroundIndex = nextBackgroundIndex
}
}