Merge branch 'charlesmchen/imageEditorDesign11'

pull/2/head
Matthew Chen 6 years ago
commit 4176d9a156

@ -20,8 +20,11 @@
340872C92239563500CB25B0 /* AttachmentItemCollection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 340872C42239563500CB25B0 /* AttachmentItemCollection.swift */; };
340872CA2239563500CB25B0 /* AttachmentApprovalViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 340872C52239563500CB25B0 /* AttachmentApprovalViewController.swift */; };
340872CB2239563500CB25B0 /* AttachmentPrepViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 340872C62239563500CB25B0 /* AttachmentPrepViewController.swift */; };
340872CC2239563500CB25B0 /* AttachmentCaptionViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 340872C72239563500CB25B0 /* AttachmentCaptionViewController.swift */; };
340872CE2239596100CB25B0 /* AttachmentApprovalInputAccessoryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 340872CD2239596000CB25B0 /* AttachmentApprovalInputAccessoryView.swift */; };
340872D02239787F00CB25B0 /* AttachmentTextToolbar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 340872CF2239787F00CB25B0 /* AttachmentTextToolbar.swift */; };
340872D622397E6800CB25B0 /* AttachmentCaptionToolbar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 340872D522397E6800CB25B0 /* AttachmentCaptionToolbar.swift */; };
340872D822397F4600CB25B0 /* AttachmentCaptionViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 340872D722397F4500CB25B0 /* AttachmentCaptionViewController.swift */; };
340872DA22397FEB00CB25B0 /* AttachmentTextView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 340872D922397FEB00CB25B0 /* AttachmentTextView.swift */; };
340B02BA1FA0D6C700F9CFEC /* ConversationViewItemTest.m in Sources */ = {isa = PBXBuildFile; fileRef = 340B02B91FA0D6C700F9CFEC /* ConversationViewItemTest.m */; };
340FC8A9204DAC8D007AEB0F /* NotificationSettingsOptionsViewController.m in Sources */ = {isa = PBXBuildFile; fileRef = 340FC87B204DAC8C007AEB0F /* NotificationSettingsOptionsViewController.m */; };
340FC8AA204DAC8D007AEB0F /* NotificationSettingsViewController.m in Sources */ = {isa = PBXBuildFile; fileRef = 340FC87C204DAC8C007AEB0F /* NotificationSettingsViewController.m */; };
@ -655,8 +658,11 @@
340872C42239563500CB25B0 /* AttachmentItemCollection.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AttachmentItemCollection.swift; sourceTree = "<group>"; };
340872C52239563500CB25B0 /* AttachmentApprovalViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AttachmentApprovalViewController.swift; sourceTree = "<group>"; };
340872C62239563500CB25B0 /* AttachmentPrepViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AttachmentPrepViewController.swift; sourceTree = "<group>"; };
340872C72239563500CB25B0 /* AttachmentCaptionViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AttachmentCaptionViewController.swift; sourceTree = "<group>"; };
340872CD2239596000CB25B0 /* AttachmentApprovalInputAccessoryView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AttachmentApprovalInputAccessoryView.swift; sourceTree = "<group>"; };
340872CF2239787F00CB25B0 /* AttachmentTextToolbar.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AttachmentTextToolbar.swift; sourceTree = "<group>"; };
340872D522397E6800CB25B0 /* AttachmentCaptionToolbar.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AttachmentCaptionToolbar.swift; sourceTree = "<group>"; };
340872D722397F4500CB25B0 /* AttachmentCaptionViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AttachmentCaptionViewController.swift; sourceTree = "<group>"; };
340872D922397FEB00CB25B0 /* AttachmentTextView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AttachmentTextView.swift; sourceTree = "<group>"; };
340B02B61F9FD31800F9CFEC /* he */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = he; path = translations/he.lproj/Localizable.strings; sourceTree = "<group>"; };
340B02B91FA0D6C700F9CFEC /* ConversationViewItemTest.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = ConversationViewItemTest.m; sourceTree = "<group>"; };
340FC87B204DAC8C007AEB0F /* NotificationSettingsOptionsViewController.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = NotificationSettingsOptionsViewController.m; sourceTree = "<group>"; };
@ -1488,9 +1494,12 @@
340872C32239563500CB25B0 /* ApprovalRailCellView.swift */,
340872CD2239596000CB25B0 /* AttachmentApprovalInputAccessoryView.swift */,
340872C52239563500CB25B0 /* AttachmentApprovalViewController.swift */,
340872C72239563500CB25B0 /* AttachmentCaptionViewController.swift */,
340872D522397E6800CB25B0 /* AttachmentCaptionToolbar.swift */,
340872D722397F4500CB25B0 /* AttachmentCaptionViewController.swift */,
340872C42239563500CB25B0 /* AttachmentItemCollection.swift */,
340872C62239563500CB25B0 /* AttachmentPrepViewController.swift */,
340872CF2239787F00CB25B0 /* AttachmentTextToolbar.swift */,
340872D922397FEB00CB25B0 /* AttachmentTextView.swift */,
);
path = AttachmentApproval;
sourceTree = "<group>";
@ -3393,6 +3402,7 @@
34BBC857220C7ADA00857249 /* ImageEditorItem.swift in Sources */,
34480B641FD0A98800BC14EF /* UIView+OWS.m in Sources */,
34AC0A1C211B39EA00997B47 /* OWSFlatButton.swift in Sources */,
340872D822397F4600CB25B0 /* AttachmentCaptionViewController.swift in Sources */,
34C3C7932040B0DD0000134C /* OWSAudioPlayer.m in Sources */,
34AC09E5211B39B100997B47 /* ScreenLockViewController.m in Sources */,
34AC09F7211B39B100997B47 /* MediaMessageView.swift in Sources */,
@ -3400,7 +3410,6 @@
3461293A1FD1B47300532771 /* OWSPreferences.m in Sources */,
34AC09E6211B39B100997B47 /* SelectRecipientViewController.m in Sources */,
4C858A52212DC5E1001B45D3 /* UIImage+OWS.swift in Sources */,
340872CC2239563500CB25B0 /* AttachmentCaptionViewController.swift in Sources */,
34480B671FD0AA9400BC14EF /* UIFont+OWS.m in Sources */,
346129E61FD5C0C600532771 /* OWSDatabaseMigrationRunner.m in Sources */,
34AC0A11211B39EA00997B47 /* OWSLayerView.swift in Sources */,
@ -3423,6 +3432,7 @@
346129AD1FD1F34E00532771 /* ImageCache.swift in Sources */,
452C7CA72037628B003D51A5 /* Weak.swift in Sources */,
34D5872F208E2C4200D2255A /* OWS109OutgoingMessageState.m in Sources */,
340872D02239787F00CB25B0 /* AttachmentTextToolbar.swift in Sources */,
34AC09F8211B39B100997B47 /* CountryCodeViewController.m in Sources */,
451F8A341FD710C3005CB9DA /* FullTextSearcher.swift in Sources */,
34080F04222858DC0087E99F /* OWSViewController+ImageEditor.swift in Sources */,
@ -3459,6 +3469,7 @@
34BEDB0B21C2FA3D007B0EAE /* OWS114RemoveDynamicInteractions.swift in Sources */,
34AC0A1A211B39EA00997B47 /* CommonStrings.swift in Sources */,
34AC0A19211B39EA00997B47 /* OWSAlerts.swift in Sources */,
340872DA22397FEB00CB25B0 /* AttachmentTextView.swift in Sources */,
34FDB29221FF986600A01202 /* UIView+OWS.swift in Sources */,
34BBC859220C7ADA00857249 /* ImageEditorStrokeItem.swift in Sources */,
451F8A351FD710DE005CB9DA /* Searcher.swift in Sources */,
@ -3489,6 +3500,7 @@
349EA07C2162AEA800F7B17F /* OWS111UDAttributesMigration.swift in Sources */,
34480B561FD0A7A400BC14EF /* DebugLogger.m in Sources */,
459B775C207BA46C0071D0AB /* OWSQuotedReplyModel.m in Sources */,
340872D622397E6800CB25B0 /* AttachmentCaptionToolbar.swift in Sources */,
34ABB2C42090C59700C727A6 /* OWSResaveCollectionDBMigration.m in Sources */,
4C948FF72146EB4800349F0D /* BlockListCache.swift in Sources */,
4551DB5A205C562300C8AE75 /* Collection+OWS.swift in Sources */,

@ -95,6 +95,9 @@
/* placeholder text for an empty captioning field */
"ATTACHMENT_APPROVAL_CAPTION_PLACEHOLDER" = "Add a caption…";
/* Title for 'caption' mode of the attachment approval view. */
"ATTACHMENT_APPROVAL_CAPTION_TITLE" = "Caption";
/* Format string for file extension label in call interstitial view */
"ATTACHMENT_APPROVAL_FILE_EXTENSION_FORMAT" = "File type: %@";
@ -305,15 +308,9 @@
/* Label for the 'next' button. */
"BUTTON_NEXT" = "Next";
/* Label for redo button. */
"BUTTON_REDO" = "Redo";
/* Button text to enable batch selection mode */
"BUTTON_SELECT" = "Select";
/* Label for undo button. */
"BUTTON_UNDO" = "Undo";
/* Label for button that lets users call a contact again. */
"CALL_AGAIN_BUTTON_TITLE" = "Call Again";
@ -1068,13 +1065,13 @@
/* Placeholder text for search bar which filters conversations. */
"HOME_VIEW_CONVERSATION_SEARCHBAR_PLACEHOLDER" = "Search";
/* Format string for a label offering to start a new conversation with your contacts, if you have 1 Signal contact. Embeds: {{The name of 1 of your Signal contacts}}. */
/* Format string for a label offering to start a new conversation with your contacts, if you have 1 Signal contact. Embeds {{The name of 1 of your Signal contacts}}. */
"HOME_VIEW_FIRST_CONVERSATION_OFFER_1_CONTACT_FORMAT" = "Some of your contacts are already on Signal, including %@.";
/* Format string for a label offering to start a new conversation with your contacts, if you have 2 Signal contacts. Embeds: {{The names of 2 of your Signal contacts}}. */
/* Format string for a label offering to start a new conversation with your contacts, if you have 2 Signal contacts. Embeds {{The names of 2 of your Signal contacts}}. */
"HOME_VIEW_FIRST_CONVERSATION_OFFER_2_CONTACTS_FORMAT" = "Some of your contacts are already on Signal, including %@ and %@";
/* Format string for a label offering to start a new conversation with your contacts, if you have at least 3 Signal contacts. Embeds: {{The names of 3 of your Signal contacts}}. */
/* Format string for a label offering to start a new conversation with your contacts, if you have at least 3 Signal contacts. Embeds {{The names of 3 of your Signal contacts}}. */
"HOME_VIEW_FIRST_CONVERSATION_OFFER_3_CONTACTS_FORMAT" = "Some of your contacts are already on Signal, including %@, %@ and %@";
/* A label offering to start a new conversation with your contacts, if you have no Signal contacts. */
@ -1089,21 +1086,6 @@
/* Title for the home view's default mode. */
"HOME_VIEW_TITLE_INBOX" = "Signal";
/* Label for brush button in image editor. */
"IMAGE_EDITOR_BRUSH_BUTTON" = "Brush";
/* Label for crop button in image editor. */
"IMAGE_EDITOR_CROP_BUTTON" = "Crop";
/* Label for button that resets crop & rotation state. */
"IMAGE_EDITOR_RESET_BUTTON" = "Reset";
/* Label for button that rotates image 45 degrees. */
"IMAGE_EDITOR_ROTATE_45_BUTTON" = "Rotate 45°";
/* Label for button that rotates image 90 degrees. */
"IMAGE_EDITOR_ROTATE_90_BUTTON" = "Rotate 90°";
/* Momentarily shown to the user when attempting to select more images than is allowed. Embeds {{max number of items}} that can be shared. */
"IMAGE_PICKER_CAN_SELECT_NO_MORE_TOAST_FORMAT" = "You can't share more than %@ items.";
@ -1811,12 +1793,6 @@
/* Alert message explaining what happens if you forget your 'two-factor auth pin'. */
"REGISTER_2FA_FORGOT_PIN_ALERT_MESSAGE" = "Registration of this phone number will be possible without your Registration Lock PIN after 7 days have passed since the phone number was last active on Signal.";
/* Instructions to enter the 'two-factor auth pin' in the 2FA registration view. */
"REGISTER_2FA_INSTRUCTIONS" = "This phone number has Registration Lock enabled. Please enter the Registration Lock PIN.\n\nYour Registration Lock PIN is separate from the automated verification code that was sent to your phone during the last step.";
/* Title for alert indicating that attempt to register with 'two-factor auth' failed. */
"REGISTER_2FA_REGISTRATION_FAILED_ALERT_TITLE" = "Registration Failed";
/* Label for 'submit' button in the 2FA registration view. */
"REGISTER_2FA_SUBMIT_BUTTON" = "Submit";
@ -1838,9 +1814,6 @@
/* Label for the country code field */
"REGISTRATION_DEFAULT_COUNTRY_NAME" = "Country Code";
/* Navigation title shown when user is re-registering after having enabled registration lock */
"REGISTRATION_ENTER_LOCK_PIN_NAV_TITLE" = "Registration Lock";
/* Placeholder text for the phone number textfield */
"REGISTRATION_ENTERNUMBER_DEFAULT_TEXT" = "Enter Number";
@ -1859,15 +1832,6 @@
/* alert title when registering an iPad */
"REGISTRATION_IPAD_CONFIRM_TITLE" = "Already have a Signal account?";
/* one line label below submit button on registration screen, which links to an external webpage. */
"REGISTRATION_LEGAL_TERMS_LINK" = "Terms & Privacy Policy";
/* legal disclaimer, embeds a tappable {{link title}} which is styled as a hyperlink */
"REGISTRATION_LEGAL_TOP_MATTER_FORMAT" = "By registering this device, you agree to Signal's %@";
/* embedded in legal topmatter, styled as a link */
"REGISTRATION_LEGAL_TOP_MATTER_LINK_TITLE" = "terms";
/* No comment provided by engineer. */
"REGISTRATION_NON_VALID_NUMBER" = "This phone number format is not supported, please contact support.";
@ -1877,9 +1841,6 @@
/* No comment provided by engineer. */
"REGISTRATION_RESTRICTED_MESSAGE" = "You need to register before you can send a message.";
/* No comment provided by engineer. */
"REGISTRATION_TITLE_LABEL" = "Your Phone Number";
/* Alert view title */
"REGISTRATION_VERIFICATION_FAILED_TITLE" = "Verification Failed";
@ -1889,9 +1850,6 @@
/* Error message indicating that registration failed due to a missing or incorrect 2FA PIN. */
"REGISTRATION_VERIFICATION_FAILED_WRONG_PIN" = "Incorrect Registration Lock PIN.";
/* No comment provided by engineer. */
"REGISTRATION_VERIFY_DEVICE" = "Register";
/* Message of alert indicating that users needs to enter a valid phone number to register. */
"REGISTRATION_VIEW_INVALID_PHONE_NUMBER_ALERT_MESSAGE" = "Please enter a valid phone number to register.";
@ -2525,24 +2483,6 @@
/* Title for the alert indicating that user should upgrade iOS. */
"UPGRADE_IOS_ALERT_TITLE" = "Upgrade iOS";
/* button text for back button on verification view */
"VERIFICATION_BACK_BUTTON" = "Back";
/* Text field placeholder for SMS verification code during registration */
"VERIFICATION_CHALLENGE_DEFAULT_TEXT" = "Verification Code";
/* button text during registration to request phone number verification be done via phone call */
"VERIFICATION_CHALLENGE_SEND_VIA_VOICE" = "Call Me Instead";
/* button text during registration to request another SMS code be sent */
"VERIFICATION_CHALLENGE_SUBMIT_AGAIN" = "Resend Code by SMS";
/* button text during registration to submit your SMS verification code. */
"VERIFICATION_CHALLENGE_SUBMIT_CODE" = "Submit";
/* Label indicating the phone number currently being verified. */
"VERIFICATION_PHONE_NUMBER_FORMAT" = "Enter the verification code we sent to %@.";
/* Format for info message indicating that the verification state was unverified on this device. Embeds {{user's name or phone number}}. */
"VERIFICATION_STATE_CHANGE_FORMAT_NOT_VERIFIED_LOCAL" = "You marked %@ as not verified.";

@ -5,18 +5,36 @@
import Foundation
import UIKit
protocol AttachmentApprovalInputAccessoryViewDelegate: class {
func attachmentApprovalInputUpdateMediaRail()
func attachmentApprovalInputEditCaptions()
}
// MARK: -
class AttachmentApprovalInputAccessoryView: UIView {
let mediaMessageTextToolbar: MediaMessageTextToolbar
weak var delegate: AttachmentApprovalInputAccessoryViewDelegate?
let attachmentTextToolbar: AttachmentTextToolbar
let attachmentCaptionToolbar: AttachmentCaptionToolbar
let galleryRailView: GalleryRailView
let currentCaptionLabel = UILabel()
let currentCaptionWrapper = UIView()
var isEditingMediaMessage: Bool {
return mediaMessageTextToolbar.textView.isFirstResponder
return attachmentTextToolbar.textView.isFirstResponder
}
private var isEditingCaptions: Bool = false
private var currentAttachmentItem: SignalAttachmentItem?
let kGalleryRailViewHeight: CGFloat = 72
required init(isAddMoreVisible: Bool) {
mediaMessageTextToolbar = MediaMessageTextToolbar(isAddMoreVisible: isAddMoreVisible)
attachmentTextToolbar = AttachmentTextToolbar(isAddMoreVisible: isAddMoreVisible)
attachmentCaptionToolbar = AttachmentCaptionToolbar()
galleryRailView = GalleryRailView()
galleryRailView.scrollFocusMode = .keepWithinBounds
@ -24,377 +42,132 @@ class AttachmentApprovalInputAccessoryView: UIView {
super.init(frame: .zero)
// Specifying auto-resizing mask and an intrinsic content size allows proper
// sizing when used as an input accessory view.
self.autoresizingMask = .flexibleHeight
self.translatesAutoresizingMaskIntoConstraints = false
backgroundColor = UIColor.black.withAlphaComponent(0.6)
preservesSuperviewLayoutMargins = true
let stackView = UIStackView(arrangedSubviews: [self.galleryRailView, self.mediaMessageTextToolbar])
stackView.axis = .vertical
addSubview(stackView)
stackView.autoPinEdgesToSuperviewEdges()
createContents()
}
required init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
// MARK:
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: -
// Coincides with Android's max text message length
let kMaxMessageBodyCharacterCount = 2000
protocol MediaMessageTextToolbarDelegate: class {
func mediaMessageTextToolbarDidTapSend(_ mediaMessageTextToolbar: MediaMessageTextToolbar)
func mediaMessageTextToolbarDidBeginEditing(_ mediaMessageTextToolbar: MediaMessageTextToolbar)
func mediaMessageTextToolbarDidEndEditing(_ mediaMessageTextToolbar: MediaMessageTextToolbar)
func mediaMessageTextToolbarDidAddMore(_ mediaMessageTextToolbar: MediaMessageTextToolbar)
}
// MARK: -
class MediaMessageTextToolbar: UIView, UITextViewDelegate {
weak var mediaMessageTextToolbarDelegate: MediaMessageTextToolbarDelegate?
var messageText: String? {
get { return textView.text }
set {
textView.text = newValue
updatePlaceholderTextViewVisibility()
}
}
// Layout Constants
let kMinTextViewHeight: CGFloat = 38
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(isAddMoreVisible: Bool) {
self.addMoreButton = UIButton(type: .custom)
self.sendButton = UIButton(type: .system)
self.textViewHeight = kMinTextViewHeight
super.init(frame: CGRect.zero)
// Specifying autorsizing mask and an intrinsic content size allows proper
private func createContents() {
// Specifying auto-resizing 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 addMoreIcon = #imageLiteral(resourceName: "album_add_more").withRenderingMode(.alwaysTemplate)
addMoreButton.setImage(addMoreIcon, for: .normal)
addMoreButton.tintColor = Theme.darkThemePrimaryColor
addMoreButton.addTarget(self, action: #selector(didTapAddMore), for: .touchUpInside)
self.backgroundColor = .clear
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 = UIFont.ows_mediumFont(withSize: 16)
sendButton.titleLabel?.textAlignment = .center
sendButton.tintColor = Theme.galleryHighlightColor
// Increase hit area of send button
sendButton.contentEdgeInsets = UIEdgeInsets(top: 6, left: 8, bottom: 6, right: 8)
preservesSuperviewLayoutMargins = true
let contentView = UIView()
contentView.addSubview(sendButton)
contentView.addSubview(textContainer)
contentView.addSubview(lengthLimitLabel)
if isAddMoreVisible {
contentView.addSubview(addMoreButton)
}
// Use a background view that extends below the keyboard to avoid animation glitches.
let backgroundView = UIView()
backgroundView.backgroundColor = UIColor.black.withAlphaComponent(0.6)
addSubview(backgroundView)
backgroundView.autoPinEdge(toSuperviewEdge: .top)
backgroundView.autoPinEdge(toSuperviewEdge: .leading)
backgroundView.autoPinEdge(toSuperviewEdge: .trailing)
backgroundView.autoPinEdge(toSuperviewEdge: .bottom, withInset: -200)
addSubview(contentView)
contentView.autoPinEdgesToSuperviewEdges()
// Layout
let kToolbarMargin: CGFloat = 8
// 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: kToolbarMargin, left: kToolbarMargin, bottom: kToolbarMargin, right: kToolbarMargin)
self.textViewHeightConstraint = textView.autoSetDimension(.height, toSize: 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)
if isAddMoreVisible {
addMoreButton.autoPinEdge(toSuperviewMargin: .left)
textContainer.autoPinEdge(.left, to: .right, of: addMoreButton, withOffset: kToolbarMargin)
addMoreButton.autoAlignAxis(.horizontal, toSameAxisOf: sendButton)
addMoreButton.setContentHuggingHigh()
addMoreButton.setCompressionResistanceHigh()
} else {
textContainer.autoPinEdge(toSuperviewMargin: .left)
}
currentCaptionLabel.textColor = UIColor(white: 1, alpha: 0.8)
currentCaptionLabel.font = UIFont.ows_dynamicTypeBody
currentCaptionLabel.numberOfLines = 5
currentCaptionLabel.lineBreakMode = .byWordWrapping
sendButton.autoPinEdge(.left, to: .right, of: textContainer, withOffset: kToolbarMargin)
sendButton.autoPinEdge(.bottom, to: .bottom, of: textContainer, withOffset: -3)
currentCaptionWrapper.isUserInteractionEnabled = true
currentCaptionWrapper.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(captionTapped)))
currentCaptionWrapper.addSubview(currentCaptionLabel)
currentCaptionLabel.autoPinEdgesToSuperviewMargins()
sendButton.autoPinEdge(toSuperviewMargin: .right)
sendButton.setContentHuggingHigh()
sendButton.setCompressionResistanceHigh()
attachmentCaptionToolbar.attachmentCaptionToolbarDelegate = self
lengthLimitLabel.autoPinEdge(toSuperviewMargin: .left)
lengthLimitLabel.autoPinEdge(toSuperviewMargin: .right)
lengthLimitLabel.autoPinEdge(.bottom, to: .top, of: textContainer, withOffset: -6)
lengthLimitLabel.setContentHuggingHigh()
lengthLimitLabel.setCompressionResistanceHigh()
}
let stackView = UIStackView(arrangedSubviews: [currentCaptionWrapper, attachmentCaptionToolbar, galleryRailView, attachmentTextToolbar])
stackView.axis = .vertical
required init?(coder aDecoder: NSCoder) {
notImplemented()
addSubview(stackView)
stackView.autoPinEdgesToSuperviewEdges()
}
// MARK: - UIView Overrides
// MARK: - Events
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
@objc func captionTapped(sender: UIGestureRecognizer) {
guard sender.state == .recognized else {
return
}
delegate?.attachmentApprovalInputEditCaptions()
}
// MARK: - Subviews
private let addMoreButton: UIButton
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_TEXT_FIELD_PLACEHOLDER", comment: "placeholder text for the editable message field")
placeholderTextView.isEditable = false
return placeholderTextView
}()
private lazy var textContainer: UIView = {
let textContainer = UIView()
textContainer.layer.borderColor = Theme.darkThemePrimaryColor.cgColor
textContainer.layer.borderWidth = 0.5
textContainer.layer.cornerRadius = kMinTextViewHeight / 2
textContainer.clipsToBounds = true
textContainer.addSubview(placeholderTextView)
placeholderTextView.autoPinEdgesToSuperviewEdges()
textContainer.addSubview(textView)
textView.autoPinEdgesToSuperviewEdges()
return textContainer
}()
private func buildTextView() -> UITextView {
let textView = MessageTextView()
textView.keyboardAppearance = Theme.darkThemeKeyboardAppearance
textView.backgroundColor = .clear
textView.tintColor = Theme.darkThemePrimaryColor
textView.font = UIFont.ows_dynamicTypeBody
textView.textColor = Theme.darkThemePrimaryColor
textView.textContainerInset = UIEdgeInsets(top: 7, left: 7, bottom: 7, right: 7)
return textView
}
// MARK:
class MessageTextView: UITextView {
// When creating new lines, contentOffset is animated, but because
// we are simultaneously resizing the text view, this can cause the
// text in the textview to be "too high" in the text view.
// Solution is to disable animation for setting content offset.
override func setContentOffset(_ contentOffset: CGPoint, animated: Bool) {
super.setContentOffset(contentOffset, animated: false)
public var shouldHideControls = false {
didSet {
updateContents()
}
}
// MARK: - Actions
@objc func didTapSend() {
mediaMessageTextToolbarDelegate?.mediaMessageTextToolbarDidTapSend(self)
}
@objc func didTapAddMore() {
mediaMessageTextToolbarDelegate?.mediaMessageTextToolbarDidAddMore(self)
}
// MARK: - UITextViewDelegate
public func textViewDidChange(_ textView: UITextView) {
updateHeight(textView: textView)
}
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
private func updateContents() {
var hasCurrentCaption = false
if let currentAttachmentItem = currentAttachmentItem,
let captionText = currentAttachmentItem.captionText {
hasCurrentCaption = captionText.count > 0
// `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
attachmentCaptionToolbar.textView.text = captionText
currentCaptionLabel.text = captionText
} else {
attachmentCaptionToolbar.textView.text = nil
currentCaptionLabel.text = nil
}
// 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)
}
attachmentCaptionToolbar.isHidden = !isEditingCaptions
currentCaptionWrapper.isHidden = isEditingCaptions || !hasCurrentCaption
attachmentTextToolbar.isHidden = isEditingCaptions
return false
if (shouldHideControls) {
if attachmentCaptionToolbar.textView.isFirstResponder {
attachmentCaptionToolbar.textView.resignFirstResponder()
} else if attachmentTextToolbar.textView.isFirstResponder {
attachmentTextToolbar.textView.resignFirstResponder()
}
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
} else if (isEditingCaptions) {
// While editing captions, the keyboard should always remain visible.
if !attachmentCaptionToolbar.textView.isFirstResponder {
attachmentCaptionToolbar.textView.becomeFirstResponder()
}
}
// NOTE: We don't automatically make attachmentTextToolbar.textView
// first responder;
// 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
}
layoutSubviews()
}
public func textViewDidBeginEditing(_ textView: UITextView) {
mediaMessageTextToolbarDelegate?.mediaMessageTextToolbarDidBeginEditing(self)
updatePlaceholderTextViewVisibility()
}
public func update(isEditingCaptions: Bool,
currentAttachmentItem: SignalAttachmentItem?) {
self.isEditingCaptions = isEditingCaptions
self.currentAttachmentItem = currentAttachmentItem
public func textViewDidEndEditing(_ textView: UITextView) {
mediaMessageTextToolbarDelegate?.mediaMessageTextToolbarDidEndEditing(self)
updatePlaceholderTextViewVisibility()
updateContents()
}
// 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
}()
// MARK:
placeholderTextView.isHidden = isHidden
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
}
}
}
private func updateHeight(textView: UITextView) {
// compute new height assuming width is unchanged
let currentSize = textView.frame.size
let newHeight = clampedTextViewHeight(fixedWidth: currentSize.width)
// MARK: -
if newHeight != textViewHeight {
Logger.debug("TextView height changed: \(textViewHeight) -> \(newHeight)")
textViewHeight = newHeight
textViewHeightConstraint?.constant = textViewHeight
invalidateIntrinsicContentSize()
extension AttachmentApprovalInputAccessoryView: AttachmentCaptionToolbarDelegate {
public func attachmentCaptionToolbarDidEdit(_ attachmentCaptionToolbar: AttachmentCaptionToolbar) {
guard let currentAttachmentItem = currentAttachmentItem else {
owsFailDebug("Missing currentAttachmentItem.")
return
}
}
private func clampedTextViewHeight(fixedWidth: CGFloat) -> CGFloat {
let contentSize = textView.sizeThatFits(CGSize(width: fixedWidth, height: CGFloat.greatestFiniteMagnitude))
return CGFloatClamp(contentSize.height, kMinTextViewHeight, maxTextViewHeight)
currentAttachmentItem.attachment.captionText = attachmentCaptionToolbar.textView.text
delegate?.attachmentApprovalInputUpdateMediaRail()
}
}

@ -34,6 +34,12 @@ public class AttachmentApprovalViewController: UIPageViewController, UIPageViewC
public weak var approvalDelegate: AttachmentApprovalViewControllerDelegate?
public var isEditingCaptions = false {
didSet {
updateContents()
}
}
// MARK: - Initializers
@available(*, unavailable, message:"use attachment: constructor instead.")
@ -79,13 +85,14 @@ public class AttachmentApprovalViewController: UIPageViewController, UIPageViewC
return bottomToolView.galleryRailView
}
var mediaMessageTextToolbar: MediaMessageTextToolbar {
return bottomToolView.mediaMessageTextToolbar
var attachmentTextToolbar: AttachmentTextToolbar {
return bottomToolView.attachmentTextToolbar
}
lazy var bottomToolView: AttachmentApprovalInputAccessoryView = {
let isAddMoreVisible = mode == .sharedNavigation
let bottomToolView = AttachmentApprovalInputAccessoryView(isAddMoreVisible: isAddMoreVisible)
bottomToolView.delegate = self
return bottomToolView
}()
@ -106,7 +113,7 @@ public class AttachmentApprovalViewController: UIPageViewController, UIPageViewC
// Bottom Toolbar
galleryRailView.delegate = self
mediaMessageTextToolbar.mediaMessageTextToolbarDelegate = self
attachmentTextToolbar.attachmentTextToolbarDelegate = self
// Navigation
@ -133,8 +140,7 @@ public class AttachmentApprovalViewController: UIPageViewController, UIPageViewC
}
navigationBar.overrideTheme(type: .clear)
updateNavigationBar()
updateControlVisibility()
updateContents()
}
override public func viewDidAppear(_ animated: Bool) {
@ -142,8 +148,7 @@ public class AttachmentApprovalViewController: UIPageViewController, UIPageViewC
super.viewDidAppear(animated)
updateNavigationBar()
updateControlVisibility()
updateContents()
}
override public func viewWillDisappear(_ animated: Bool) {
@ -151,6 +156,14 @@ public class AttachmentApprovalViewController: UIPageViewController, UIPageViewC
super.viewWillDisappear(animated)
}
private func updateContents() {
updateNavigationBar()
updateControlVisibility()
updateInputAccessory()
}
// MARK: - Input Accessory
override public var inputAccessoryView: UIView? {
bottomToolView.layoutIfNeeded()
return bottomToolView
@ -160,6 +173,15 @@ public class AttachmentApprovalViewController: UIPageViewController, UIPageViewC
return !shouldHideControls
}
public func updateInputAccessory() {
var currentPageViewController: AttachmentPrepViewController?
if pageViewControllers.count == 1 {
currentPageViewController = pageViewControllers.first
}
let currentAttachmentItem: SignalAttachmentItem? = currentPageViewController?.attachmentItem
bottomToolView.update(isEditingCaptions: isEditingCaptions, currentAttachmentItem: currentAttachmentItem)
}
// MARK: - Navigation Bar
public func updateNavigationBar() {
@ -169,21 +191,36 @@ public class AttachmentApprovalViewController: UIPageViewController, UIPageViewC
return
}
guard !isEditingCaptions else {
// Hide all navigation bar items while the caption view is open.
self.navigationItem.leftBarButtonItem = UIBarButtonItem(title: NSLocalizedString("ATTACHMENT_APPROVAL_CAPTION_TITLE", comment: "Title for 'caption' mode of the attachment approval view."), style: .plain, target: nil, action: nil)
let doneButton = navigationBarButton(imageName: "image_editor_checkmark_full",
selector: #selector(didTapCaptionDone(sender:)))
let navigationBarItems = [doneButton]
updateNavigationBar(navigationBarItems: navigationBarItems)
return
}
var navigationBarItems = [UIView]()
var isShowingCaptionView = false
if let viewControllers = viewControllers,
viewControllers.count == 1,
let firstViewController = viewControllers.first as? AttachmentPrepViewController {
navigationBarItems = firstViewController.navigationBarItems()
isShowingCaptionView = firstViewController.isShowingCaptionView
}
guard !isShowingCaptionView else {
// Hide all navigation bar items while the caption view is open.
self.navigationItem.leftBarButtonItem = nil
self.navigationItem.rightBarButtonItem = nil
return
// Show the caption UI if there's more than one attachment
// OR if the attachment already has a caption.
let attachmentCount = attachmentItemCollection.count
var shouldShowCaptionUI = attachmentCount > 0
if let captionText = firstViewController.attachmentItem.captionText, captionText.count > 0 {
shouldShowCaptionUI = true
}
if shouldShowCaptionUI {
let captionButton = navigationBarButton(imageName: "image_editor_caption",
selector: #selector(didTapCaption(sender:)))
navigationBarItems.append(captionButton)
}
}
updateNavigationBar(navigationBarItems: navigationBarItems)
@ -264,15 +301,10 @@ public class AttachmentApprovalViewController: UIPageViewController, UIPageViewC
}
private func updateControlVisibility() {
if shouldHideControls {
if isFirstResponder {
resignFirstResponder()
}
} else {
if !isFirstResponder {
becomeFirstResponder()
}
if !shouldHideControls, !isFirstResponder {
becomeFirstResponder()
}
bottomToolView.shouldHideControls = shouldHideControls
}
// MARK: - View Helpers
@ -351,8 +383,7 @@ public class AttachmentApprovalViewController: UIPageViewController, UIPageViewC
}
}
updateNavigationBar()
updateControlVisibility()
updateContents()
}
// MARK: - UIPageViewControllerDataSource
@ -564,29 +595,43 @@ public class AttachmentApprovalViewController: UIPageViewController, UIPageViewC
private func cancelPressed() {
self.approvalDelegate?.attachmentApproval(self, didCancelAttachments: attachments)
}
@objc func didTapCaption(sender: UIButton) {
Logger.verbose("")
isEditingCaptions = true
}
@objc func didTapCaptionDone(sender: UIButton) {
Logger.verbose("")
isEditingCaptions = false
}
}
extension AttachmentApprovalViewController: MediaMessageTextToolbarDelegate {
func mediaMessageTextToolbarDidBeginEditing(_ mediaMessageTextToolbar: MediaMessageTextToolbar) {
// MARK: -
extension AttachmentApprovalViewController: AttachmentTextToolbarDelegate {
func attachmentTextToolbarDidBeginEditing(_ attachmentTextToolbar: AttachmentTextToolbar) {
currentPageViewController.setAttachmentViewScale(.compact, animated: true)
}
func mediaMessageTextToolbarDidEndEditing(_ mediaMessageTextToolbar: MediaMessageTextToolbar) {
func attachmentTextToolbarDidEndEditing(_ attachmentTextToolbar: AttachmentTextToolbar) {
currentPageViewController.setAttachmentViewScale(.fullsize, animated: true)
}
func mediaMessageTextToolbarDidTapSend(_ mediaMessageTextToolbar: MediaMessageTextToolbar) {
func attachmentTextToolbarDidTapSend(_ attachmentTextToolbar: AttachmentTextToolbar) {
// Toolbar flickers in and out if there are errors
// and remains visible momentarily after share extension is dismissed.
// It's easiest to just hide it at this point since we're done with it.
currentPageViewController.shouldAllowAttachmentViewResizing = false
mediaMessageTextToolbar.isUserInteractionEnabled = false
mediaMessageTextToolbar.isHidden = true
attachmentTextToolbar.isUserInteractionEnabled = false
attachmentTextToolbar.isHidden = true
approvalDelegate?.attachmentApproval(self, didApproveAttachments: attachments, messageText: mediaMessageTextToolbar.messageText)
approvalDelegate?.attachmentApproval(self, didApproveAttachments: attachments, messageText: attachmentTextToolbar.messageText)
}
func mediaMessageTextToolbarDidAddMore(_ mediaMessageTextToolbar: MediaMessageTextToolbar) {
func attachmentTextToolbarDidAddMore(_ attachmentTextToolbar: AttachmentTextToolbar) {
self.approvalDelegate?.attachmentApproval?(self, addMoreToAttachments: attachments)
}
}
@ -594,12 +639,6 @@ extension AttachmentApprovalViewController: MediaMessageTextToolbarDelegate {
// MARK: -
extension AttachmentApprovalViewController: AttachmentPrepViewControllerDelegate {
func prepViewController(_ prepViewController: AttachmentPrepViewController, didUpdateCaptionForAttachmentItem attachmentItem: SignalAttachmentItem) {
self.approvalDelegate?.attachmentApproval?(self, changedCaptionOfAttachment: attachmentItem.attachment)
updateMediaRail()
}
func prepViewControllerUpdateNavigationBar() {
updateNavigationBar()
}
@ -607,10 +646,6 @@ extension AttachmentApprovalViewController: AttachmentPrepViewControllerDelegate
func prepViewControllerUpdateControls() {
updateControlVisibility()
}
func prepViewControllerAttachmentCount() -> Int {
return attachmentItemCollection.count
}
}
// MARK: GalleryRail
@ -671,3 +706,15 @@ extension AttachmentApprovalViewController: ApprovalRailCellViewDelegate {
remove(attachmentItem: attachmentItem)
}
}
// MARK: -
extension AttachmentApprovalViewController: AttachmentApprovalInputAccessoryViewDelegate {
public func attachmentApprovalInputUpdateMediaRail() {
updateMediaRail()
}
public func attachmentApprovalInputEditCaptions() {
isEditingCaptions = true
}
}

@ -0,0 +1,222 @@
//
// Copyright (c) 2019 Open Whisper Systems. All rights reserved.
//
import Foundation
import UIKit
protocol AttachmentCaptionToolbarDelegate: class {
func attachmentCaptionToolbarDidEdit(_ attachmentCaptionToolbar: AttachmentCaptionToolbar)
}
// MARK: -
class AttachmentCaptionToolbar: UIView, UITextViewDelegate {
private let kMaxCaptionCharacterCount = 240
weak var attachmentCaptionToolbarDelegate: AttachmentCaptionToolbarDelegate?
var messageText: String? {
get { return textView.text }
set {
textView.text = newValue
}
}
// Layout Constants
let kMinTextViewHeight: CGFloat = 38
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.textViewHeight = 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
// Layout
let kToolbarMargin: CGFloat = 8
self.textViewHeightConstraint = textView.autoSetDimension(.height, toSize: kMinTextViewHeight)
lengthLimitLabel.setContentHuggingHigh()
lengthLimitLabel.setCompressionResistanceHigh()
let contentView = UIStackView(arrangedSubviews: [textContainer, lengthLimitLabel])
// 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: kToolbarMargin, left: kToolbarMargin, bottom: kToolbarMargin, right: kToolbarMargin)
contentView.axis = .vertical
addSubview(contentView)
contentView.autoPinEdgesToSuperviewEdges()
}
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 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 textContainer: UIView = {
let textContainer = UIView()
textContainer.clipsToBounds = true
textContainer.addSubview(textView)
textView.autoPinEdgesToSuperviewEdges()
return textContainer
}()
private func buildTextView() -> UITextView {
let textView = AttachmentTextView()
textView.keyboardAppearance = Theme.darkThemeKeyboardAppearance
textView.backgroundColor = .clear
textView.tintColor = Theme.darkThemePrimaryColor
textView.font = UIFont.ows_dynamicTypeBody
textView.textColor = Theme.darkThemePrimaryColor
textView.textContainerInset = UIEdgeInsets(top: 7, left: 7, bottom: 7, right: 7)
return textView
}
// MARK: - UITextViewDelegate
public func textViewDidChange(_ textView: UITextView) {
updateHeight(textView: textView)
attachmentCaptionToolbarDelegate?.attachmentCaptionToolbarDidEdit(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 < kMaxCaptionCharacterCount 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(kMaxCaptionCharacterCount) - 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
}
}
// MARK: - Helpers
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, kMinTextViewHeight, maxTextViewHeight)
}
}

@ -7,13 +7,9 @@ import UIKit
import AVFoundation
protocol AttachmentPrepViewControllerDelegate: class {
func prepViewController(_ prepViewController: AttachmentPrepViewController, didUpdateCaptionForAttachmentItem attachmentItem: SignalAttachmentItem)
func prepViewControllerUpdateNavigationBar()
func prepViewControllerUpdateControls()
func prepViewControllerAttachmentCount() -> Int
}
// MARK: -
@ -42,13 +38,6 @@ public class AttachmentPrepViewController: OWSViewController, PlayerProgressBarD
private(set) var playVideoButton: UIView?
private var imageEditorView: ImageEditorView?
public var isShowingCaptionView = false {
didSet {
prepDelegate?.prepViewControllerUpdateNavigationBar()
prepDelegate?.prepViewControllerUpdateControls()
}
}
public var shouldHideControls: Bool {
guard let imageEditorView = imageEditorView else {
return false
@ -189,8 +178,6 @@ public class AttachmentPrepViewController: OWSViewController, PlayerProgressBarD
playButton.autoCenterInSuperview()
}
// Caption
view.addSubview(touchInterceptorView)
touchInterceptorView.autoPinEdgesToSuperviewEdges()
touchInterceptorView.isHidden = true
@ -227,52 +214,10 @@ public class AttachmentPrepViewController: OWSViewController, PlayerProgressBarD
// MARK: - Navigation Bar
public func navigationBarItems() -> [UIView] {
let captionButton = navigationBarButton(imageName: "image_editor_caption",
selector: #selector(didTapCaption(sender:)))
guard let imageEditorView = imageEditorView else {
// Show the "add caption" button for non-image attachments if
// there is more than one attachment.
if let prepDelegate = prepDelegate,
prepDelegate.prepViewControllerAttachmentCount() > 1 {
return [captionButton]
}
return []
}
var navigationBarItems = imageEditorView.navigationBarItems()
// Show the caption UI if there's more than one attachment
// OR if the attachment already has a caption.
var shouldShowCaptionUI = attachmentCount() > 0
if let captionText = attachmentItem.captionText, captionText.count > 0 {
shouldShowCaptionUI = true
}
if shouldShowCaptionUI {
navigationBarItems.append(captionButton)
}
return navigationBarItems
}
private func attachmentCount() -> Int {
guard let prepDelegate = prepDelegate else {
owsFailDebug("Missing prepDelegate.")
return 0
}
return prepDelegate.prepViewControllerAttachmentCount()
}
@objc func didTapCaption(sender: UIButton) {
Logger.verbose("")
presentCaptionView()
}
private func presentCaptionView() {
let view = AttachmentCaptionViewController(delegate: self, attachmentItem: attachmentItem)
self.imageEditor(presentFullScreenView: view, isTransparent: true)
isShowingCaptionView = true
return imageEditorView.navigationBarItems()
}
// MARK: - Event Handlers
@ -435,22 +380,6 @@ public class AttachmentPrepViewController: OWSViewController, PlayerProgressBarD
// MARK: -
extension AttachmentPrepViewController: AttachmentCaptionDelegate {
func captionView(_ captionView: AttachmentCaptionViewController, didChangeCaptionText captionText: String?, attachmentItem: SignalAttachmentItem) {
let attachment = attachmentItem.attachment
attachment.captionText = captionText
prepDelegate?.prepViewController(self, didUpdateCaptionForAttachmentItem: attachmentItem)
isShowingCaptionView = false
}
func captionViewDidCancel() {
isShowingCaptionView = false
}
}
// MARK: -
extension AttachmentPrepViewController: UIScrollViewDelegate {
public func viewForZooming(in scrollView: UIScrollView) -> UIView? {

@ -0,0 +1,339 @@
//
// Copyright (c) 2019 Open Whisper Systems. All rights reserved.
//
import Foundation
import UIKit
// 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 attachmentTextToolbarDidAddMore(_ 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
let kMinTextViewHeight: CGFloat = 38
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(isAddMoreVisible: Bool) {
self.addMoreButton = UIButton(type: .custom)
self.sendButton = UIButton(type: .system)
self.textViewHeight = 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 addMoreIcon = #imageLiteral(resourceName: "album_add_more").withRenderingMode(.alwaysTemplate)
addMoreButton.setImage(addMoreIcon, for: .normal)
addMoreButton.tintColor = Theme.darkThemePrimaryColor
addMoreButton.addTarget(self, action: #selector(didTapAddMore), for: .touchUpInside)
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 = UIFont.ows_mediumFont(withSize: 16)
sendButton.titleLabel?.textAlignment = .center
sendButton.tintColor = Theme.galleryHighlightColor
// 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)
if isAddMoreVisible {
contentView.addSubview(addMoreButton)
}
addSubview(contentView)
contentView.autoPinEdgesToSuperviewEdges()
// Layout
let kToolbarMargin: CGFloat = 8
// 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: kToolbarMargin, left: kToolbarMargin, bottom: kToolbarMargin, right: kToolbarMargin)
self.textViewHeightConstraint = textView.autoSetDimension(.height, toSize: 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)
if isAddMoreVisible {
addMoreButton.autoPinEdge(toSuperviewMargin: .left)
textContainer.autoPinEdge(.left, to: .right, of: addMoreButton, withOffset: kToolbarMargin)
addMoreButton.autoAlignAxis(.horizontal, toSameAxisOf: sendButton)
addMoreButton.setContentHuggingHigh()
addMoreButton.setCompressionResistanceHigh()
} else {
textContainer.autoPinEdge(toSuperviewMargin: .left)
}
sendButton.autoPinEdge(.left, to: .right, of: textContainer, withOffset: 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 addMoreButton: UIButton
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_TEXT_FIELD_PLACEHOLDER", comment: "placeholder text for the editable message field")
placeholderTextView.isEditable = false
return placeholderTextView
}()
private lazy var textContainer: UIView = {
let textContainer = UIView()
textContainer.layer.borderColor = Theme.darkThemePrimaryColor.cgColor
textContainer.layer.borderWidth = 0.5
textContainer.layer.cornerRadius = 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 = Theme.darkThemeKeyboardAppearance
textView.backgroundColor = .clear
textView.tintColor = Theme.darkThemePrimaryColor
textView.font = UIFont.ows_dynamicTypeBody
textView.textColor = Theme.darkThemePrimaryColor
textView.textContainerInset = UIEdgeInsets(top: 7, left: 7, bottom: 7, right: 7)
return textView
}
// MARK: - Actions
@objc func didTapSend() {
attachmentTextToolbarDelegate?.attachmentTextToolbarDidTapSend(self)
}
@objc func didTapAddMore() {
attachmentTextToolbarDelegate?.attachmentTextToolbarDidAddMore(self)
}
// MARK: - UITextViewDelegate
public func textViewDidChange(_ textView: UITextView) {
updateHeight(textView: textView)
}
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, kMinTextViewHeight, maxTextViewHeight)
}
}

@ -0,0 +1,16 @@
//
// Copyright (c) 2019 Open Whisper Systems. All rights reserved.
//
import Foundation
import UIKit
class AttachmentTextView: UITextView {
// When creating new lines, contentOffset is animated, but because
// we are simultaneously resizing the text view, this can cause the
// text in the textview to be "too high" in the text view.
// Solution is to disable animation for setting content offset.
override func setContentOffset(_ contentOffset: CGPoint, animated: Bool) {
super.setContentOffset(contentOffset, animated: false)
}
}
Loading…
Cancel
Save