// // Copyright (c) 2019 Open Whisper Systems. All rights reserved. // import UIKit @objc public protocol VAlignTextViewDelegate: class { func textViewDidComplete() } // MARK: - private class VAlignTextView: UITextView { fileprivate weak var textViewDelegate: VAlignTextViewDelegate? enum Alignment: String { case top case center case bottom } private let alignment: Alignment @objc public override var bounds: CGRect { didSet { if oldValue != bounds { updateInsets() } } } @objc public override var frame: CGRect { didSet { if oldValue != frame { updateInsets() } } } public init(alignment: Alignment) { self.alignment = alignment super.init(frame: .zero, textContainer: nil) self.addObserver(self, forKeyPath: "contentSize", options: .new, context: nil) } @available(*, unavailable, message: "use other init() instead.") required public init?(coder aDecoder: NSCoder) { notImplemented() } deinit { self.removeObserver(self, forKeyPath: "contentSize") } private func updateInsets() { let topOffset: CGFloat switch alignment { case .top: topOffset = 0 case .center: topOffset = max(0, (self.height() - contentSize.height) * 0.5) case .bottom: topOffset = max(0, self.height() - contentSize.height) } contentInset = UIEdgeInsets(top: topOffset, leading: 0, bottom: 0, trailing: 0) } open override func observeValue(forKeyPath keyPath: String?, of object: Any?, change: [NSKeyValueChangeKey: Any]?, context: UnsafeMutableRawPointer?) { updateInsets() } // MARK: - Key Commands override var keyCommands: [UIKeyCommand]? { return [ UIKeyCommand(input: "\r", modifierFlags: .command, action: #selector(self.modifiedReturnPressed(sender:)), discoverabilityTitle: "Send Message"), UIKeyCommand(input: "\r", modifierFlags: .alternate, action: #selector(self.modifiedReturnPressed(sender:)), discoverabilityTitle: "Send Message") ] } @objc public func modifiedReturnPressed(sender: UIKeyCommand) { Logger.verbose("") self.textViewDelegate?.textViewDidComplete() } } // MARK: - @objc public protocol ImageEditorTextViewControllerDelegate: class { func textEditDidComplete(textItem: ImageEditorTextItem) func textEditDidDelete(textItem: ImageEditorTextItem) func textEditDidCancel() } // MARK: - // A view for editing text item in image editor. public class ImageEditorTextViewController: OWSViewController, VAlignTextViewDelegate { private weak var delegate: ImageEditorTextViewControllerDelegate? private let textItem: ImageEditorTextItem private let isNewItem: Bool private let maxTextWidthPoints: CGFloat private let textView = VAlignTextView(alignment: .center) private let model: ImageEditorModel private let canvasView: ImageEditorCanvasView private let paletteView: ImageEditorPaletteView init(delegate: ImageEditorTextViewControllerDelegate, model: ImageEditorModel, textItem: ImageEditorTextItem, isNewItem: Bool, maxTextWidthPoints: CGFloat) { self.delegate = delegate self.model = model self.textItem = textItem self.isNewItem = isNewItem self.maxTextWidthPoints = maxTextWidthPoints self.canvasView = ImageEditorCanvasView(model: model, itemIdsToIgnore: [textItem.itemId]) self.paletteView = ImageEditorPaletteView(currentColor: textItem.color) super.init(nibName: nil, bundle: nil) self.textView.textViewDelegate = self } @available(*, unavailable, message: "use other init() instead.") required public init?(coder aDecoder: NSCoder) { notImplemented() } // MARK: - View Lifecycle public override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) textView.becomeFirstResponder() self.view.layoutSubviews() } public override func viewDidAppear(_ animated: Bool) { super.viewDidAppear(animated) textView.becomeFirstResponder() self.view.layoutSubviews() } public override func loadView() { self.view = UIView() self.view.backgroundColor = .black self.view.isOpaque = true canvasView.configureSubviews() self.view.addSubview(canvasView) canvasView.autoPinEdgesToSuperviewEdges() let tintView = UIView() tintView.backgroundColor = UIColor(white: 0, alpha: 0.33) tintView.isOpaque = false self.view.addSubview(tintView) tintView.autoPinEdgesToSuperviewEdges() tintView.layer.opacity = 0 UIView.animate(withDuration: 0.25, animations: { tintView.layer.opacity = 1 }, completion: { (_) in tintView.layer.opacity = 1 }) configureTextView() self.view.layoutMargins = UIEdgeInsets(top: 16, left: 20, bottom: 16, right: 20) self.view.addSubview(textView) textView.autoPinTopToSuperviewMargin() textView.autoHCenterInSuperview() self.autoPinView(toBottomOfViewControllerOrKeyboard: textView, avoidNotch: true) paletteView.delegate = self self.view.addSubview(paletteView) paletteView.autoAlignAxis(.horizontal, toSameAxisOf: textView) paletteView.autoPinEdge(toSuperviewEdge: .trailing, withInset: 0) // This will determine the text view's size. paletteView.autoPinEdge(.leading, to: .trailing, of: textView, withOffset: 0) let pinchGestureRecognizer = ImageEditorPinchGestureRecognizer(target: self, action: #selector(handlePinchGesture(_:))) pinchGestureRecognizer.referenceView = view view.addGestureRecognizer(pinchGestureRecognizer) updateNavigationBar() } private func configureTextView() { textView.text = textItem.text textView.font = textItem.font textView.textColor = textItem.color.color textView.isEditable = true textView.backgroundColor = .clear textView.isOpaque = false // We use a white cursor since we use a dark background. textView.tintColor = .white textView.returnKeyType = .done // TODO: Limit the size of the text? // textView.delegate = self textView.isScrollEnabled = true textView.scrollsToTop = false textView.isUserInteractionEnabled = true textView.textAlignment = .center textView.textContainerInset = .zero textView.textContainer.lineFragmentPadding = 0 textView.contentInset = .zero } private func updateNavigationBar() { let undoButton = navigationBarButton(imageName: "image_editor_undo", selector: #selector(didTapUndo(sender:))) let doneButton = navigationBarButton(imageName: "image_editor_checkmark_full", selector: #selector(didTapDone(sender:))) let navigationBarItems = [undoButton, doneButton] updateNavigationBar(navigationBarItems: navigationBarItems) } // MARK: - Pinch Gesture private var pinchFontStart: UIFont? @objc public func handlePinchGesture(_ gestureRecognizer: ImageEditorPinchGestureRecognizer) { AssertIsOnMainThread() switch gestureRecognizer.state { case .began: pinchFontStart = textView.font case .changed, .ended: guard let pinchFontStart = pinchFontStart else { return } var pointSize: CGFloat = pinchFontStart.pointSize if gestureRecognizer.pinchStateLast.distance > 0 { pointSize *= gestureRecognizer.pinchStateLast.distance / gestureRecognizer.pinchStateStart.distance } let minPointSize: CGFloat = 12 let maxPointSize: CGFloat = 64 pointSize = max(minPointSize, min(maxPointSize, pointSize)) let font = pinchFontStart.withSize(pointSize) textView.font = font default: pinchFontStart = nil } } // MARK: - Events @objc func didTapUndo(sender: UIButton) { Logger.verbose("") self.delegate?.textEditDidCancel() self.dismiss(animated: false) { // Do nothing. } } @objc func didTapDone(sender: UIButton) { Logger.verbose("") completeAndDismiss() } private func completeAndDismiss() { var newTextItem = textItem if isNewItem { let view = self.canvasView.gestureReferenceView let viewBounds = view.bounds // Ensure continuity of the new text item's location // with its apparent location in this text editor. let locationInView = view.convert(textView.bounds.center, from: textView) let textCenterImageUnit = ImageEditorCanvasView.locationImageUnit(forLocationInView: locationInView, viewBounds: viewBounds, model: model, transform: model.currentTransform()) // Same, but for size. let imageFrame = ImageEditorCanvasView.imageFrame(forViewSize: viewBounds.size, imageSize: model.srcImageSizePixels, transform: model.currentTransform()) let unitWidth = textView.width() / imageFrame.width newTextItem = textItem.copy(unitCenter: textCenterImageUnit).copy(unitWidth: unitWidth) } var font = textItem.font if let newFont = textView.font { font = newFont } else { owsFailDebug("Missing font.") } newTextItem = newTextItem.copy(font: font) guard let text = textView.text?.ows_stripped(), text.count > 0 else { self.delegate?.textEditDidDelete(textItem: textItem) self.dismiss(animated: false) { // Do nothing. } return } newTextItem = newTextItem.copy(withText: text, color: paletteView.selectedValue) // Hide the text view immediately to avoid animation glitches in the dismiss transition. textView.isHidden = true if textItem == newTextItem { // No changes were made. Cancel to avoid dirtying the undo stack. self.delegate?.textEditDidCancel() } else { self.delegate?.textEditDidComplete(textItem: newTextItem) } self.dismiss(animated: false) { // Do nothing. } } // MARK: - VAlignTextViewDelegate public func textViewDidComplete() { completeAndDismiss() } } // MARK: - extension ImageEditorTextViewController: ImageEditorPaletteViewDelegate { public func selectedColorDidChange() { self.textView.textColor = self.paletteView.selectedValue.color } }