123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389 |
- /*
- MIT License
- Copyright (c) 2017-2018 MessageKit
- Permission is hereby granted, free of charge, to any person obtaining a copy
- of this software and associated documentation files (the "Software"), to deal
- in the Software without restriction, including without limitation the rights
- to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
- copies of the Software, and to permit persons to whom the Software is
- furnished to do so, subject to the following conditions:
- The above copyright notice and this permission notice shall be included in all
- copies or substantial portions of the Software.
- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
- IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
- FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
- AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
- LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
- OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
- SOFTWARE.
- */
- import UIKit
- /**
- A `UITextView` that has a `UILabel` embedded for placeholder text
-
- ## Important Notes ##
- 1. Changing the font, textAlignment or textContainerInset automatically performs the same modifications to the placeholderLabel
- 2. Intended to be used in an `MessageInputBar`
- 3. Default placeholder text is "New Message"
- 4. Will pass a pasted image it's `MessageInputBar`'s `InputManager`s
- */
- open class InputTextView: UITextView {
-
- // MARK: - Properties
-
- open override var text: String! {
- didSet {
- postTextViewDidChangeNotification()
- }
- }
-
- open override var attributedText: NSAttributedString! {
- didSet {
- postTextViewDidChangeNotification()
- }
- }
-
- /// The images that are currently stored as `NSTextAttachment`'s
- open var images: [UIImage] {
- return parseForAttachedImages()
- }
-
- open var components: [Any] {
- return parseForComponents()
- }
-
- open var isImagePasteEnabled: Bool = true
-
- /// A UILabel that holds the `InputTextView`'s placeholder text
- public let placeholderLabel: UILabel = {
- let label = UILabel()
- label.numberOfLines = 0
- label.textColor = .lightGray
- label.text = "New Message"
- label.backgroundColor = .clear
- label.translatesAutoresizingMaskIntoConstraints = false
- return label
- }()
-
- /// The placeholder text that appears when there is no text. The default value is "New Message"
- open var placeholder: String? = "New Message" {
- didSet {
- placeholderLabel.text = placeholder
- }
- }
-
- /// The placeholderLabel's textColor
- open var placeholderTextColor: UIColor? = .lightGray {
- didSet {
- placeholderLabel.textColor = placeholderTextColor
- }
- }
-
- /// The `UIEdgeInsets` the placeholderLabel has within the `InputTextView`
- open var placeholderLabelInsets: UIEdgeInsets = UIEdgeInsets(top: 8, left: 4, bottom: 8, right: 4) {
- didSet {
- updateConstraintsForPlaceholderLabel()
- }
- }
-
- /// The font of the `InputTextView`. When set the placeholderLabel's font is also updated
- open override var font: UIFont! {
- didSet {
- placeholderLabel.font = font
- }
- }
-
- /// The `textAlignment` of the `InputTextView`. When set the placeholderLabel's `textAlignment` is also updated
- open override var textAlignment: NSTextAlignment {
- didSet {
- placeholderLabel.textAlignment = textAlignment
- }
- }
-
- /// The textContainerInset of the `InputTextView`. When set the placeholderLabelInsets is also updated
- open override var textContainerInset: UIEdgeInsets {
- didSet {
- placeholderLabelInsets = textContainerInset
- }
- }
-
- open override var scrollIndicatorInsets: UIEdgeInsets {
- didSet {
- // When .zero a rendering issue can occur
- if scrollIndicatorInsets == .zero {
- scrollIndicatorInsets = UIEdgeInsets(top: .leastNonzeroMagnitude,
- left: .leastNonzeroMagnitude,
- bottom: .leastNonzeroMagnitude,
- right: .leastNonzeroMagnitude)
- }
- }
- }
-
- /// A weak reference to the `MessageInputBar` that the `InputTextView` is contained within
- open weak var messageInputBar: MessageInputBar?
-
- /// The constraints of the placeholderLabel
- private var placeholderLabelConstraintSet: NSLayoutConstraintSet?
-
- // MARK: - Initializers
-
- public convenience init() {
- self.init(frame: .zero)
- }
-
- public override init(frame: CGRect, textContainer: NSTextContainer?) {
- super.init(frame: frame, textContainer: textContainer)
- setup()
- }
-
- required public init?(coder aDecoder: NSCoder) {
- super.init(coder: aDecoder)
- setup()
- }
-
- deinit {
- NotificationCenter.default.removeObserver(self)
- }
-
- // MARK: - Setup
-
- /// Sets up the default properties
- open func setup() {
-
- backgroundColor = .clear
- font = UIFont.preferredFont(forTextStyle: .body)
- isScrollEnabled = false
- scrollIndicatorInsets = UIEdgeInsets(top: .leastNonzeroMagnitude,
- left: .leastNonzeroMagnitude,
- bottom: .leastNonzeroMagnitude,
- right: .leastNonzeroMagnitude)
- setupPlaceholderLabel()
- setupObservers()
- }
- /// Adds the placeholderLabel to the view and sets up its initial constraints
- private func setupPlaceholderLabel() {
- addSubview(placeholderLabel)
- placeholderLabelConstraintSet = NSLayoutConstraintSet(
- top: placeholderLabel.topAnchor.constraint(equalTo: topAnchor, constant: placeholderLabelInsets.top),
- bottom: placeholderLabel.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -placeholderLabelInsets.bottom),
- left: placeholderLabel.leftAnchor.constraint(equalTo: leftAnchor, constant: placeholderLabelInsets.left),
- right: placeholderLabel.rightAnchor.constraint(equalTo: rightAnchor, constant: -placeholderLabelInsets.right),
- centerX: placeholderLabel.centerXAnchor.constraint(equalTo: centerXAnchor),
- centerY: placeholderLabel.centerYAnchor.constraint(equalTo: centerYAnchor)
- )
- placeholderLabelConstraintSet?.centerX?.priority = .defaultLow
- placeholderLabelConstraintSet?.centerY?.priority = .defaultLow
- placeholderLabelConstraintSet?.activate()
- }
-
- /// Adds the required notification observers
- private func setupObservers() {
-
- NotificationCenter.default.addObserver(self,
- selector: #selector(InputTextView.redrawTextAttachments),
- name: UIDevice.orientationDidChangeNotification, object: nil)
- NotificationCenter.default.addObserver(self,
- selector: #selector(InputTextView.textViewTextDidChange),
- name: UITextView.textDidChangeNotification, object: nil)
- }
-
- /// Updates the placeholderLabels constraint constants to match the placeholderLabelInsets
- private func updateConstraintsForPlaceholderLabel() {
- placeholderLabelConstraintSet?.top?.constant = placeholderLabelInsets.top
- placeholderLabelConstraintSet?.bottom?.constant = -placeholderLabelInsets.bottom
- placeholderLabelConstraintSet?.left?.constant = placeholderLabelInsets.left
- placeholderLabelConstraintSet?.right?.constant = -placeholderLabelInsets.right
- }
-
-
- // MARK: - Notification
-
- private func postTextViewDidChangeNotification() {
- NotificationCenter.default.post(name: UITextView.textDidChangeNotification, object: self)
- }
-
- @objc
- private func textViewTextDidChange() {
- placeholderLabel.isHidden = !text.isEmpty
- }
-
- // MARK: - Image Paste Support
-
- open override func canPerformAction(_ action: Selector, withSender sender: Any?) -> Bool {
-
- if action == NSSelectorFromString("paste:") && UIPasteboard.general.image != nil {
- return isImagePasteEnabled
- }
- return super.canPerformAction(action, withSender: sender)
- }
-
- open override func paste(_ sender: Any?) {
-
- guard let image = UIPasteboard.general.image else {
- return super.paste(sender)
- }
- if isImagePasteEnabled {
- pasteImageInTextContainer(with: image)
- } else {
- for plugin in messageInputBar?.plugins ?? [] {
- if plugin.handleInput(of: image) {
- return
- }
- }
- }
- }
-
- /// Addes a new UIImage to the NSTextContainer as an NSTextAttachment
- ///
- /// - Parameter image: The image to add
- private func pasteImageInTextContainer(with image: UIImage) {
-
- // Add the new image as an NSTextAttachment
- let attributedImageString = NSAttributedString(attachment: textAttachment(using: image))
-
- let isEmpty = attributedText.length == 0
-
- // Add a new line character before the image, this is what iMessage does
- let newAttributedStingComponent = isEmpty ? NSMutableAttributedString(string: "") : NSMutableAttributedString(string: "\n")
- newAttributedStingComponent.append(attributedImageString)
-
- // Add a new line character after the image, this is what iMessage does
- newAttributedStingComponent.append(NSAttributedString(string: "\n"))
-
- // The attributes that should be applied to the new NSAttributedString to match the current attributes
- let attributes: [NSAttributedString.Key: Any] = [
- NSAttributedString.Key.font: font ?? UIFont.preferredFont(forTextStyle: .body),
- NSAttributedString.Key.foregroundColor: textColor ?? .black
- ]
- newAttributedStingComponent.addAttributes(attributes, range: NSRange(location: 0, length: newAttributedStingComponent.length))
-
- textStorage.beginEditing()
- // Paste over selected text
- textStorage.replaceCharacters(in: selectedRange, with: newAttributedStingComponent)
- textStorage.endEditing()
-
- // Advance the range to the selected range plus the number of characters added
- let location = selectedRange.location + (isEmpty ? 2 : 3)
- selectedRange = NSRange(location: location, length: 0)
-
- // Broadcast a notification to recievers such as the MessageInputBar which will handle resizing
- postTextViewDidChangeNotification()
- }
-
- /// Returns an NSTextAttachment the provided image that will fit inside the NSTextContainer
- ///
- /// - Parameter image: The image to create an attachment with
- /// - Returns: The formatted NSTextAttachment
- private func textAttachment(using image: UIImage) -> NSTextAttachment {
-
- guard let cgImage = image.cgImage else { return NSTextAttachment() }
- let scale = image.size.width / (frame.width - 2 * (textContainerInset.left + textContainerInset.right))
- let textAttachment = NSTextAttachment()
- textAttachment.image = UIImage(cgImage: cgImage, scale: scale, orientation: .up)
- return textAttachment
- }
-
- /// Returns all images that exist as NSTextAttachment's
- ///
- /// - Returns: An array of type UIImage
- private func parseForAttachedImages() -> [UIImage] {
-
- var images = [UIImage]()
- let range = NSRange(location: 0, length: attributedText.length)
- attributedText.enumerateAttribute(.attachment, in: range, options: [], using: { value, range, _ -> Void in
-
- if let attachment = value as? NSTextAttachment {
- if let image = attachment.image {
- images.append(image)
- } else if let image = attachment.image(forBounds: attachment.bounds,
- textContainer: nil,
- characterIndex: range.location) {
- images.append(image)
- }
- }
- })
- return images
- }
-
- /// Returns an array of components (either a String or UIImage) that makes up the textContainer in
- /// the order that they were typed
- ///
- /// - Returns: An array of objects guaranteed to be of UIImage or String
- private func parseForComponents() -> [Any] {
-
- var components = [Any]()
- var attachments = [(NSRange, UIImage)]()
- let length = attributedText.length
- let range = NSRange(location: 0, length: length)
- attributedText.enumerateAttribute(.attachment, in: range) { (object, range, _) in
- if let attachment = object as? NSTextAttachment {
- if let image = attachment.image {
- attachments.append((range, image))
- } else if let image = attachment.image(forBounds: attachment.bounds,
- textContainer: nil,
- characterIndex: range.location) {
- attachments.append((range,image))
- }
- }
- }
-
- var curLocation = 0
- if attachments.count == 0 {
- let text = attributedText.string.trimmingCharacters(in: .whitespacesAndNewlines)
- if !text.isEmpty {
- components.append(text)
- }
- }
- else {
- attachments.forEach { (attachment) in
- let (range, image) = attachment
- if curLocation < range.location {
- let textRange = NSMakeRange(curLocation, range.location)
- let text = attributedText.attributedSubstring(from: textRange).string.trimmingCharacters(in: .whitespacesAndNewlines)
- if !text.isEmpty {
- components.append(text)
- }
- }
-
- curLocation = range.location + range.length
- components.append(image)
- }
- if curLocation < length - 1 {
- let text = attributedText.attributedSubstring(from: NSMakeRange(curLocation, length - curLocation)).string.trimmingCharacters(in: .whitespacesAndNewlines)
- if !text.isEmpty {
- components.append(text)
- }
- }
- }
-
- return components
- }
-
- /// Redraws the NSTextAttachments in the NSTextContainer to fit the current bounds
- @objc
- private func redrawTextAttachments() {
-
- guard images.count > 0 else { return }
- let range = NSRange(location: 0, length: attributedText.length)
- attributedText.enumerateAttribute(.attachment, in: range, options: [], using: { value, _, _ -> Void in
- if let attachment = value as? NSTextAttachment, let image = attachment.image {
-
- // Calculates a new width/height ratio to fit the image in the current frame
- let newWidth = frame.width - 2 * (textContainerInset.left + textContainerInset.right)
- let ratio = image.size.height / image.size.width
- attachment.bounds.size = CGSize(width: newWidth, height: ratio * newWidth)
- }
- })
- layoutManager.invalidateLayout(forCharacterRange: range, actualCharacterRange: nil)
- }
-
- }
|