Signal-iOS/SignalUI/ViewControllers/AttachmentApproval/AttachmentTextToolbar.swift
Igor Solomennikov a71c59b9ad Fix issue making it impossible to activate popup menu in media editor's review screen.
Placeholder text view (the one that displays "Add Message") was stealing
focus and the fix is to disable user interaction for it.
2022-07-14 12:35:29 -07:00

371 lines
14 KiB
Swift

//
// Copyright (c) 2022 Open Whisper Systems. All rights reserved.
//
import Foundation
import UIKit
import SignalMessaging
// Coincides with Android's max text message length
let kMaxMessageBodyCharacterCount = 2000
protocol AttachmentTextToolbarDelegate: AnyObject {
func attachmentTextToolbarDidBeginEditing(_ attachmentTextToolbar: AttachmentTextToolbar)
func attachmentTextToolbarDidEndEditing(_ attachmentTextToolbar: AttachmentTextToolbar)
func attachmentTextToolbarDidChange(_ attachmentTextToolbar: AttachmentTextToolbar)
}
// MARK: -
class AttachmentTextToolbar: UIView {
// Forward text editing-related events to AttachmentApprovalToolbar.
weak var delegate: AttachmentTextToolbarDelegate?
// Forward mention-related calls directly to the view controller.
weak var mentionTextViewDelegate: MentionTextViewDelegate?
private var isViewOnceEnabled: Bool = false
func setIsViewOnce(enabled: Bool, animated: Bool) {
guard isViewOnceEnabled != enabled else { return }
isViewOnceEnabled = enabled
updateContent(animated: animated)
}
var isEditingText: Bool {
textView.isFirstResponder
}
var messageBody: MessageBody? {
get {
// Ignore message text if "view-once" is enabled.
guard !isViewOnceEnabled else {
return nil
}
return textView.messageBody
}
set {
textView.messageBody = newValue
updateAppearance(animated: false)
}
}
// MARK: - Initializers
override init(frame: CGRect) {
super.init(frame: frame)
// Specifying autoresizing mask and an intrinsic content size allows proper
// sizing when used as an input accessory view.
autoresizingMask = .flexibleHeight
preservesSuperviewLayoutMargins = true
translatesAutoresizingMaskIntoConstraints = false
layoutMargins.top = 10
layoutMargins.bottom = 10
textView.mentionDelegate = self
// Layout
addSubview(textViewContainer)
textViewContainer.autoPinEdgesToSuperviewMargins()
// We pin 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
textViewWrapperView.autoPinEdge(toSuperviewMargin: .top)
textViewWrapperView.autoPinEdge(toSuperviewMargin: .bottom)
addSubview(addMessageButton)
addMessageButton.autoPinEdgesToSuperviewMargins()
addConstraint({
let constraint = addMessageButton.heightAnchor.constraint(equalToConstant: kMinTextViewHeight)
constraint.priority = UILayoutPriority.defaultLow
return constraint
}())
addSubview(viewOnceMediaLabel)
viewOnceMediaLabel.autoPinEdgesToSuperviewMargins()
addConstraint({
let constraint = viewOnceMediaLabel.heightAnchor.constraint(equalToConstant: kMinTextViewHeight)
constraint.priority = UILayoutPriority.defaultLow
return constraint
}())
updateContent(animated: false)
}
@available(*, unavailable, message: "Use init(frame:) instead")
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
// MARK: - UIView Overrides
// Since we have `self.autoresizingMask = UIViewAutoresizingFlexibleHeight`, we must specify
// an intrinsicContentSize. Specifying CGSize.zero causes the height to be determined by autolayout.
override var intrinsicContentSize: CGSize { .zero }
// MARK: - Subviews
private let kMinTextViewHeight: CGFloat = 36
private 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
}
private lazy var textViewMinimumHeightConstraint: NSLayoutConstraint = {
textView.heightAnchor.constraint(greaterThanOrEqualToConstant: kMinTextViewHeight)
}()
private lazy var textViewHeightConstraint: NSLayoutConstraint = {
textView.heightAnchor.constraint(equalToConstant: kMinTextViewHeight)
}()
private func updateContent(animated: Bool) {
AssertIsOnMainThread()
updateAppearance(animated: animated)
updateHeight()
}
private func updateAppearance(animated: Bool) {
let hasText: Bool = {
guard let text = self.textView.text else {
return false
}
return !text.isEmpty
}()
addMessageButton.setIsHidden(hasText || isEditingText || isViewOnceEnabled, animated: animated)
viewOnceMediaLabel.setIsHidden(!isViewOnceEnabled, animated: animated)
textViewContainer.setIsHidden((!hasText && !isEditingText) || isViewOnceEnabled, animated: animated)
placeholderTextView.setIsHidden(hasText, animated: animated)
doneButton.setIsHidden(!isEditingText, animated: animated)
if let blueCircleView = doneButton.subviews.first(where: { $0 is CircleView }) {
doneButton.sendSubviewToBack(blueCircleView)
}
}
lazy private(set) var textView: MentionTextView = {
let textView = buildTextView()
textView.returnKeyType = .done
textView.scrollIndicatorInsets = UIEdgeInsets(top: 5, left: 0, bottom: 5, right: 3)
textView.mentionDelegate = self
return textView
}()
private let placeholderText = OWSLocalizedString("MEDIA_EDITOR_TEXT_FIELD_ADD_MESSAGE", comment: "Placeholder for message text input field in media editor.")
private lazy var placeholderTextView: UITextView = {
let placeholderTextView = buildTextView()
placeholderTextView.text = placeholderText
placeholderTextView.isEditable = false
placeholderTextView.isUserInteractionEnabled = false
placeholderTextView.textContainer.maximumNumberOfLines = 1
placeholderTextView.textContainer.lineBreakMode = .byTruncatingTail
placeholderTextView.textColor = .ows_gray45
return placeholderTextView
}()
private lazy var addMessageButton: UIButton = {
let button = OWSButton(title: placeholderText) { [weak self] in
guard let self = self else { return }
self.didTapAddMessage()
}
button.setTitleColor(.ows_white, for: .normal)
button.titleLabel?.lineBreakMode = .byTruncatingTail
button.titleLabel?.textAlignment = .center
button.titleLabel?.font = .ows_dynamicTypeBodyClamped
return button
}()
private lazy var viewOnceMediaLabel: UILabel = {
let label = UILabel()
label.text = OWSLocalizedString("MEDIA_EDITOR_TEXT_FIELD_VIEW_ONCE_MEDIA", comment: "Shown in place of message input text in media editor when 'View Once' is on.")
label.numberOfLines = 1
label.lineBreakMode = .byTruncatingTail
label.textAlignment = .center
label.textColor = .ows_whiteAlpha50
label.font = .ows_dynamicTypeBodyClamped
return label
}()
private lazy var textViewContainer: UIView = {
let hStackView = UIStackView(arrangedSubviews: [ textViewWrapperView, doneButton ])
hStackView.axis = .horizontal
hStackView.alignment = .bottom
hStackView.spacing = 4
return hStackView
}()
private lazy var doneButton: UIButton = {
let doneButton = OWSButton(imageName: "check-24", tintColor: .white) { [weak self] in
guard let self = self else { return }
self.didTapFinishEditing()
}
let visibleButtonSize = kMinTextViewHeight
doneButton.layoutMargins = UIEdgeInsets(top: 0, leading: 8, bottom: 0, trailing: 0)
doneButton.contentEdgeInsets = doneButton.layoutMargins
doneButton.accessibilityLabel = CommonStrings.doneButton
let blueCircle = CircleView(diameter: visibleButtonSize)
blueCircle.backgroundColor = .ows_accentBlue
blueCircle.isUserInteractionEnabled = false
doneButton.addSubview(blueCircle)
doneButton.sendSubviewToBack(blueCircle)
blueCircle.autoPinEdgesToSuperviewMargins()
return doneButton
}()
private lazy var textViewWrapperView: UIView = {
let backgroundView = UIView()
backgroundView.backgroundColor = .ows_gray80
backgroundView.layer.cornerRadius = kMinTextViewHeight / 2
backgroundView.clipsToBounds = true
let wrapperView = UIView()
wrapperView.addSubview(backgroundView)
backgroundView.autoPinEdgesToSuperviewEdges()
wrapperView.addSubview(textView)
textView.autoPinEdgesToSuperviewEdges()
wrapperView.addConstraint(textViewHeightConstraint)
wrapperView.addConstraint(textViewMinimumHeightConstraint)
wrapperView.addSubview(placeholderTextView)
placeholderTextView.autoPinEdges(toEdgesOf: textView)
return wrapperView
}()
private func buildTextView() -> MentionTextView {
let textView = AttachmentTextView()
textView.keyboardAppearance = Theme.darkThemeKeyboardAppearance
textView.backgroundColor = .clear
textView.tintColor = Theme.darkThemePrimaryColor
textView.font = .ows_dynamicTypeBodyClamped
textView.textColor = Theme.darkThemePrimaryColor
textView.textContainerInset.left = 7
textView.textContainerInset.right = 7
return textView
}
}
// MARK: - Actions
extension AttachmentTextToolbar {
@objc
private func didTapFinishEditing() {
textView.acceptAutocorrectSuggestion()
textView.resignFirstResponder()
}
@objc
private func didTapAddMessage() {
guard !isViewOnceEnabled else { return }
textView.becomeFirstResponder()
}
}
extension AttachmentTextToolbar: MentionTextViewDelegate {
func textViewDidBeginTypingMention(_ textView: MentionTextView) {
mentionTextViewDelegate?.textViewDidBeginTypingMention(textView)
}
func textViewDidEndTypingMention(_ textView: MentionTextView) {
mentionTextViewDelegate?.textViewDidEndTypingMention(textView)
}
func textViewMentionPickerParentView(_ textView: MentionTextView) -> UIView? {
return mentionTextViewDelegate?.textViewMentionPickerParentView(textView)
}
func textViewMentionPickerReferenceView(_ textView: MentionTextView) -> UIView? {
return mentionTextViewDelegate?.textViewMentionPickerReferenceView(textView)
}
func textViewMentionPickerPossibleAddresses(_ textView: MentionTextView) -> [SignalServiceAddress] {
return mentionTextViewDelegate?.textViewMentionPickerPossibleAddresses(textView) ?? []
}
func textView(_ textView: MentionTextView, didDeleteMention mention: Mention) {}
func textViewMentionStyle(_ textView: MentionTextView) -> Mention.Style {
return .composingAttachment
}
}
extension AttachmentTextToolbar: UITextViewDelegate {
public func textViewDidChange(_ textView: UITextView) {
updateContent(animated: false)
delegate?.attachmentTextToolbarDidChange(self)
}
public func textView(_ textView: UITextView, shouldChangeTextIn range: NSRange, replacementText text: String) -> Bool {
// 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 textViewShouldBeginEditing(_ textView: UITextView) -> Bool {
// Putting these lines in `textViewDidBeginEditing` doesn't work.
textView.textContainer.lineBreakMode = .byWordWrapping
textView.textContainer.maximumNumberOfLines = 0
return true
}
public func textViewDidBeginEditing(_ textView: UITextView) {
// Making textView think its content has changed is necessary
// in order to get correct textView size and expand it to multiple lines if necessary.
textView.layoutManager.processEditing(for: textView.textStorage,
edited: .editedCharacters,
range: NSRange(location: 0, length: 0),
changeInLength: 0,
invalidatedRange: NSRange(location: 0, length: 0))
delegate?.attachmentTextToolbarDidBeginEditing(self)
updateContent(animated: true)
}
public func textViewDidEndEditing(_ textView: UITextView) {
textView.textContainer.lineBreakMode = .byTruncatingTail
textView.textContainer.maximumNumberOfLines = 1
delegate?.attachmentTextToolbarDidEndEditing(self)
updateContent(animated: true)
}
}
extension AttachmentTextToolbar {
private func updateHeight() {
// Minimum text area size defines text field size when input field isn't active.
let placeholderTextViewHeight = clampedHeight(for: placeholderTextView)
textViewMinimumHeightConstraint.constant = placeholderTextViewHeight
// Always keep height of the text field in expanded state current.
let textViewHeight = clampedHeight(for: textView)
if textViewHeightConstraint.constant != textViewHeight {
Logger.debug("TextView height changed: \(textViewHeightConstraint.constant) -> \(textViewHeight)")
textViewHeightConstraint.constant = textViewHeight
invalidateIntrinsicContentSize()
}
textViewHeightConstraint.isActive = isEditingText
}
private func clampedHeight(for textView: UITextView) -> CGFloat {
let fixedWidth = textView.width
let contentSize = textView.sizeThatFits(CGSize(width: fixedWidth, height: CGFloat.greatestFiniteMagnitude))
return CGFloatClamp(contentSize.height, kMinTextViewHeight, maxTextViewHeight)
}
}