Signal-iOS/SignalUI/Views/ImageEditor/ImageEditorViewController.swift
Igor Solomennikov 2fd17eda12 Animate controls on the crop screens in and out.
This change also fixes a bug when bottom buttons in the crop screen
were not visible. That happened because ImageEditorBottomBar by default
had its controls hidden (with the intent that they are animated in once
view controller's view is visible). That was a poor design choice on my
end and is fixed in this commit.
2022-07-07 08:37:34 -07:00

657 lines
23 KiB
Swift

//
// Copyright (c) 2022 Open Whisper Systems. All rights reserved.
//
import SignalCoreKit
import SignalMessaging
import UIKit
// Base class for all tool view controllers.
class ImageEditorViewController: OWSViewController {
let model: ImageEditorModel
// We only want to let users undo changes made in this view.
// So we snapshot any older "operation id" and prevent
// users from undoing it.
private let firstUndoOperationId: String?
let imageEditorView: ImageEditorView
let topBar = ImageEditorTopBar()
lazy var bottomBar: ImageEditorBottomBar = ImageEditorBottomBar(buttonProvider: self)
enum Mode: Int {
case draw = 1
case blur
case text
}
var mode: Mode = .draw {
didSet {
if oldValue != mode && isViewLoaded {
updateUIForCurrentMode()
}
}
}
/**
* Returns maximum width for the area with tool-specific UI elements in the toolbar at the bottom.
* Such tool-specific elements are: color picker (for both text and drawing tools), text style selection button etc.
* This maximum width is calculated as:
* iPhone: screen width in portrait orientation minus standard horizontal margins.
* iPad: value from iPhone 13 Max (428 - 2x20)
*/
static let preferredToolbarContentWidth: CGFloat = {
if UIDevice.current.isIPad {
return 388
} else {
let screenWidth = min(UIScreen.main.bounds.width, UIScreen.main.bounds.height)
let inset: CGFloat = UIDevice.current.isPlusSizePhone ? 20 : 16
return screenWidth - 2*inset
}
}()
// Pen Tool UI
var drawToolUIInitialized = false
lazy var drawToolbar: DrawToolbar = {
let toolbar = DrawToolbar(currentColor: model.color)
toolbar.preservesSuperviewLayoutMargins = true
toolbar.paletteView.delegate = self
toolbar.strokeTypeButton.addTarget(self, action: #selector(strokeTypeButtonTapped(sender:)), for: .touchUpInside)
return toolbar
}()
lazy var drawToolGestureRecognizer: ImageEditorPanGestureRecognizer = {
let gestureRecognizer = ImageEditorPanGestureRecognizer(target: self, action: #selector(handleDrawToolGesture(_:)))
gestureRecognizer.maximumNumberOfTouches = 1
gestureRecognizer.referenceView = imageEditorView.gestureReferenceView
gestureRecognizer.delegate = self
return gestureRecognizer
}()
// Blur Tool UI
var blurToolUIInitialized = false
lazy var blurToolbar: UIStackView = {
let drawAnywhereHint = UILabel()
drawAnywhereHint.font = .ows_dynamicTypeCaption1
drawAnywhereHint.textColor = Theme.darkThemePrimaryColor
drawAnywhereHint.textAlignment = .center
drawAnywhereHint.numberOfLines = 0
drawAnywhereHint.lineBreakMode = .byWordWrapping
drawAnywhereHint.text = OWSLocalizedString("IMAGE_EDITOR_BLUR_HINT",
comment: "The image editor hint that you can draw blur")
drawAnywhereHint.layer.shadowColor = UIColor.black.cgColor
drawAnywhereHint.layer.shadowRadius = 2
drawAnywhereHint.layer.shadowOpacity = 0.66
drawAnywhereHint.layer.shadowOffset = .zero
let stackView = UIStackView()
stackView.alignment = .center
stackView.axis = .vertical
stackView.spacing = 14
stackView.addArrangedSubviews([ faceBlurContainer, drawAnywhereHint ])
return stackView
}()
lazy var faceBlurContainer: UIView = {
let containerView = PillView()
containerView.layoutMargins = UIEdgeInsets(top: 6, leading: 16, bottom: 6, trailing: 8)
let blurBackgroundView = UIVisualEffectView(effect: UIBlurEffect(style: .dark))
containerView.addSubview(blurBackgroundView)
blurBackgroundView.autoPinEdgesToSuperviewEdges()
let autoBlurLabel = UILabel()
autoBlurLabel.text = OWSLocalizedString("IMAGE_EDITOR_BLUR_SETTING",
comment: "The image editor setting to blur faces")
autoBlurLabel.font = .ows_dynamicTypeSubheadlineClamped
autoBlurLabel.textColor = Theme.darkThemePrimaryColor
let stackView = UIStackView(arrangedSubviews: [ autoBlurLabel, faceBlurSwitch ])
stackView.spacing = 12
stackView.alignment = .center
stackView.axis = .horizontal
containerView.addSubview(stackView)
stackView.autoPinEdgesToSuperviewMargins()
return containerView
}()
lazy var faceBlurSwitch: UISwitch = {
let faceBlurSwitch = UISwitch()
faceBlurSwitch.addTarget(self, action: #selector(didToggleAutoBlur), for: .valueChanged)
faceBlurSwitch.isOn = currentAutoBlurItem != nil
return faceBlurSwitch
}()
lazy var blurToolGestureRecognizer: ImageEditorPanGestureRecognizer = {
let gestureRecognizer = ImageEditorPanGestureRecognizer(target: self, action: #selector(handleBlurToolGesture(_:)))
gestureRecognizer.maximumNumberOfTouches = 1
gestureRecognizer.referenceView = imageEditorView.gestureReferenceView
gestureRecognizer.delegate = self
return gestureRecognizer
}()
// We persist an auto blur identifier for this session so
// we can keep the toggle switch in sync with undo/redo behavior
static let autoBlurItemIdentifier = "autoBlur"
var currentAutoBlurItem: ImageEditorBlurRegionsItem? {
return model.item(forId: ImageEditorViewController.autoBlurItemIdentifier) as? ImageEditorBlurRegionsItem
}
// Pen / Blur Drawing
lazy var strokeWidthSlider: ImageEditorSlider = {
let slider = ImageEditorSlider()
slider.minimumValue = 0.2
slider.maximumValue = 2
slider.value = 1
slider.addTarget(self, action: #selector(handleSliderTouchEvents(slider:)), for: .allTouchEvents)
slider.addTarget(self, action: #selector(handleSliderValueChanged(slider:)), for: .valueChanged)
return slider
}()
lazy var strokeWidthSliderContainer = UIView()
lazy var strokeWidthPreviewDot: UIView = {
let view = CircleView()
view.layer.borderColor = UIColor.white.cgColor
view.layer.borderWidth = 2
strokeWidthPreviewDotSize = view.autoSetDimension(.width, toSize: 20)
view.autoPinToSquareAspectRatio()
return view
}()
var strokeWidthPreviewDotSize: NSLayoutConstraint?
var strokeWidthSliderIsTrackingObservation: NSKeyValueObservation?
var strokeWidthSliderRevealed = false
var hideStrokeWidthSliderTimer: Timer?
var strokeWidthSliderPosition: NSLayoutConstraint?
var strokeWidthValues: [ImageEditorStrokeItem.StrokeType: Float] = [:]
var currentStrokeType: ImageEditorStrokeItem.StrokeType = .pen {
didSet {
updateStrokeWidthSliderValue()
updateStrokeWidthPreviewSize()
updateStrokeWidthPreviewColor()
}
}
var currentStroke: ImageEditorStrokeItem? {
didSet {
updateControlsVisibility(animated: true)
updateTopBar()
}
}
var currentStrokeSamples = [ImageEditorStrokeItem.StrokeSample]()
func currentStrokeUnitWidth() -> CGFloat {
let defaultUnitStrokeWidth = ImageEditorStrokeItem.defaultUnitStrokeWidth(forStrokeType: currentStrokeType)
return defaultUnitStrokeWidth * CGFloat(strokeWidthSlider.value) / model.currentTransform().scaling
}
// Text UI
var textUIInitialized = false
var startEditingTextOnViewAppear = false
var currentTextItem: (textItem: ImageEditorTextItem, isNewItem: Bool)?
var pinchFontStart: UIFont?
lazy var textViewContainer: UIView = {
let view = UIView(frame: view.bounds)
view.preservesSuperviewLayoutMargins = true
view.alpha = 0
return view
}()
lazy var textView: VAlignTextView = {
let textView = VAlignTextView()
textView.delegate = self
textView.isEditable = true
textView.backgroundColor = .clear
textView.isOpaque = false
textView.tintColor = .white
textView.isScrollEnabled = false
textView.scrollsToTop = false
textView.isUserInteractionEnabled = true
textView.textAlignment = .center
textView.textContainerInset = .zero
textView.textContainer.lineFragmentPadding = 0
textView.contentInset = .zero
return textView
}()
lazy var textToolbar: TextToolbar = {
let toolbar = TextToolbar(currentColor: currentTextItem?.textItem.color ?? model.color)
toolbar.preservesSuperviewLayoutMargins = true
toolbar.paletteView.delegate = self
toolbar.textStyleButton.addTarget(self, action: #selector(didTapTextStyleButton(sender:)), for: .touchUpInside)
return toolbar
}()
lazy var textViewAccessoryToolbar: TextToolbar = {
let toolbar = TextToolbar(currentColor: currentTextItem?.textItem.color ?? model.color)
toolbar.preservesSuperviewLayoutMargins = true
toolbar.paletteView.delegate = self
toolbar.textStyleButton.addTarget(self, action: #selector(didTapTextStyleButton(sender:)), for: .touchUpInside)
return toolbar
}()
init(model: ImageEditorModel) {
self.model = model
self.imageEditorView = ImageEditorView(model: model, delegate: nil)
self.firstUndoOperationId = model.currentUndoOperationId()
super.init()
model.add(observer: self)
}
override func loadView() {
view = UIView()
view.backgroundColor = .black
view.isOpaque = true
imageEditorView.configureSubviews()
view.addSubview(imageEditorView)
imageEditorView.autoPinWidthToSuperview()
imageEditorView.autoPinEdge(toSuperviewEdge: .top)
// Top toolbar
updateTopBar()
topBar.undoButton.addTarget(self, action: #selector(didTapUndo(sender:)), for: .touchUpInside)
topBar.clearAllButton.addTarget(self, action: #selector(didTapClearAll(sender:)), for: .touchUpInside)
topBar.install(in: view)
// Bottom toolbar
view.addSubview(bottomBar)
bottomBar.autoPinWidthToSuperview()
bottomBar.autoPinEdge(toSuperviewEdge: .bottom)
bottomBar.autoPinEdge(.top, to: .bottom, of: imageEditorView)
bottomBar.cancelButton.addTarget(self, action: #selector(didTapCancel(sender:)), for: .touchUpInside)
bottomBar.doneButton.addTarget(self, action: #selector(didTapDone(sender:)), for: .touchUpInside)
// Stroke width slider
strokeWidthSliderContainer.addSubview(strokeWidthSlider)
strokeWidthSlider.autoPinEdgesToSuperviewMargins()
strokeWidthSliderContainer.layoutMargins = UIEdgeInsets(top: 8, leading: 0, bottom: 8, trailing: 0)
strokeWidthSliderContainer.transform = CGAffineTransform(rotationAngle: -.halfPi)
view.addSubview(strokeWidthSliderContainer)
strokeWidthSliderContainer.autoVCenterInSuperview()
strokeWidthSliderPosition = strokeWidthSliderContainer.centerXAnchor.constraint(equalTo: view.leadingAnchor)
strokeWidthSliderPosition?.autoInstall()
strokeWidthSliderContainer.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(handleSliderContainerTap(_:))))
}
override func viewDidLoad() {
super.viewDidLoad()
updateUIForCurrentMode()
}
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
UIView.performWithoutAnimation {
setControls(hidden: true)
}
}
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
updateControlsVisibility(animated: true) { finished in
guard finished else { return }
if self.startEditingTextOnViewAppear && self.canBeginTextEditingOnViewAppear {
self.beginTextEditing()
}
self.startEditingTextOnViewAppear = false
}
}
override func viewSafeAreaInsetsDidChange() {
super.viewSafeAreaInsetsDidChange()
updateContentLayoutMargins()
}
override var prefersStatusBarHidden: Bool {
!UIDevice.current.hasIPhoneXNotch && !UIDevice.current.isIPad && !CurrentAppContext().hasActiveCall
}
override var preferredStatusBarStyle: UIStatusBarStyle {
.lightContent
}
// MARK: -
private func updateContentLayoutMargins() {
var contentLayoutMargins: UIEdgeInsets = .zero
if UIDevice.current.hasIPhoneXNotch {
contentLayoutMargins.top = view.safeAreaInsets.top
}
// Unlike code in `AttachmentApprovalViewController` bottom inset is zero
// because bottom of ImageEditorView is constrained to the top of toolbar.
imageEditorView.contentLayoutMargins = contentLayoutMargins
}
private func updateUIForCurrentMode() {
switch mode {
case .draw, .blur:
strokeWidthSliderContainer.isHidden = false
finishTextEditing(applyEdits: true)
case .text:
strokeWidthSliderContainer.isHidden = true
}
imageEditorView.textInteractionModes = {
switch mode {
case .draw, .blur: return [ .select ]
case .text: return .all
}
}()
updateDrawToolUIVisibility()
updateBlurToolUIVisibility()
updateTextUIVisibility()
for button in bottomBar.buttons {
button.isSelected = mode.rawValue == button.tag
}
}
private func updateTopBar() {
let canUndo = canUndo
topBar.isUndoButtonHidden = !canUndo
topBar.isClearAllButtonHidden = !canUndo
}
private var shouldHideControls: Bool {
switch mode {
case .draw, .blur:
return currentStroke != nil
case .text:
return imageEditorView.shouldHideControls
}
}
private var canUndo: Bool {
model.canUndo() && firstUndoOperationId != model.currentUndoOperationId()
}
func updateControlsVisibility(animated: Bool, completion: ((Bool) -> Void)? = nil) {
setControls(hidden: shouldHideControls, animated: animated, completion: completion)
}
private func setControls(hidden: Bool, animated: Bool, completion: ((Bool) -> Void)? = nil) {
if animated {
UIView.animate(withDuration: 0.15,
animations: {
self.setControls(hidden: hidden)
// Animate layout changes made within bottomBar.setControls(hidden:).
self.bottomBar.setNeedsDisplay()
self.bottomBar.layoutIfNeeded()
},
completion: completion)
} else {
setControls(hidden: hidden)
if let completion = completion {
completion(true)
}
}
}
private func setControls(hidden: Bool) {
let alpha: CGFloat = hidden ? 0 : 1
topBar.alpha = alpha
bottomBar.alpha = alpha
bottomBar.setControls(hidden: hidden)
switch mode {
case .draw:
updateDrawToolControlsVisibility()
case .blur:
updateBlurToolControlsVisibility()
case .text:
updateTextControlsVisibility()
}
}
private func modelDidChange() {
updateTopBar()
if blurToolUIInitialized {
// If we undo/redo, we may remove or re-apply the auto blur
faceBlurSwitch.isOn = currentAutoBlurItem != nil
}
}
private func undo() {
Logger.verbose("")
guard canUndo else {
owsFailDebug("Can't undo.")
return
}
model.undo()
}
private func clearAll() {
Logger.verbose("")
if mode == .text {
finishTextEditing(applyEdits: false)
}
while canUndo {
model.undo()
}
}
private func prepareToDismiss(completion: ((Bool) -> Void)?) {
if mode == .text {
finishTextEditing(applyEdits: false)
}
setControls(hidden: true, animated: true, completion: completion)
}
private func prepareToFinish(completion: ((Bool) -> Void)?) {
if mode == .text {
finishTextEditing(applyEdits: true)
}
setControls(hidden: true, animated: true, completion: completion)
}
private func discardAndDismiss() {
if canUndo {
askToDiscardAllChanges {
self.prepareToDismiss { finished in
guard finished else { return }
self.dismiss(animated: false)
}
}
} else {
prepareToDismiss { finished in
guard finished else { return }
self.dismiss(animated: false)
}
}
}
private func completeAndDismiss() {
prepareToFinish { finished in
guard finished else { return }
self.dismiss(animated: false)
}
}
private func askToDiscardAllChanges(_ completionHandler: (() -> Void)?) {
let actionSheetTitle = OWSLocalizedString("MEDIA_EDITOR_DISCARD_ALL_CONFIRMATION_TITLE",
comment: "Media Editor: Title for the 'Discard Changes' confirmation prompt.")
let actionSheetMessage = OWSLocalizedString("MEDIA_EDITOR_DISCARD_ALL_CONFIRMATION_MESSAGE",
comment: "Media Editor: Message for the 'Discard Changes' confirmation prompt.")
let discardChangesButton = OWSLocalizedString("MEDIA_EDITOR_DISCARD_ALL_BUTTON",
comment: "Media Editor: Title for the button in 'Discard Changes' confirmation prompt.")
let actionSheet = ActionSheetController(title: actionSheetTitle, message: actionSheetMessage)
actionSheet.addAction(ActionSheetAction(title: discardChangesButton, style: .destructive, handler: { _ in
self.clearAll()
if let completionHandler = completionHandler {
completionHandler()
}
}))
actionSheet.addAction(ActionSheetAction(title: CommonStrings.cancelButton, style: .cancel, handler: nil))
presentActionSheet(actionSheet)
}
}
// MARK: - Actions
extension ImageEditorViewController {
@objc
private func didTapUndo(sender: UIButton) {
undo()
}
@objc
private func didTapClearAll(sender: UIButton) {
askToDiscardAllChanges(nil)
}
@objc
private func didTapCancel(sender: UIButton) {
Logger.verbose("")
discardAndDismiss()
}
@objc
private func didTapDone(sender: UIButton) {
Logger.verbose("")
completeAndDismiss()
}
@objc
private func didTapPen(sender: UIButton) {
Logger.verbose("")
// Second tap on Pen icon switches editor to "text" mode.
mode = (mode == .draw) ? .text : .draw
}
@objc
private func didTapAddText(sender: UIButton) {
Logger.verbose("")
let textStyle = textToolbar.textStyle
let textColor = textToolbar.paletteView.selectedValue
let textItem = imageEditorView.createNewTextItem(withColor: textColor, textStyle: textStyle)
selectTextItem(textItem, isNewItem: true, startEditing: true)
}
@objc
private func didTapAddSticker(sender: UIButton) {
Logger.verbose("")
mode = .text
}
@objc
private func didTapBlur(sender: UIButton) {
Logger.verbose("")
// Second tap on Blur icon switches editor to "text" mode.
mode = (mode == .blur) ? .text : .blur
}
}
// MARK: - Bottom Bar
extension ImageEditorViewController: ImageEditorBottomBarButtonProvider {
var middleButtons: [UIButton] {
let penButton = RoundMediaButton(image: #imageLiteral(resourceName: "media-editor-toolbar-pen"), backgroundStyle: .solid(.clear))
penButton.tag = Mode.draw.rawValue
penButton.addTarget(self, action: #selector(didTapPen(sender:)), for: .touchUpInside)
let textButton = RoundMediaButton(image: #imageLiteral(resourceName: "media-editor-toolbar-text"), backgroundStyle: .solid(.clear))
textButton.addTarget(self, action: #selector(didTapAddText(sender:)), for: .touchUpInside)
let stickerButton = RoundMediaButton(image: #imageLiteral(resourceName: "media-editor-toolbar-emoji"), backgroundStyle: .solid(.clear))
stickerButton.addTarget(self, action: #selector(didTapAddSticker(sender:)), for: .touchUpInside)
let blurButton = RoundMediaButton(image: #imageLiteral(resourceName: "media-editor-toolbar-blur"), backgroundStyle: .solid(.clear))
blurButton.tag = Mode.blur.rawValue
blurButton.addTarget(self, action: #selector(didTapBlur(sender:)), for: .touchUpInside)
let buttons = [ penButton, textButton, stickerButton, blurButton ]
for button in buttons {
button.setBackgroundColor(.ows_white, for: .highlighted)
button.setBackgroundColor(.ows_white, for: .selected)
if let image = button.image(for: .normal),
let tintedImage = image.asTintedImage(color: .ows_black) {
button.setImage(tintedImage, for: .highlighted)
button.setImage(tintedImage, for: .selected)
}
}
return buttons
}
}
// MARK: - UIGestureRecognizerDelegate
extension ImageEditorViewController: UIGestureRecognizerDelegate {
func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldReceive touch: UITouch) -> Bool {
// Ignore touches that begin inside the control areas.
switch mode {
case .draw:
guard !drawToolbar.bounds.contains(touch.location(in: drawToolbar)) else {
return false
}
guard !strokeWidthSliderContainer.bounds.contains(touch.location(in: strokeWidthSliderContainer)) else {
return false
}
return true
case .blur:
return !blurToolbar.bounds.contains(touch.location(in: blurToolbar))
default:
return true
}
}
}
// MARK: - ImageEditorModelObserver
extension ImageEditorViewController: ImageEditorModelObserver {
func imageEditorModelDidChange(before: ImageEditorContents, after: ImageEditorContents) {
modelDidChange()
}
func imageEditorModelDidChange(changedItemIds: [String]) {
modelDidChange()
}
}
// MARK: - ImageEditorPaletteViewDelegate
extension ImageEditorViewController: ImageEditorPaletteViewDelegate {
func imageEditorPaletteView(_ paletteView: ImageEditorPaletteView, didSelectColor color: ImageEditorColor) {
switch mode {
case .draw:
model.color = color
updateStrokeWidthPreviewColor()
case .text:
imageEditorView.updateSelectedTextItem(withColor: color)
if paletteView == textToolbar.paletteView {
textViewAccessoryToolbar.paletteView.selectedValue = color
} else if paletteView == textViewAccessoryToolbar.paletteView {
textToolbar.paletteView.selectedValue = color
}
if textView.isFirstResponder {
updateTextViewAttributes(withColor: color.color)
}
default:
owsAssertDebug(false, "Invalid mode [\(mode)]")
}
}
}