session-ios/SignalUtilitiesKit/Media Viewing & Editing/Attachment Approval/AttachmentCaptionViewContro...

316 lines
11 KiB
Swift

//
// 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()
}
// MARK: -
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 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.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 backgroundView = UIView()
backgroundView.backgroundColor = UIColor(white: 0, alpha: 0.5)
view.addSubview(backgroundView)
view.sendSubviewToBack(backgroundView)
backgroundView.autoPinEdge(toSuperviewEdge: .leading)
backgroundView.autoPinEdge(toSuperviewEdge: .trailing)
backgroundView.autoPinEdge(toSuperviewEdge: .bottom)
backgroundView.autoPinEdge(.top, to: .top, of: stackView)
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 = .zero
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()
}
}