InputTextView.swift 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373
  1. /*
  2. MIT License
  3. Copyright (c) 2017-2018 MessageKit
  4. Permission is hereby granted, free of charge, to any person obtaining a copy
  5. of this software and associated documentation files (the "Software"), to deal
  6. in the Software without restriction, including without limitation the rights
  7. to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
  8. copies of the Software, and to permit persons to whom the Software is
  9. furnished to do so, subject to the following conditions:
  10. The above copyright notice and this permission notice shall be included in all
  11. copies or substantial portions of the Software.
  12. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
  13. IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
  14. FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
  15. AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
  16. LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
  17. OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
  18. SOFTWARE.
  19. */
  20. import UIKit
  21. /**
  22. A UITextView that has a UILabel embedded for placeholder text
  23. ## Important Notes ##
  24. 1. Changing the font, textAlignment or textContainerInset automatically performs the same modifications to the placeholderLabel
  25. 2. Intended to be used in an `MessageInputBar`
  26. 3. Default placeholder text is "New Message"
  27. 4. Will pass a pasted image it's `MessageInputBar`'s `InputManager`s
  28. */
  29. open class InputTextView: UITextView {
  30. // MARK: - Properties
  31. open override var text: String! {
  32. didSet {
  33. postTextViewDidChangeNotification()
  34. placeholderLabel.isHidden = !text.isEmpty
  35. }
  36. }
  37. open override var attributedText: NSAttributedString! {
  38. didSet {
  39. postTextViewDidChangeNotification()
  40. placeholderLabel.isHidden = !text.isEmpty
  41. }
  42. }
  43. /// The images that are currently stored as NSTextAttachment's
  44. open var images: [UIImage] {
  45. return parseForAttachedImages()
  46. }
  47. open var components: [Any] {
  48. return parseForComponents()
  49. }
  50. open var isImagePasteEnabled: Bool = true
  51. /// A UILabel that holds the InputTextView's placeholder text
  52. public let placeholderLabel: UILabel = {
  53. let label = UILabel()
  54. label.numberOfLines = 0
  55. label.textColor = .lightGray
  56. label.text = "New Message"
  57. label.backgroundColor = .clear
  58. label.translatesAutoresizingMaskIntoConstraints = false
  59. return label
  60. }()
  61. /// The placeholder text that appears when there is no text. The default value is "New Message"
  62. open var placeholder: String? = "New Message" {
  63. didSet {
  64. placeholderLabel.text = placeholder
  65. }
  66. }
  67. /// The placeholderLabel's textColor
  68. open var placeholderTextColor: UIColor? = .lightGray {
  69. didSet {
  70. placeholderLabel.textColor = placeholderTextColor
  71. }
  72. }
  73. /// The UIEdgeInsets the placeholderLabel has within the InputTextView
  74. open var placeholderLabelInsets: UIEdgeInsets = UIEdgeInsets(top: 4, left: 7, bottom: 4, right: 7) {
  75. didSet {
  76. updateConstraintsForPlaceholderLabel()
  77. }
  78. }
  79. /// The font of the InputTextView. When set the placeholderLabel's font is also updated
  80. open override var font: UIFont! {
  81. didSet {
  82. placeholderLabel.font = font
  83. }
  84. }
  85. /// The textAlignment of the InputTextView. When set the placeholderLabel's textAlignment is also updated
  86. open override var textAlignment: NSTextAlignment {
  87. didSet {
  88. placeholderLabel.textAlignment = textAlignment
  89. }
  90. }
  91. open override var scrollIndicatorInsets: UIEdgeInsets {
  92. didSet {
  93. // When .zero a rendering issue can occur
  94. if scrollIndicatorInsets == .zero {
  95. scrollIndicatorInsets = UIEdgeInsets(top: .leastNonzeroMagnitude,
  96. left: .leastNonzeroMagnitude,
  97. bottom: .leastNonzeroMagnitude,
  98. right: .leastNonzeroMagnitude)
  99. }
  100. }
  101. }
  102. /// A weak reference to the MessageInputBar that the InputTextView is contained within
  103. open weak var messageInputBar: MessageInputBar?
  104. /// The constraints of the placeholderLabel
  105. private var placeholderLabelConstraintSet: NSLayoutConstraintSet?
  106. // MARK: - Initializers
  107. public convenience init() {
  108. self.init(frame: .zero)
  109. }
  110. public override init(frame: CGRect, textContainer: NSTextContainer?) {
  111. super.init(frame: frame, textContainer: textContainer)
  112. setup()
  113. }
  114. required public init?(coder aDecoder: NSCoder) {
  115. super.init(coder: aDecoder)
  116. setup()
  117. }
  118. deinit {
  119. NotificationCenter.default.removeObserver(self)
  120. }
  121. // MARK: - Setup
  122. /// Sets up the default properties
  123. open func setup() {
  124. font = UIFont.preferredFont(forTextStyle: .body)
  125. textContainerInset = UIEdgeInsets(top: 4, left: 4, bottom: 4, right: 4)
  126. scrollIndicatorInsets = UIEdgeInsets(top: .leastNonzeroMagnitude,
  127. left: .leastNonzeroMagnitude,
  128. bottom: .leastNonzeroMagnitude,
  129. right: .leastNonzeroMagnitude)
  130. isScrollEnabled = false
  131. layer.cornerRadius = 5.0
  132. layer.borderWidth = 1.25
  133. layer.borderColor = UIColor.lightGray.cgColor
  134. allowsEditingTextAttributes = false
  135. setupPlaceholderLabel()
  136. setupObservers()
  137. }
  138. // swiftlint:disable colon
  139. /// Adds the placeholderLabel to the view and sets up its initial constraints
  140. private func setupPlaceholderLabel() {
  141. addSubview(placeholderLabel)
  142. placeholderLabelConstraintSet = NSLayoutConstraintSet(
  143. top: placeholderLabel.topAnchor.constraint(equalTo: topAnchor, constant: placeholderLabelInsets.top),
  144. bottom: placeholderLabel.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -placeholderLabelInsets.bottom),
  145. left: placeholderLabel.leftAnchor.constraint(equalTo: leftAnchor, constant: placeholderLabelInsets.left),
  146. right: placeholderLabel.rightAnchor.constraint(equalTo: rightAnchor, constant: -placeholderLabelInsets.right),
  147. centerX: placeholderLabel.centerXAnchor.constraint(equalTo: centerXAnchor),
  148. centerY: placeholderLabel.centerYAnchor.constraint(equalTo: centerYAnchor)
  149. )
  150. placeholderLabelConstraintSet?.centerX?.priority = .defaultLow
  151. placeholderLabelConstraintSet?.centerY?.priority = .defaultLow
  152. placeholderLabelConstraintSet?.activate()
  153. }
  154. // swiftlint:enable colon
  155. /// Adds the required notification observers
  156. private func setupObservers() {
  157. NotificationCenter.default.addObserver(self,
  158. selector: #selector(InputTextView.redrawTextAttachments),
  159. name: UIDevice.orientationDidChangeNotification, object: nil)
  160. }
  161. /// Updates the placeholderLabels constraint constants to match the placeholderLabelInsets
  162. private func updateConstraintsForPlaceholderLabel() {
  163. placeholderLabelConstraintSet?.top?.constant = placeholderLabelInsets.top
  164. placeholderLabelConstraintSet?.bottom?.constant = -placeholderLabelInsets.bottom
  165. placeholderLabelConstraintSet?.left?.constant = placeholderLabelInsets.left
  166. placeholderLabelConstraintSet?.right?.constant = -placeholderLabelInsets.right
  167. }
  168. // MARK: - Notification
  169. private func postTextViewDidChangeNotification() {
  170. NotificationCenter.default.post(name: UITextView.textDidChangeNotification, object: self)
  171. }
  172. // MARK: - Image Paste Support
  173. open override func canPerformAction(_ action: Selector, withSender sender: Any?) -> Bool {
  174. if action == NSSelectorFromString("paste:") && UIPasteboard.general.image != nil {
  175. return isImagePasteEnabled
  176. }
  177. return super.canPerformAction(action, withSender: sender)
  178. }
  179. open override func paste(_ sender: Any?) {
  180. guard let image = UIPasteboard.general.image else {
  181. return super.paste(sender)
  182. }
  183. pasteImageInTextContainer(with: image)
  184. }
  185. /// Addes a new UIImage to the NSTextContainer as an NSTextAttachment
  186. ///
  187. /// - Parameter image: The image to add
  188. private func pasteImageInTextContainer(with image: UIImage) {
  189. // Add the new image as an NSTextAttachment
  190. let attributedImageString = NSAttributedString(attachment: textAttachment(using: image))
  191. let isEmpty = attributedText.length == 0
  192. // Add a new line character before the image, this is what iMessage does
  193. let newAttributedStingComponent = isEmpty ? NSMutableAttributedString(string: "") : NSMutableAttributedString(string: "\n")
  194. newAttributedStingComponent.append(attributedImageString)
  195. // Add a new line character after the image, this is what iMessage does
  196. newAttributedStingComponent.append(NSAttributedString(string: "\n"))
  197. // The attributes that should be applied to the new NSAttributedString to match the current attributes
  198. let attributes: [NSAttributedString.Key: Any] = [
  199. NSAttributedString.Key.font: font ?? UIFont.preferredFont(forTextStyle: .body),
  200. NSAttributedString.Key.foregroundColor: textColor ?? .black
  201. ]
  202. newAttributedStingComponent.addAttributes(attributes, range: NSRange(location: 0, length: newAttributedStingComponent.length))
  203. textStorage.beginEditing()
  204. // Paste over selected text
  205. textStorage.replaceCharacters(in: selectedRange, with: newAttributedStingComponent)
  206. textStorage.endEditing()
  207. // Advance the range to the selected range plus the number of characters added
  208. let location = selectedRange.location + (isEmpty ? 2 : 3)
  209. selectedRange = NSRange(location: location, length: 0)
  210. // Broadcast a notification to recievers such as the MessageInputBar which will handle resizing
  211. NotificationCenter.default.post(name: UITextView.textDidChangeNotification, object: self)
  212. }
  213. /// Returns an NSTextAttachment the provided image that will fit inside the NSTextContainer
  214. ///
  215. /// - Parameter image: The image to create an attachment with
  216. /// - Returns: The formatted NSTextAttachment
  217. private func textAttachment(using image: UIImage) -> NSTextAttachment {
  218. guard let cgImage = image.cgImage else { return NSTextAttachment() }
  219. let scale = image.size.width / (frame.width - 2 * (textContainerInset.left + textContainerInset.right))
  220. let textAttachment = NSTextAttachment()
  221. textAttachment.image = UIImage(cgImage: cgImage, scale: scale, orientation: .up)
  222. return textAttachment
  223. }
  224. /// Returns all images that exist as NSTextAttachment's
  225. ///
  226. /// - Returns: An array of type UIImage
  227. private func parseForAttachedImages() -> [UIImage] {
  228. var images = [UIImage]()
  229. let range = NSRange(location: 0, length: attributedText.length)
  230. attributedText.enumerateAttribute(.attachment, in: range, options: [], using: { value, range, _ -> Void in
  231. if let attachment = value as? NSTextAttachment {
  232. if let image = attachment.image {
  233. images.append(image)
  234. } else if let image = attachment.image(forBounds: attachment.bounds,
  235. textContainer: nil,
  236. characterIndex: range.location) {
  237. images.append(image)
  238. }
  239. }
  240. })
  241. return images
  242. }
  243. /// Returns an array of components (either a String or UIImage) that makes up the textContainer in
  244. /// the order that they were typed
  245. ///
  246. /// - Returns: An array of objects guaranteed to be of UIImage or String
  247. private func parseForComponents() -> [Any] {
  248. var components = [Any]()
  249. var attachments = [(NSRange, UIImage)]()
  250. let length = attributedText.length
  251. let range = NSRange(location: 0, length: length)
  252. attributedText.enumerateAttribute(.attachment, in: range) { (object, range, _) in
  253. if let attachment = object as? NSTextAttachment {
  254. if let image = attachment.image {
  255. attachments.append((range, image))
  256. } else if let image = attachment.image(forBounds: attachment.bounds,
  257. textContainer: nil,
  258. characterIndex: range.location) {
  259. attachments.append((range,image))
  260. }
  261. }
  262. }
  263. var curLocation = 0
  264. if attachments.count == 0 {
  265. let text = attributedText.string.trimmingCharacters(in: .whitespacesAndNewlines)
  266. if !text.isEmpty {
  267. components.append(text)
  268. }
  269. }
  270. else {
  271. attachments.forEach { (attachment) in
  272. let (range, image) = attachment
  273. if curLocation < range.location {
  274. let textRange = NSMakeRange(curLocation, range.location)
  275. let text = attributedText.attributedSubstring(from: textRange).string.trimmingCharacters(in: .whitespacesAndNewlines)
  276. if !text.isEmpty {
  277. components.append(text)
  278. }
  279. }
  280. curLocation = range.location + range.length
  281. components.append(image)
  282. }
  283. if curLocation < length - 1 {
  284. let text = attributedText.attributedSubstring(from: NSMakeRange(curLocation, length - curLocation)).string.trimmingCharacters(in: .whitespacesAndNewlines)
  285. if !text.isEmpty {
  286. components.append(text)
  287. }
  288. }
  289. }
  290. return components
  291. }
  292. /// Redraws the NSTextAttachments in the NSTextContainer to fit the current bounds
  293. @objc
  294. private func redrawTextAttachments() {
  295. guard images.count > 0 else { return }
  296. let range = NSRange(location: 0, length: attributedText.length)
  297. attributedText.enumerateAttribute(.attachment, in: range, options: [], using: { value, _, _ -> Void in
  298. if let attachment = value as? NSTextAttachment, let image = attachment.image {
  299. // Calculates a new width/height ratio to fit the image in the current frame
  300. let newWidth = frame.width - 2 * (textContainerInset.left + textContainerInset.right)
  301. let ratio = image.size.height / image.size.width
  302. attachment.bounds.size = CGSize(width: newWidth, height: ratio * newWidth)
  303. }
  304. })
  305. layoutManager.invalidateLayout(forCharacterRange: range, actualCharacterRange: nil)
  306. }
  307. }