InputTextView.swift 16 KB

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