session-ios/SignalUtilitiesKit/Media Viewing & Editing/Attachment Approval/AttachmentTextToolbar.swift
Morgan Pretty 61f809caee Fixed a couple of bugs and started UI refactoring
Refactored the UI creation and layout code in the attachments UI.
Started refactoring the UI in the MediaMessageView (converting the existing stuff and will then consolidate when done).
Fixed a bug where playing a video attachment would result in the zoom continually getting reset.
Fixed a bug where the attachment zoom scale would randomly change causing odd behaviours.
2022-01-12 16:57:04 +11:00

325 lines
13 KiB
Swift

//
// Copyright (c) 2019 Open Whisper Systems. All rights reserved.
//
import Foundation
import UIKit
import SessionUIKit
// Coincides with Android's max text message length
let kMaxMessageBodyCharacterCount = 2000
protocol AttachmentTextToolbarDelegate: class {
func attachmentTextToolbarDidTapSend(_ attachmentTextToolbar: AttachmentTextToolbar)
func attachmentTextToolbarDidBeginEditing(_ attachmentTextToolbar: AttachmentTextToolbar)
func attachmentTextToolbarDidEndEditing(_ attachmentTextToolbar: AttachmentTextToolbar)
func attachmentTextToolbarDidChange(_ attachmentTextToolbar: AttachmentTextToolbar)
}
// MARK: -
class AttachmentTextToolbar: UIView, UITextViewDelegate {
weak var attachmentTextToolbarDelegate: AttachmentTextToolbarDelegate?
var messageText: String? {
get { return textView.text }
set {
textView.text = newValue
updatePlaceholderTextViewVisibility()
}
}
// Layout Constants
static let kToolbarMargin: CGFloat = 8
static let kMinTextViewHeight: CGFloat = 40
var maxTextViewHeight: CGFloat {
// About ~4 lines in portrait and ~3 lines in landscape.
// Otherwise we risk obscuring too much of the content.
return UIDevice.current.orientation.isPortrait ? 160 : 100
}
var textViewHeightConstraint: NSLayoutConstraint!
var textViewHeight: CGFloat
// MARK: - Initializers
init() {
self.sendButton = UIButton(type: .system)
self.textViewHeight = AttachmentTextToolbar.kMinTextViewHeight
super.init(frame: CGRect.zero)
// Specifying autorsizing mask and an intrinsic content size allows proper
// sizing when used as an input accessory view.
self.autoresizingMask = .flexibleHeight
self.translatesAutoresizingMaskIntoConstraints = false
self.backgroundColor = UIColor.clear
textView.delegate = self
let sendTitle = NSLocalizedString("ATTACHMENT_APPROVAL_SEND_BUTTON", comment: "Label for 'send' button in the 'attachment approval' dialog.")
sendButton.setTitle(sendTitle, for: .normal)
sendButton.addTarget(self, action: #selector(didTapSend), for: .touchUpInside)
sendButton.titleLabel?.font = .boldSystemFont(ofSize: Values.mediumFontSize)
sendButton.titleLabel?.textAlignment = .center
sendButton.tintColor = Colors.accent
// Increase hit area of send button
sendButton.contentEdgeInsets = UIEdgeInsets(top: 6, left: 8, bottom: 6, right: 8)
let contentView = UIView()
contentView.addSubview(sendButton)
contentView.addSubview(textContainer)
contentView.addSubview(lengthLimitLabel)
addSubview(contentView)
contentView.autoPinEdgesToSuperviewEdges()
// Layout
// We have to wrap the toolbar items in a content view because iOS (at least on iOS10.3) assigns the inputAccessoryView.layoutMargins
// when resigning first responder (verified by auditing with `layoutMarginsDidChange`).
// The effect of this is that if we were to assign these margins to self.layoutMargins, they'd be blown away if the
// user dismisses the keyboard, giving the input accessory view a wonky layout.
contentView.layoutMargins = UIEdgeInsets(
top: AttachmentTextToolbar.kToolbarMargin,
left: AttachmentTextToolbar.kToolbarMargin,
bottom: AttachmentTextToolbar.kToolbarMargin,
right: AttachmentTextToolbar.kToolbarMargin
)
self.textViewHeightConstraint = textView.autoSetDimension(.height, toSize: AttachmentTextToolbar.kMinTextViewHeight)
// We pin all three edges explicitly rather than doing something like:
// textView.autoPinEdges(toSuperviewMarginsExcludingEdge: .right)
// because that method uses `leading` / `trailing` rather than `left` vs. `right`.
// So it doesn't work as expected with RTL layouts when we explicitly want something
// to be on the right side for both RTL and LTR layouts, like with the send button.
// I believe this is a bug in PureLayout. Filed here: https://github.com/PureLayout/PureLayout/issues/209
textContainer.autoPinEdge(toSuperviewMargin: .top)
textContainer.autoPinEdge(toSuperviewMargin: .bottom)
textContainer.autoPinEdge(toSuperviewMargin: .left)
sendButton.autoPinEdge(.left, to: .right, of: textContainer, withOffset: AttachmentTextToolbar.kToolbarMargin)
sendButton.autoPinEdge(.bottom, to: .bottom, of: textContainer, withOffset: -3)
sendButton.autoPinEdge(toSuperviewMargin: .right)
sendButton.setContentHuggingHigh()
sendButton.setCompressionResistanceHigh()
lengthLimitLabel.autoPinEdge(toSuperviewMargin: .left)
lengthLimitLabel.autoPinEdge(toSuperviewMargin: .right)
lengthLimitLabel.autoPinEdge(.bottom, to: .top, of: textContainer, withOffset: -6)
lengthLimitLabel.setContentHuggingHigh()
lengthLimitLabel.setCompressionResistanceHigh()
}
required init?(coder aDecoder: NSCoder) {
notImplemented()
}
// MARK: - UIView Overrides
override var intrinsicContentSize: CGSize {
get {
// Since we have `self.autoresizingMask = UIViewAutoresizingFlexibleHeight`, we must specify
// an intrinsicContentSize. Specifying CGSize.zero causes the height to be determined by autolayout.
return CGSize.zero
}
}
// MARK: - Subviews
private let sendButton: UIButton
private lazy var lengthLimitLabel: UILabel = {
let lengthLimitLabel = UILabel()
// Length Limit Label shown when the user inputs too long of a message
lengthLimitLabel.textColor = .white
lengthLimitLabel.text = NSLocalizedString("ATTACHMENT_APPROVAL_MESSAGE_LENGTH_LIMIT_REACHED", comment: "One-line label indicating the user can add no more text to the media message field.")
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.layer.shadowRadius = 2.0
lengthLimitLabel.isHidden = true
return lengthLimitLabel
}()
lazy var textView: UITextView = {
let textView = buildTextView()
textView.returnKeyType = .done
textView.scrollIndicatorInsets = UIEdgeInsets(top: 5, left: 0, bottom: 5, right: 3)
return textView
}()
private lazy var placeholderTextView: UITextView = {
let placeholderTextView = buildTextView()
placeholderTextView.text = NSLocalizedString("Message", comment: "")
placeholderTextView.isEditable = false
return placeholderTextView
}()
private lazy var textContainer: UIView = {
let textContainer = UIView()
textContainer.layer.borderColor = UIColor.white.cgColor
textContainer.layer.borderWidth = Values.separatorThickness
textContainer.layer.cornerRadius = (AttachmentTextToolbar.kMinTextViewHeight / 2)
textContainer.clipsToBounds = true
textContainer.addSubview(placeholderTextView)
placeholderTextView.autoPinEdgesToSuperviewEdges()
textContainer.addSubview(textView)
textView.autoPinEdgesToSuperviewEdges()
return textContainer
}()
private func buildTextView() -> UITextView {
let textView = AttachmentTextView()
textView.keyboardAppearance = isLightMode ? .default : .dark
textView.backgroundColor = .clear
textView.tintColor = .white
textView.font = .systemFont(ofSize: Values.mediumFontSize)
textView.textColor = .white
textView.showsVerticalScrollIndicator = false
textView.textContainerInset = UIEdgeInsets(top: 10, left: 10, bottom: 10, right: 10)
return textView
}
// MARK: - Actions
@objc func didTapSend() {
attachmentTextToolbarDelegate?.attachmentTextToolbarDidTapSend(self)
}
// MARK: - UITextViewDelegate
public func textViewDidChange(_ textView: UITextView) {
updateHeight(textView: textView)
attachmentTextToolbarDelegate?.attachmentTextToolbarDidChange(self)
}
public func textView(_ textView: UITextView, shouldChangeTextIn range: NSRange, replacementText text: String) -> Bool {
if !FeatureFlags.sendingMediaWithOversizeText {
let existingText: String = textView.text ?? ""
let proposedText: String = (existingText as NSString).replacingCharacters(in: range, with: text)
// Don't complicate things by mixing media attachments with oversize text attachments
guard proposedText.utf8.count < kOversizeTextMessageSizeThreshold else {
Logger.debug("long text was truncated")
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
}
self.lengthLimitLabel.isHidden = true
// After verifying the byte-length is sufficiently small, verify the character count is within bounds.
guard proposedText.count < kMaxMessageBodyCharacterCount else {
Logger.debug("hit attachment message body 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(kMaxMessageBodyCharacterCount) - charsAfterDelete
if charBudget >= 0 {
let acceptableNewText = String(text.prefix(charBudget))
textView.text = (existingText as NSString).replacingCharacters(in: range, with: acceptableNewText)
}
return false
}
}
// Though we can wrap the text, we don't want to encourage multline captions, plus a "done" button
// allows the user to get the keyboard out of the way while in the attachment approval view.
if text == "\n" {
textView.resignFirstResponder()
return false
} else {
return true
}
}
public func textViewDidBeginEditing(_ textView: UITextView) {
attachmentTextToolbarDelegate?.attachmentTextToolbarDidBeginEditing(self)
updatePlaceholderTextViewVisibility()
}
public func textViewDidEndEditing(_ textView: UITextView) {
attachmentTextToolbarDelegate?.attachmentTextToolbarDidEndEditing(self)
updatePlaceholderTextViewVisibility()
}
// MARK: - Helpers
func updatePlaceholderTextViewVisibility() {
let isHidden: Bool = {
guard !self.textView.isFirstResponder else {
return true
}
guard let text = self.textView.text else {
return false
}
guard text.count > 0 else {
return false
}
return true
}()
placeholderTextView.isHidden = isHidden
}
private func updateHeight(textView: UITextView) {
// compute new height assuming width is unchanged
let currentSize = textView.frame.size
let newHeight = clampedTextViewHeight(fixedWidth: currentSize.width)
if newHeight != textViewHeight {
Logger.debug("TextView height changed: \(textViewHeight) -> \(newHeight)")
textViewHeight = newHeight
textViewHeightConstraint?.constant = textViewHeight
invalidateIntrinsicContentSize()
}
}
private func clampedTextViewHeight(fixedWidth: CGFloat) -> CGFloat {
let contentSize = textView.sizeThatFits(CGSize(width: fixedWidth, height: CGFloat.greatestFiniteMagnitude))
return CGFloatClamp(contentSize.height, AttachmentTextToolbar.kMinTextViewHeight, maxTextViewHeight)
}
}