mirror of https://github.com/oxen-io/session-ios
Merge branch 'charlesmchen/imageEditorCaptions'
commit
0813ebe16d
@ -0,0 +1,310 @@
|
||||
//
|
||||
// Copyright (c) 2019 Open Whisper Systems. All rights reserved.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
protocol AttachmentCaptionDelegate: class {
|
||||
func captionView(_ captionView: AttachmentCaptionViewController, didChangeCaptionText captionText: String?, attachmentItem: SignalAttachmentItem)
|
||||
func captionViewDidCancel()
|
||||
}
|
||||
|
||||
class AttachmentCaptionViewController: OWSViewController {
|
||||
|
||||
weak var delegate: AttachmentCaptionDelegate?
|
||||
|
||||
private let attachmentItem: SignalAttachmentItem
|
||||
|
||||
private let originalCaptionText: String?
|
||||
|
||||
private let textView = UITextView()
|
||||
|
||||
private var textViewHeightConstraint: NSLayoutConstraint?
|
||||
|
||||
private let kMaxCaptionCharacterCount = 240
|
||||
|
||||
init(delegate: AttachmentCaptionDelegate,
|
||||
attachmentItem: SignalAttachmentItem) {
|
||||
self.delegate = delegate
|
||||
self.attachmentItem = attachmentItem
|
||||
self.originalCaptionText = attachmentItem.captionText
|
||||
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
|
||||
self.addObserver(textView, forKeyPath: "contentSize", options: .new, context: nil)
|
||||
}
|
||||
|
||||
@available(*, unavailable, message: "use other init() instead.")
|
||||
required public init?(coder aDecoder: NSCoder) {
|
||||
notImplemented()
|
||||
}
|
||||
|
||||
deinit {
|
||||
self.removeObserver(textView, forKeyPath: "contentSize")
|
||||
}
|
||||
|
||||
open override func observeValue(forKeyPath keyPath: String?, of object: Any?, change: [NSKeyValueChangeKey: Any]?, context: UnsafeMutableRawPointer?) {
|
||||
updateTextView()
|
||||
}
|
||||
|
||||
// MARK: - View Lifecycle
|
||||
|
||||
public override func viewWillAppear(_ animated: Bool) {
|
||||
super.viewWillAppear(animated)
|
||||
|
||||
textView.becomeFirstResponder()
|
||||
|
||||
updateTextView()
|
||||
}
|
||||
|
||||
public override func viewDidAppear(_ animated: Bool) {
|
||||
super.viewDidAppear(animated)
|
||||
|
||||
textView.becomeFirstResponder()
|
||||
|
||||
updateTextView()
|
||||
}
|
||||
|
||||
public override func loadView() {
|
||||
self.view = UIView()
|
||||
self.view.backgroundColor = UIColor(white: 0, alpha: 0.25)
|
||||
self.view.isOpaque = false
|
||||
|
||||
self.view.isUserInteractionEnabled = true
|
||||
self.view.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(backgroundTapped)))
|
||||
|
||||
configureTextView()
|
||||
|
||||
let cancelButton = UIBarButtonItem(barButtonSystemItem: .cancel,
|
||||
target: self,
|
||||
action: #selector(didTapCancel))
|
||||
cancelButton.tintColor = .white
|
||||
navigationItem.leftBarButtonItem = cancelButton
|
||||
let doneIcon = UIImage(named: "image_editor_checkmark_full")?.withRenderingMode(.alwaysTemplate)
|
||||
let doneButton = UIBarButtonItem(image: doneIcon, style: .plain,
|
||||
target: self,
|
||||
action: #selector(didTapDone))
|
||||
doneButton.tintColor = .white
|
||||
navigationItem.rightBarButtonItem = doneButton
|
||||
|
||||
self.view.layoutMargins = .zero
|
||||
|
||||
lengthLimitLabel.setContentHuggingHigh()
|
||||
lengthLimitLabel.setCompressionResistanceHigh()
|
||||
|
||||
let stackView = UIStackView(arrangedSubviews: [lengthLimitLabel, textView])
|
||||
stackView.axis = .vertical
|
||||
stackView.spacing = 20
|
||||
stackView.alignment = .fill
|
||||
stackView.addBackgroundView(withBackgroundColor: UIColor(white: 0, alpha: 0.5))
|
||||
stackView.layoutMargins = UIEdgeInsets(top: 16, left: 20, bottom: 16, right: 20)
|
||||
stackView.isLayoutMarginsRelativeArrangement = true
|
||||
self.view.addSubview(stackView)
|
||||
stackView.autoPinEdge(toSuperviewEdge: .leading)
|
||||
stackView.autoPinEdge(toSuperviewEdge: .trailing)
|
||||
self.autoPinView(toBottomOfViewControllerOrKeyboard: stackView, avoidNotch: true)
|
||||
|
||||
let minTextHeight: CGFloat = textView.font?.lineHeight ?? 0
|
||||
textViewHeightConstraint = textView.autoSetDimension(.height, toSize: minTextHeight)
|
||||
|
||||
view.addSubview(placeholderTextView)
|
||||
placeholderTextView.autoAlignAxis(.horizontal, toSameAxisOf: textView)
|
||||
placeholderTextView.autoPinEdge(.leading, to: .leading, of: textView)
|
||||
placeholderTextView.autoPinEdge(.trailing, to: .trailing, of: textView)
|
||||
}
|
||||
|
||||
private func configureTextView() {
|
||||
textView.delegate = self
|
||||
|
||||
textView.text = attachmentItem.captionText
|
||||
textView.font = UIFont.ows_dynamicTypeBody
|
||||
textView.textColor = .white
|
||||
|
||||
textView.isEditable = true
|
||||
textView.backgroundColor = .clear
|
||||
textView.isOpaque = false
|
||||
// We use a white cursor since we use a dark background.
|
||||
textView.tintColor = .white
|
||||
textView.isScrollEnabled = true
|
||||
textView.scrollsToTop = false
|
||||
textView.isUserInteractionEnabled = true
|
||||
textView.textAlignment = .left
|
||||
textView.textContainerInset = .zero
|
||||
textView.textContainer.lineFragmentPadding = 0
|
||||
textView.contentInset = .zero
|
||||
}
|
||||
|
||||
// MARK: - Events
|
||||
|
||||
@objc func backgroundTapped(sender: UIGestureRecognizer) {
|
||||
AssertIsOnMainThread()
|
||||
|
||||
completeAndDismiss(didCancel: false)
|
||||
}
|
||||
|
||||
@objc public func didTapCancel() {
|
||||
completeAndDismiss(didCancel: true)
|
||||
}
|
||||
|
||||
@objc public func didTapDone() {
|
||||
completeAndDismiss(didCancel: false)
|
||||
}
|
||||
|
||||
private func completeAndDismiss(didCancel: Bool) {
|
||||
if didCancel {
|
||||
self.delegate?.captionViewDidCancel()
|
||||
} else {
|
||||
self.delegate?.captionView(self, didChangeCaptionText: self.textView.text, attachmentItem: attachmentItem)
|
||||
}
|
||||
|
||||
self.dismiss(animated: true) {
|
||||
// Do nothing.
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Length Limit
|
||||
|
||||
private lazy var lengthLimitLabel: UILabel = {
|
||||
let lengthLimitLabel = UILabel()
|
||||
|
||||
// Length Limit Label shown when the user inputs too long of a message
|
||||
lengthLimitLabel.textColor = UIColor.ows_destructiveRed
|
||||
lengthLimitLabel.text = NSLocalizedString("ATTACHMENT_APPROVAL_CAPTION_LENGTH_LIMIT_REACHED", comment: "One-line label indicating the user can add no more text to the attachment caption.")
|
||||
lengthLimitLabel.textAlignment = .center
|
||||
|
||||
// Add shadow in case overlayed on white content
|
||||
lengthLimitLabel.layer.shadowColor = UIColor.black.cgColor
|
||||
lengthLimitLabel.layer.shadowOffset = CGSize(width: 0.0, height: 0.0)
|
||||
lengthLimitLabel.layer.shadowOpacity = 0.8
|
||||
lengthLimitLabel.isHidden = true
|
||||
|
||||
return lengthLimitLabel
|
||||
}()
|
||||
|
||||
// MARK: - Text Height
|
||||
|
||||
// TODO: We need to revisit this with Myles.
|
||||
func updatePlaceholderTextViewVisibility() {
|
||||
let isHidden: Bool = {
|
||||
guard !self.textView.isFirstResponder else {
|
||||
return true
|
||||
}
|
||||
|
||||
guard let captionText = self.textView.text else {
|
||||
return false
|
||||
}
|
||||
|
||||
guard captionText.count > 0 else {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}()
|
||||
|
||||
placeholderTextView.isHidden = isHidden
|
||||
}
|
||||
|
||||
private lazy var placeholderTextView: UIView = {
|
||||
let placeholderTextView = UITextView()
|
||||
placeholderTextView.text = NSLocalizedString("ATTACHMENT_APPROVAL_CAPTION_PLACEHOLDER", comment: "placeholder text for an empty captioning field")
|
||||
placeholderTextView.isEditable = false
|
||||
|
||||
placeholderTextView.backgroundColor = .clear
|
||||
placeholderTextView.font = UIFont.ows_dynamicTypeBody
|
||||
|
||||
placeholderTextView.textColor = Theme.darkThemePrimaryColor
|
||||
placeholderTextView.tintColor = Theme.darkThemePrimaryColor
|
||||
placeholderTextView.returnKeyType = .done
|
||||
|
||||
return placeholderTextView
|
||||
}()
|
||||
|
||||
// MARK: - Text Height
|
||||
|
||||
private func updateTextView() {
|
||||
guard let textViewHeightConstraint = textViewHeightConstraint else {
|
||||
owsFailDebug("Missing textViewHeightConstraint.")
|
||||
return
|
||||
}
|
||||
|
||||
let contentSize = textView.sizeThatFits(CGSize(width: textView.width(), height: CGFloat.greatestFiniteMagnitude))
|
||||
|
||||
// `textView.contentSize` isn't accurate when restoring a multiline draft, so we compute it here.
|
||||
textView.contentSize = contentSize
|
||||
|
||||
let minHeight: CGFloat = textView.font?.lineHeight ?? 0
|
||||
let maxHeight: CGFloat = 300
|
||||
let newHeight = contentSize.height.clamp(minHeight, maxHeight)
|
||||
|
||||
textViewHeightConstraint.constant = newHeight
|
||||
textView.invalidateIntrinsicContentSize()
|
||||
textView.superview?.invalidateIntrinsicContentSize()
|
||||
|
||||
textView.isScrollEnabled = contentSize.height > maxHeight
|
||||
|
||||
updatePlaceholderTextViewVisibility()
|
||||
}
|
||||
}
|
||||
|
||||
extension AttachmentCaptionViewController: UITextViewDelegate {
|
||||
|
||||
public func textViewDidChange(_ textView: UITextView) {
|
||||
updateTextView()
|
||||
}
|
||||
|
||||
public func textView(_ textView: UITextView, shouldChangeTextIn range: NSRange, replacementText text: String) -> Bool {
|
||||
let existingText: String = textView.text ?? ""
|
||||
let proposedText: String = (existingText as NSString).replacingCharacters(in: range, with: text)
|
||||
|
||||
let kMaxCaptionByteCount = kOversizeTextMessageSizeThreshold / 4
|
||||
guard proposedText.utf8.count <= kMaxCaptionByteCount else {
|
||||
Logger.debug("hit caption byte count limit")
|
||||
self.lengthLimitLabel.isHidden = false
|
||||
|
||||
// `range` represents the section of the existing text we will replace. We can re-use that space.
|
||||
// Range is in units of NSStrings's standard UTF-16 characters. Since some of those chars could be
|
||||
// represented as single bytes in utf-8, while others may be 8 or more, the only way to be sure is
|
||||
// to just measure the utf8 encoded bytes of the replaced substring.
|
||||
let bytesAfterDelete: Int = (existingText as NSString).replacingCharacters(in: range, with: "").utf8.count
|
||||
|
||||
// Accept as much of the input as we can
|
||||
let byteBudget: Int = Int(kOversizeTextMessageSizeThreshold) - bytesAfterDelete
|
||||
if byteBudget >= 0, let acceptableNewText = text.truncated(toByteCount: UInt(byteBudget)) {
|
||||
textView.text = (existingText as NSString).replacingCharacters(in: range, with: acceptableNewText)
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// After verifying the byte-length is sufficiently small, verify the character count is within bounds.
|
||||
// Normally this character count should entail *much* less byte count.
|
||||
guard proposedText.count <= kMaxCaptionCharacterCount else {
|
||||
Logger.debug("hit caption character count limit")
|
||||
|
||||
self.lengthLimitLabel.isHidden = false
|
||||
|
||||
// `range` represents the section of the existing text we will replace. We can re-use that space.
|
||||
let charsAfterDelete: Int = (existingText as NSString).replacingCharacters(in: range, with: "").count
|
||||
|
||||
// Accept as much of the input as we can
|
||||
let charBudget: Int = Int(kMaxCaptionCharacterCount) - charsAfterDelete
|
||||
if charBudget >= 0 {
|
||||
let acceptableNewText = String(text.prefix(charBudget))
|
||||
textView.text = (existingText as NSString).replacingCharacters(in: range, with: acceptableNewText)
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
self.lengthLimitLabel.isHidden = true
|
||||
return true
|
||||
}
|
||||
|
||||
public func textViewDidBeginEditing(_ textView: UITextView) {
|
||||
updatePlaceholderTextViewVisibility()
|
||||
}
|
||||
|
||||
public func textViewDidEndEditing(_ textView: UITextView) {
|
||||
updatePlaceholderTextViewVisibility()
|
||||
}
|
||||
}
|
@ -0,0 +1,217 @@
|
||||
//
|
||||
// Copyright (c) 2019 Open Whisper Systems. All rights reserved.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
@objc
|
||||
public protocol ImageEditorBrushViewControllerDelegate: class {
|
||||
func brushDidComplete()
|
||||
}
|
||||
|
||||
// MARK: -
|
||||
|
||||
public class ImageEditorBrushViewController: OWSViewController {
|
||||
|
||||
private weak var delegate: ImageEditorBrushViewControllerDelegate?
|
||||
|
||||
private let model: ImageEditorModel
|
||||
|
||||
private let canvasView: ImageEditorCanvasView
|
||||
|
||||
private let paletteView: ImageEditorPaletteView
|
||||
|
||||
private var brushGestureRecognizer: ImageEditorPanGestureRecognizer?
|
||||
|
||||
init(delegate: ImageEditorBrushViewControllerDelegate,
|
||||
model: ImageEditorModel,
|
||||
currentColor: ImageEditorColor) {
|
||||
self.delegate = delegate
|
||||
self.model = model
|
||||
self.canvasView = ImageEditorCanvasView(model: model)
|
||||
self.paletteView = ImageEditorPaletteView(currentColor: currentColor)
|
||||
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
|
||||
model.add(observer: self)
|
||||
}
|
||||
|
||||
@available(*, unavailable, message: "use other init() instead.")
|
||||
required public init?(coder aDecoder: NSCoder) {
|
||||
notImplemented()
|
||||
}
|
||||
|
||||
// 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()
|
||||
|
||||
paletteView.delegate = self
|
||||
self.view.addSubview(paletteView)
|
||||
paletteView.autoVCenterInSuperview()
|
||||
paletteView.autoPinEdge(toSuperviewEdge: .trailing, withInset: 20)
|
||||
|
||||
self.view.isUserInteractionEnabled = true
|
||||
|
||||
let brushGestureRecognizer = ImageEditorPanGestureRecognizer(target: self, action: #selector(handleBrushGesture(_:)))
|
||||
brushGestureRecognizer.maximumNumberOfTouches = 1
|
||||
brushGestureRecognizer.referenceView = canvasView.gestureReferenceView
|
||||
self.view.addGestureRecognizer(brushGestureRecognizer)
|
||||
self.brushGestureRecognizer = brushGestureRecognizer
|
||||
|
||||
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 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:)))
|
||||
|
||||
var navigationBarItems = [UIView]()
|
||||
if model.canUndo() {
|
||||
navigationBarItems = [undoButton, doneButton]
|
||||
} else {
|
||||
navigationBarItems = [doneButton]
|
||||
}
|
||||
updateNavigationBar(navigationBarItems: navigationBarItems)
|
||||
}
|
||||
|
||||
// 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.delegate?.brushDidComplete()
|
||||
|
||||
self.dismiss(animated: false) {
|
||||
// Do nothing.
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Brush
|
||||
|
||||
// These properties are non-empty while drawing a stroke.
|
||||
private var currentStroke: ImageEditorStrokeItem?
|
||||
private var currentStrokeSamples = [ImageEditorStrokeItem.StrokeSample]()
|
||||
|
||||
@objc
|
||||
public func handleBrushGesture(_ gestureRecognizer: UIGestureRecognizer) {
|
||||
AssertIsOnMainThread()
|
||||
|
||||
let removeCurrentStroke = {
|
||||
if let stroke = self.currentStroke {
|
||||
self.model.remove(item: stroke)
|
||||
}
|
||||
self.currentStroke = nil
|
||||
self.currentStrokeSamples.removeAll()
|
||||
}
|
||||
let tryToAppendStrokeSample = {
|
||||
let view = self.canvasView.gestureReferenceView
|
||||
let viewBounds = view.bounds
|
||||
let locationInView = gestureRecognizer.location(in: view)
|
||||
let newSample = ImageEditorCanvasView.locationImageUnit(forLocationInView: locationInView,
|
||||
viewBounds: viewBounds,
|
||||
model: self.model,
|
||||
transform: self.model.currentTransform())
|
||||
|
||||
if let prevSample = self.currentStrokeSamples.last,
|
||||
prevSample == newSample {
|
||||
// Ignore duplicate samples.
|
||||
return
|
||||
}
|
||||
self.currentStrokeSamples.append(newSample)
|
||||
}
|
||||
|
||||
let strokeColor = paletteView.selectedValue.color
|
||||
// TODO: Tune stroke width.
|
||||
let unitStrokeWidth = ImageEditorStrokeItem.defaultUnitStrokeWidth()
|
||||
|
||||
switch gestureRecognizer.state {
|
||||
case .began:
|
||||
removeCurrentStroke()
|
||||
|
||||
tryToAppendStrokeSample()
|
||||
|
||||
let stroke = ImageEditorStrokeItem(color: strokeColor, unitSamples: currentStrokeSamples, unitStrokeWidth: unitStrokeWidth)
|
||||
model.append(item: stroke)
|
||||
currentStroke = stroke
|
||||
|
||||
case .changed, .ended:
|
||||
tryToAppendStrokeSample()
|
||||
|
||||
guard let lastStroke = self.currentStroke else {
|
||||
owsFailDebug("Missing last stroke.")
|
||||
removeCurrentStroke()
|
||||
return
|
||||
}
|
||||
|
||||
// Model items are immutable; we _replace_ the
|
||||
// stroke item rather than modify it.
|
||||
let stroke = ImageEditorStrokeItem(itemId: lastStroke.itemId, color: strokeColor, unitSamples: currentStrokeSamples, unitStrokeWidth: unitStrokeWidth)
|
||||
model.replace(item: stroke, suppressUndo: true)
|
||||
|
||||
if gestureRecognizer.state == .ended {
|
||||
currentStroke = nil
|
||||
currentStrokeSamples.removeAll()
|
||||
} else {
|
||||
currentStroke = stroke
|
||||
}
|
||||
default:
|
||||
removeCurrentStroke()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: -
|
||||
|
||||
extension ImageEditorBrushViewController: ImageEditorModelObserver {
|
||||
|
||||
public func imageEditorModelDidChange(before: ImageEditorContents,
|
||||
after: ImageEditorContents) {
|
||||
updateNavigationBar()
|
||||
}
|
||||
|
||||
public func imageEditorModelDidChange(changedItemIds: [String]) {
|
||||
updateNavigationBar()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: -
|
||||
|
||||
extension ImageEditorBrushViewController: ImageEditorPaletteViewDelegate {
|
||||
public func selectedColorDidChange() {
|
||||
// TODO:
|
||||
}
|
||||
}
|
@ -0,0 +1,39 @@
|
||||
//
|
||||
// Copyright (c) 2019 Open Whisper Systems. All rights reserved.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
|
||||
public extension NSObject {
|
||||
|
||||
public func navigationBarButton(imageName: String,
|
||||
selector: Selector) -> UIView {
|
||||
let button = OWSButton()
|
||||
button.setImage(imageName: imageName)
|
||||
button.tintColor = .white
|
||||
button.addTarget(self, action: selector, for: .touchUpInside)
|
||||
button.layer.shadowColor = UIColor.black.cgColor
|
||||
button.layer.shadowRadius = 2
|
||||
button.layer.shadowOpacity = 0.66
|
||||
return button
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: -
|
||||
|
||||
public extension UIViewController {
|
||||
|
||||
public func updateNavigationBar(navigationBarItems: [UIView]) {
|
||||
guard navigationBarItems.count > 0 else {
|
||||
self.navigationItem.rightBarButtonItems = []
|
||||
return
|
||||
}
|
||||
|
||||
let stackView = UIStackView(arrangedSubviews: navigationBarItems)
|
||||
stackView.axis = .horizontal
|
||||
stackView.spacing = 8
|
||||
stackView.alignment = .center
|
||||
|
||||
self.navigationItem.rightBarButtonItem = UIBarButtonItem(customView: stackView)
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue