394 lines
14 KiB
Swift
394 lines
14 KiB
Swift
//
|
|
// Copyright (c) 2020 Open Whisper Systems. All rights reserved.
|
|
//
|
|
|
|
import UIKit
|
|
import Vision
|
|
|
|
public class ImageEditorBlurViewController: OWSViewController {
|
|
|
|
private let model: ImageEditorModel
|
|
|
|
private let canvasView: ImageEditorCanvasView
|
|
|
|
private let bottomControlsContainer = UIView()
|
|
private let faceBlurContainer = UIView()
|
|
private let faceBlurSwitch = UISwitch()
|
|
|
|
// 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?
|
|
|
|
init(model: ImageEditorModel) {
|
|
self.model = model
|
|
self.canvasView = ImageEditorCanvasView(model: model)
|
|
self.firstUndoOperationId = model.currentUndoOperationId()
|
|
|
|
super.init()
|
|
|
|
model.add(observer: self)
|
|
}
|
|
|
|
// MARK: - View Lifecycle
|
|
|
|
public override func loadView() {
|
|
self.view = UIView()
|
|
self.view.backgroundColor = .black
|
|
self.view.isOpaque = true
|
|
|
|
canvasView.configureSubviews()
|
|
self.view.addSubview(canvasView)
|
|
canvasView.autoPinEdgesToSuperviewEdges()
|
|
|
|
self.view.isUserInteractionEnabled = true
|
|
|
|
let brushGestureRecognizer = ImageEditorPanGestureRecognizer(target: self, action: #selector(handleBlurGesture))
|
|
brushGestureRecognizer.maximumNumberOfTouches = 1
|
|
brushGestureRecognizer.referenceView = canvasView.gestureReferenceView
|
|
brushGestureRecognizer.delegate = self
|
|
self.view.addGestureRecognizer(brushGestureRecognizer)
|
|
|
|
view.addSubview(bottomControlsContainer)
|
|
bottomControlsContainer.autoHCenterInSuperview()
|
|
bottomControlsContainer.autoPinEdge(toSuperviewSafeArea: .bottom, withInset: 16)
|
|
|
|
faceBlurContainer.backgroundColor = .ows_blackAlpha60
|
|
faceBlurContainer.layoutMargins = UIEdgeInsets(top: 6, leading: 16, bottom: 6, trailing: 8)
|
|
|
|
bottomControlsContainer.addSubview(faceBlurContainer)
|
|
faceBlurContainer.autoHCenterInSuperview()
|
|
faceBlurContainer.autoPinEdge(toSuperviewEdge: .top)
|
|
|
|
let autoBlurLabel = UILabel()
|
|
autoBlurLabel.text = NSLocalizedString("IMAGE_EDITOR_BLUR_SETTING", comment: "The image editor setting to blur faces")
|
|
autoBlurLabel.font = .ows_dynamicTypeSubheadlineClamped
|
|
autoBlurLabel.textColor = Theme.darkThemePrimaryColor
|
|
|
|
faceBlurContainer.addSubview(autoBlurLabel)
|
|
autoBlurLabel.autoPinLeadingToSuperviewMargin()
|
|
autoBlurLabel.autoPinHeightToSuperviewMargins()
|
|
|
|
faceBlurSwitch.addTarget(self, action: #selector(didToggleAutoBlur), for: .valueChanged)
|
|
faceBlurSwitch.isOn = currentAutoBlurItem != nil
|
|
|
|
faceBlurContainer.addSubview(faceBlurSwitch)
|
|
faceBlurSwitch.autoPinTrailingToSuperviewMargin()
|
|
faceBlurSwitch.autoPinHeightToSuperviewMargins()
|
|
faceBlurSwitch.autoPinEdge(.leading, to: .trailing, of: autoBlurLabel, withOffset: 10)
|
|
|
|
let drawAnywhereHint = UILabel()
|
|
drawAnywhereHint.font = .ows_dynamicTypeCaption1
|
|
drawAnywhereHint.textColor = Theme.darkThemePrimaryColor
|
|
drawAnywhereHint.textAlignment = .center
|
|
drawAnywhereHint.numberOfLines = 0
|
|
drawAnywhereHint.lineBreakMode = .byWordWrapping
|
|
drawAnywhereHint.text = NSLocalizedString("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
|
|
|
|
bottomControlsContainer.addSubview(drawAnywhereHint)
|
|
drawAnywhereHint.autoPinEdge(.top, to: .bottom, of: faceBlurContainer, withOffset: 8)
|
|
drawAnywhereHint.autoPinWidthToSuperviewMargins()
|
|
drawAnywhereHint.autoPinEdge(toSuperviewEdge: .bottom)
|
|
|
|
updateNavigationBar()
|
|
}
|
|
|
|
public override func viewWillAppear(_ animated: Bool) {
|
|
super.viewWillAppear(animated)
|
|
|
|
self.view.layoutSubviews()
|
|
}
|
|
|
|
public override func viewDidAppear(_ animated: Bool) {
|
|
super.viewDidAppear(animated)
|
|
|
|
self.view.layoutSubviews()
|
|
}
|
|
|
|
public override func viewDidLayoutSubviews() {
|
|
super.viewDidLayoutSubviews()
|
|
|
|
faceBlurContainer.layoutIfNeeded()
|
|
|
|
faceBlurContainer.layer.cornerRadius = faceBlurContainer.height / 2
|
|
}
|
|
|
|
private func updateNavigationBar() {
|
|
// Hide controls during blur.
|
|
let hasBlur = currentBlurStroke != nil
|
|
guard !hasBlur else {
|
|
updateNavigationBar(navigationBarItems: [])
|
|
return
|
|
}
|
|
|
|
let undoButton = navigationBarButton(imageName: "image_editor_undo",
|
|
selector: #selector(didTapUndo(sender:)))
|
|
let doneButton = navigationBarButton(imageName: "image_editor_checkmark_full",
|
|
selector: #selector(didTapDone(sender:)))
|
|
|
|
// Prevent users from undo any changes made before entering the view.
|
|
let canUndo = model.canUndo() && firstUndoOperationId != model.currentUndoOperationId()
|
|
var navigationBarItems = [UIView]()
|
|
if canUndo {
|
|
navigationBarItems = [undoButton, doneButton]
|
|
} else {
|
|
navigationBarItems = [doneButton]
|
|
}
|
|
updateNavigationBar(navigationBarItems: navigationBarItems)
|
|
}
|
|
|
|
private func updateControls() {
|
|
// Hide controls during blur.
|
|
let hasBlur = currentBlurStroke != nil
|
|
bottomControlsContainer.isHidden = hasBlur
|
|
}
|
|
|
|
@objc
|
|
public override var prefersStatusBarHidden: Bool {
|
|
guard !OWSWindowManager.shared.hasCall else {
|
|
return false
|
|
}
|
|
|
|
return true
|
|
}
|
|
|
|
// MARK: - Actions
|
|
|
|
@objc func didTapUndo(sender: UIButton) {
|
|
Logger.verbose("")
|
|
guard model.canUndo() else {
|
|
owsFailDebug("Can't undo.")
|
|
return
|
|
}
|
|
model.undo()
|
|
}
|
|
|
|
@objc func didTapDone(sender: UIButton) {
|
|
Logger.verbose("")
|
|
|
|
completeAndDismiss()
|
|
}
|
|
|
|
private func completeAndDismiss() {
|
|
self.dismiss(animated: false)
|
|
}
|
|
|
|
// We persist an auto blur identifier for this session so
|
|
// we can keep the toggle switch in sync with undo/redo behavior
|
|
private static let autoBlurItemIdentifier = "autoBlur"
|
|
private var currentAutoBlurItem: ImageEditorBlurRegionsItem? {
|
|
return model.item(forId: ImageEditorBlurViewController.autoBlurItemIdentifier) as? ImageEditorBlurRegionsItem
|
|
}
|
|
|
|
@objc func didToggleAutoBlur(sender: UISwitch) {
|
|
Logger.verbose("")
|
|
|
|
if let currentAutoBlurItem = currentAutoBlurItem {
|
|
model.remove(item: currentAutoBlurItem)
|
|
}
|
|
|
|
guard sender.isOn else { return }
|
|
|
|
guard let srcImage = ImageEditorCanvasView.loadSrcImage(model: model),
|
|
let srcCGImage = srcImage.cgImage else {
|
|
return
|
|
}
|
|
|
|
let cgOrientation = CGImagePropertyOrientation(srcImage.imageOrientation)
|
|
|
|
ModalActivityIndicatorViewController.present(
|
|
fromViewController: self,
|
|
canCancel: false,
|
|
presentationDelay: 0.5
|
|
) { modal in
|
|
func showToast() {
|
|
let toastController = ToastController(text: NSLocalizedString(
|
|
"IMAGE_EDITOR_BLUR_TOAST",
|
|
comment: "A toast indicating that you can blur more faces after detection"
|
|
))
|
|
let bottomInset = self.bottomLayoutGuide.length + 90
|
|
toastController.presentToastView(fromBottomOfView: self.view, inset: bottomInset)
|
|
}
|
|
|
|
func faceDetectionFailed() {
|
|
DispatchQueue.main.async {
|
|
sender.isOn = false
|
|
modal.dismiss { showToast() }
|
|
}
|
|
}
|
|
|
|
let request = VNDetectFaceRectanglesRequest { request, error in
|
|
if let error = error {
|
|
owsFailDebug("Face Detection Error \(error)")
|
|
return faceDetectionFailed()
|
|
}
|
|
// Perform drawing on the main thread.
|
|
DispatchQueue.main.async {
|
|
guard let results = request.results as? [VNFaceObservation] else {
|
|
return faceDetectionFailed()
|
|
}
|
|
|
|
Logger.verbose("Detected \(results.count) faces")
|
|
|
|
func unitBoundingBox(_ faceObservation: VNFaceObservation) -> CGRect {
|
|
var unitRect = faceObservation.boundingBox
|
|
unitRect.origin.y = 1 - unitRect.origin.y - unitRect.height
|
|
return unitRect
|
|
}
|
|
|
|
let autoBlurItem = ImageEditorBlurRegionsItem(
|
|
itemId: ImageEditorBlurViewController.autoBlurItemIdentifier,
|
|
unitBoundingBoxes: results.map(unitBoundingBox)
|
|
)
|
|
self.model.append(item: autoBlurItem)
|
|
|
|
modal.dismiss { showToast() }
|
|
}
|
|
}
|
|
|
|
let imageRequestHandler = VNImageRequestHandler(cgImage: srcCGImage,
|
|
orientation: cgOrientation,
|
|
options: [:])
|
|
|
|
// Send the requests to the request handler.
|
|
do {
|
|
try imageRequestHandler.perform([request])
|
|
} catch let error as NSError {
|
|
owsFailDebug("Failed to perform image request: \(error)")
|
|
return faceDetectionFailed()
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Blur
|
|
|
|
// These properties are non-empty while drawing a blur.
|
|
private var currentBlurStroke: ImageEditorStrokeItem? {
|
|
didSet {
|
|
updateControls()
|
|
updateNavigationBar()
|
|
}
|
|
}
|
|
private var currentBlurStrokeSamples = [ImageEditorStrokeItem.StrokeSample]()
|
|
|
|
@objc
|
|
public func handleBlurGesture(_ gestureRecognizer: ImageEditorPanGestureRecognizer) {
|
|
AssertIsOnMainThread()
|
|
|
|
func removeCurrentBlur() {
|
|
if let blur = self.currentBlurStroke {
|
|
self.model.remove(item: blur)
|
|
}
|
|
self.currentBlurStroke = nil
|
|
self.currentBlurStrokeSamples.removeAll()
|
|
}
|
|
func tryToAppendBlurSample(_ locationInView: CGPoint) {
|
|
let view = self.canvasView.gestureReferenceView
|
|
let viewBounds = view.bounds
|
|
let newSample = ImageEditorCanvasView.locationImageUnit(forLocationInView: locationInView,
|
|
viewBounds: viewBounds,
|
|
model: self.model,
|
|
transform: self.model.currentTransform())
|
|
|
|
if let prevSample = self.currentBlurStrokeSamples.last,
|
|
prevSample == newSample {
|
|
// Ignore duplicate samples.
|
|
return
|
|
}
|
|
self.currentBlurStrokeSamples.append(newSample)
|
|
}
|
|
|
|
let unitBlurStrokeWidth = 0.05 / self.model.currentTransform().scaling
|
|
|
|
switch gestureRecognizer.state {
|
|
case .began:
|
|
removeCurrentBlur()
|
|
|
|
// Apply the location history of the gesture so that the blur reflects
|
|
// the touch's movement before the gesture recognized.
|
|
for location in gestureRecognizer.locationHistory {
|
|
tryToAppendBlurSample(location)
|
|
}
|
|
|
|
let locationInView = gestureRecognizer.location(in: canvasView.gestureReferenceView)
|
|
tryToAppendBlurSample(locationInView)
|
|
|
|
let blur = ImageEditorStrokeItem(isBlur: true, unitSamples: currentBlurStrokeSamples, unitStrokeWidth: unitBlurStrokeWidth)
|
|
model.append(item: blur)
|
|
currentBlurStroke = blur
|
|
|
|
case .changed, .ended:
|
|
let locationInView = gestureRecognizer.location(in: canvasView.gestureReferenceView)
|
|
tryToAppendBlurSample(locationInView)
|
|
|
|
guard let lastBlur = self.currentBlurStroke else {
|
|
owsFailDebug("Missing last blur.")
|
|
removeCurrentBlur()
|
|
return
|
|
}
|
|
|
|
// Model items are immutable; we _replace_ the
|
|
// blur item rather than modify it.
|
|
let blurStroke = ImageEditorStrokeItem(itemId: lastBlur.itemId, isBlur: true, unitSamples: currentBlurStrokeSamples, unitStrokeWidth: unitBlurStrokeWidth)
|
|
model.replace(item: blurStroke, suppressUndo: true)
|
|
|
|
if gestureRecognizer.state == .ended {
|
|
currentBlurStroke = nil
|
|
currentBlurStrokeSamples.removeAll()
|
|
} else {
|
|
currentBlurStroke = blurStroke
|
|
}
|
|
default:
|
|
removeCurrentBlur()
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: -
|
|
|
|
extension ImageEditorBlurViewController: ImageEditorModelObserver {
|
|
|
|
public func imageEditorModelDidChange(before: ImageEditorContents,
|
|
after: ImageEditorContents) {
|
|
updateNavigationBar()
|
|
|
|
// If we undo/redo, we may remove or re-apply the auto blur
|
|
faceBlurSwitch.isOn = currentAutoBlurItem != nil
|
|
}
|
|
|
|
public func imageEditorModelDidChange(changedItemIds: [String]) {
|
|
updateNavigationBar()
|
|
}
|
|
}
|
|
|
|
// MARK: -
|
|
|
|
extension ImageEditorBlurViewController: UIGestureRecognizerDelegate {
|
|
@objc public func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldReceive touch: UITouch) -> Bool {
|
|
// Ignore touches that begin inside the autoBlurContainer.
|
|
let location = touch.location(in: bottomControlsContainer)
|
|
return !bottomControlsContainer.bounds.contains(location)
|
|
}
|
|
}
|
|
|
|
private extension CGImagePropertyOrientation {
|
|
init(_ uiImageOrientation: UIImage.Orientation) {
|
|
switch uiImageOrientation {
|
|
case .up: self = .up
|
|
case .down: self = .down
|
|
case .left: self = .left
|
|
case .right: self = .right
|
|
case .upMirrored: self = .upMirrored
|
|
case .downMirrored: self = .downMirrored
|
|
case .leftMirrored: self = .leftMirrored
|
|
case .rightMirrored: self = .rightMirrored
|
|
default: self = .up
|
|
}
|
|
}
|
|
}
|