AutocompleteManager.swift 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503
  1. //
  2. // AttachmentManager.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 10/4/17.
  26. //
  27. import UIKit
  28. public extension NSAttributedString.Key {
  29. /// A key used for referencing which substrings were autocompleted
  30. /// by InputBarAccessoryView.AutocompleteManager
  31. static let autocompleted = NSAttributedString.Key("com.system.autocompletekey")
  32. /// A key used for referencing the context of autocompleted substrings
  33. /// by InputBarAccessoryView.AutocompleteManager
  34. static let autocompletedContext = NSAttributedString.Key("com.system.autocompletekey.context")
  35. }
  36. open class AutocompleteManager: NSObject, InputPlugin, UITextViewDelegate, UITableViewDelegate, UITableViewDataSource {
  37. // MARK: - Properties [Public]
  38. /// A protocol that passes data to the `AutocompleteManager`
  39. open weak var dataSource: AutocompleteManagerDataSource?
  40. /// A protocol that more precisely defines `AutocompleteManager` logic
  41. open weak var delegate: AutocompleteManagerDelegate?
  42. /// A reference to the `InputTextView` that the `AutocompleteManager` is using
  43. private(set) public weak var textView: UITextView?
  44. @available(*, deprecated, message: "`inputTextView` has been renamed to `textView` of type `UITextView`")
  45. public var inputTextView: InputTextView? { return textView as? InputTextView }
  46. /// An ongoing session reference that holds the prefix, range and text to complete with
  47. private(set) public var currentSession: AutocompleteSession?
  48. /// The `AutocompleteTableView` that renders available autocompletes for the `currentSession`
  49. open lazy var tableView: AutocompleteTableView = { [weak self] in
  50. let tableView = AutocompleteTableView()
  51. tableView.register(AutocompleteCell.self, forCellReuseIdentifier: AutocompleteCell.reuseIdentifier)
  52. tableView.separatorStyle = .none
  53. tableView.backgroundColor = .white
  54. tableView.rowHeight = 44
  55. tableView.delegate = self
  56. tableView.dataSource = self
  57. return tableView
  58. }()
  59. /// Adds an additional space after the autocompleted text when true.
  60. /// Default value is `TRUE`
  61. open var appendSpaceOnCompletion = true
  62. /// Keeps the prefix typed when text is autocompleted.
  63. /// Default value is `TRUE`
  64. open var keepPrefixOnCompletion = true
  65. /// Allows a single space character to be entered mid autocompletion.
  66. ///
  67. /// For example, your autocomplete is "Nathan Tannar", the .whitespace deliminater
  68. /// set would terminate the session after "Nathan". By setting `maxSpaceCountDuringCompletion`
  69. /// the session termination will disregard that number of spaces
  70. ///
  71. /// Default value is `0`
  72. open var maxSpaceCountDuringCompletion: Int = 0
  73. /// When enabled, autocomplete completions that contain whitespace will be deleted in parts.
  74. /// This meands backspacing on "@Nathan Tannar" will result in " Tannar" being removed first
  75. /// with a second backspace action required to delete "@Nathan"
  76. ///
  77. /// Default value is `TRUE`
  78. open var deleteCompletionByParts = true
  79. /// The default text attributes
  80. open var defaultTextAttributes: [NSAttributedString.Key: Any] =
  81. [.font: UIFont.preferredFont(forTextStyle: .body), .foregroundColor: UIColor.black]
  82. /// The NSAttributedString.Key.paragraphStyle value applied to attributed strings
  83. public let paragraphStyle: NSMutableParagraphStyle = {
  84. let style = NSMutableParagraphStyle()
  85. style.paragraphSpacingBefore = 2
  86. style.lineHeightMultiple = 1
  87. return style
  88. }()
  89. /// A block that filters the `AutocompleteCompletion`'s sourced
  90. /// from the `dataSource`, based on the `AutocompleteSession`.
  91. /// The default function requires the `AutocompleteCompletion.text`
  92. /// string contains the `AutocompleteSession.filter`
  93. /// string ignoring case
  94. open var filterBlock: (AutocompleteSession, AutocompleteCompletion) -> (Bool) = { session, completion in completion.text.lowercased().contains(session.filter.lowercased())
  95. }
  96. // MARK: - Properties [Private]
  97. /// The prefices that the manager will recognize
  98. public private(set) var autocompletePrefixes = Set<String>()
  99. /// The delimiters that the manager will terminate a session with
  100. /// The default value is: [.whitespaces, .newlines]
  101. public private(set) var autocompleteDelimiterSets: Set<CharacterSet> = [.whitespaces, .newlines]
  102. /// The text attributes applied to highlighted substrings for each prefix
  103. public private(set) var autocompleteTextAttributes = [String: [NSAttributedString.Key: Any]]()
  104. /// A reference to `defaultTextAttributes` that adds the NSAttributedAutocompleteKey
  105. private var typingTextAttributes: [NSAttributedString.Key: Any] {
  106. var attributes = defaultTextAttributes
  107. attributes[.autocompleted] = false
  108. attributes[.autocompletedContext] = nil
  109. attributes[.paragraphStyle] = paragraphStyle
  110. return attributes
  111. }
  112. /// The current autocomplete text options filtered by the text after the prefix
  113. private var currentAutocompleteOptions: [AutocompleteCompletion] {
  114. guard let session = currentSession, let completions = dataSource?.autocompleteManager(self, autocompleteSourceFor: session.prefix) else { return [] }
  115. guard !session.filter.isEmpty else { return completions }
  116. return completions.filter { completion in
  117. return filterBlock(session, completion)
  118. }
  119. }
  120. // MARK: - Initialization
  121. public init(for textView: UITextView) {
  122. super.init()
  123. self.textView = textView
  124. self.textView?.delegate = self
  125. }
  126. // MARK: - InputPlugin
  127. /// Reloads the InputPlugin's session
  128. open func reloadData() {
  129. var delimiterSet = autocompleteDelimiterSets.reduce(CharacterSet()) { result, set in
  130. return result.union(set)
  131. }
  132. let query = textView?.find(prefixes: autocompletePrefixes, with: delimiterSet)
  133. guard let result = query else {
  134. if let session = currentSession, session.spaceCounter <= maxSpaceCountDuringCompletion {
  135. delimiterSet = delimiterSet.subtracting(.whitespaces)
  136. guard let result = textView?.find(prefixes: [session.prefix], with: delimiterSet) else {
  137. unregisterCurrentSession()
  138. return
  139. }
  140. let wordWithoutPrefix = (result.word as NSString).substring(from: result.prefix.utf16.count)
  141. updateCurrentSession(to: wordWithoutPrefix)
  142. } else {
  143. unregisterCurrentSession()
  144. }
  145. return
  146. }
  147. let wordWithoutPrefix = (result.word as NSString).substring(from: result.prefix.utf16.count)
  148. guard let session = AutocompleteSession(prefix: result.prefix, range: result.range, filter: wordWithoutPrefix) else { return }
  149. guard let currentSession = currentSession else {
  150. registerCurrentSession(to: session)
  151. return
  152. }
  153. if currentSession == session {
  154. updateCurrentSession(to: wordWithoutPrefix)
  155. } else {
  156. registerCurrentSession(to: session)
  157. }
  158. }
  159. /// Invalidates the InputPlugin's session
  160. open func invalidate() {
  161. unregisterCurrentSession()
  162. }
  163. /// Passes an object into the InputPlugin's session to handle
  164. ///
  165. /// - Parameter object: A string to append
  166. @discardableResult
  167. open func handleInput(of object: AnyObject) -> Bool {
  168. guard let newText = object as? String, let textView = textView else { return false }
  169. let attributedString = NSMutableAttributedString(attributedString: textView.attributedText)
  170. let newAttributedString = NSAttributedString(string: newText, attributes: typingTextAttributes)
  171. attributedString.append(newAttributedString)
  172. textView.attributedText = attributedString
  173. reloadData()
  174. return true
  175. }
  176. // MARK: - API [Public]
  177. /// Registers a prefix and its the attributes to apply to its autocompleted strings
  178. ///
  179. /// - Parameters:
  180. /// - prefix: The prefix such as: @, # or !
  181. /// - attributedTextAttributes: The attributes to apply to the NSAttributedString
  182. open func register(prefix: String, with attributedTextAttributes: [NSAttributedString.Key:Any]? = nil) {
  183. autocompletePrefixes.insert(prefix)
  184. autocompleteTextAttributes[prefix] = attributedTextAttributes
  185. autocompleteTextAttributes[prefix]?[.paragraphStyle] = paragraphStyle
  186. }
  187. /// Unregisters a prefix and removes its associated cached attributes
  188. ///
  189. /// - Parameter prefix: The prefix such as: @, # or !
  190. open func unregister(prefix: String) {
  191. autocompletePrefixes.remove(prefix)
  192. autocompleteTextAttributes[prefix] = nil
  193. }
  194. /// Registers a CharacterSet as a delimiter
  195. ///
  196. /// - Parameter delimiterSet: The `CharacterSet` to recognize as a delimiter
  197. open func register(delimiterSet set: CharacterSet) {
  198. autocompleteDelimiterSets.insert(set)
  199. }
  200. /// Unregisters a CharacterSet
  201. ///
  202. /// - Parameter delimiterSet: The `CharacterSet` to recognize as a delimiter
  203. open func unregister(delimiterSet set: CharacterSet) {
  204. autocompleteDelimiterSets.remove(set)
  205. }
  206. /// Replaces the current prefix and filter text with the supplied text
  207. ///
  208. /// - Parameters:
  209. /// - text: The replacement text
  210. open func autocomplete(with session: AutocompleteSession) {
  211. guard let textView = textView else { return }
  212. guard delegate?.autocompleteManager(self, shouldComplete: session.prefix, with: session.filter) != false else { return }
  213. // Create a range that overlaps the prefix
  214. let prefixLength = session.prefix.utf16.count
  215. let insertionRange = NSRange(
  216. location: session.range.location + (keepPrefixOnCompletion ? prefixLength : 0),
  217. length: session.filter.utf16.count + (!keepPrefixOnCompletion ? prefixLength : 0)
  218. )
  219. // Transform range
  220. guard let range = Range(insertionRange, in: textView.text) else { return }
  221. let nsrange = NSRange(range, in: textView.text)
  222. // Replace the attributedText with a modified version
  223. let autocomplete = session.completion?.text ?? ""
  224. insertAutocomplete(autocomplete, at: session, for: nsrange)
  225. // Move Cursor to the end of the inserted text
  226. let selectedLocation = insertionRange.location + autocomplete.utf16.count + (appendSpaceOnCompletion ? 1 : 0)
  227. textView.selectedRange = NSRange(
  228. location: selectedLocation,
  229. length: 0
  230. )
  231. // End the session
  232. unregisterCurrentSession()
  233. }
  234. /// Returns an attributed string with bolded characters matching the characters typed in the session
  235. ///
  236. /// - Parameter session: The `AutocompleteSession` to form an `NSMutableAttributedString` with
  237. /// - Returns: An `NSMutableAttributedString`
  238. open func attributedText(matching session: AutocompleteSession,
  239. fontSize: CGFloat = 15,
  240. keepPrefix: Bool = true) -> NSMutableAttributedString {
  241. guard let completion = session.completion else {
  242. return NSMutableAttributedString()
  243. }
  244. // Bolds the text that currently matches the filter
  245. let matchingRange = (completion.text as NSString).range(of: session.filter, options: .caseInsensitive)
  246. let attributedString = NSMutableAttributedString().normal(completion.text, fontSize: fontSize)
  247. attributedString.addAttributes([.font: UIFont.boldSystemFont(ofSize: fontSize)], range: matchingRange)
  248. guard keepPrefix else { return attributedString }
  249. let stringWithPrefix = NSMutableAttributedString().normal(String(session.prefix), fontSize: fontSize)
  250. stringWithPrefix.append(attributedString)
  251. return stringWithPrefix
  252. }
  253. // MARK: - API [Private]
  254. /// Resets the `InputTextView`'s typingAttributes to `defaultTextAttributes`
  255. private func preserveTypingAttributes() {
  256. textView?.typingAttributes = typingTextAttributes
  257. }
  258. /// Inserts an autocomplete for a given selection
  259. ///
  260. /// - Parameters:
  261. /// - autocomplete: The 'String' to autocomplete to
  262. /// - sesstion: The 'AutocompleteSession'
  263. /// - range: The 'NSRange' to insert over
  264. private func insertAutocomplete(_ autocomplete: String, at session: AutocompleteSession, for range: NSRange) {
  265. guard let textView = textView else { return }
  266. // Apply the autocomplete attributes
  267. var attrs = autocompleteTextAttributes[session.prefix] ?? defaultTextAttributes
  268. attrs[.autocompleted] = true
  269. attrs[.autocompletedContext] = session.completion?.context
  270. let newString = (keepPrefixOnCompletion ? session.prefix : "") + autocomplete
  271. let newAttributedString = NSAttributedString(string: newString, attributes: attrs)
  272. // Modify the NSRange to include the prefix length
  273. let rangeModifier = keepPrefixOnCompletion ? session.prefix.count : 0
  274. let highlightedRange = NSRange(location: range.location - rangeModifier, length: range.length + rangeModifier)
  275. // Replace the attributedText with a modified version including the autocompete
  276. let newAttributedText = textView.attributedText.replacingCharacters(in: highlightedRange, with: newAttributedString)
  277. if appendSpaceOnCompletion {
  278. newAttributedText.append(NSAttributedString(string: " ", attributes: typingTextAttributes))
  279. }
  280. // Set to a blank attributed string to prevent keyboard autocorrect from cloberring the insert
  281. textView.attributedText = NSAttributedString()
  282. textView.attributedText = newAttributedText
  283. }
  284. /// Initializes a session with a new `AutocompleteSession` object
  285. ///
  286. /// - Parameters:
  287. /// - session: The session to register
  288. private func registerCurrentSession(to session: AutocompleteSession) {
  289. guard delegate?.autocompleteManager(self, shouldRegister: session.prefix, at: session.range) != false else { return }
  290. currentSession = session
  291. layoutIfNeeded()
  292. delegate?.autocompleteManager(self, shouldBecomeVisible: true)
  293. }
  294. /// Updates the session to a new String to filter results with
  295. ///
  296. /// - Parameters:
  297. /// - filterText: The String to filter `AutocompleteCompletion`s
  298. private func updateCurrentSession(to filterText: String) {
  299. currentSession?.filter = filterText
  300. layoutIfNeeded()
  301. delegate?.autocompleteManager(self, shouldBecomeVisible: true)
  302. }
  303. /// Invalidates the `currentSession` session if it existed
  304. private func unregisterCurrentSession() {
  305. guard let session = currentSession else { return }
  306. guard delegate?.autocompleteManager(self, shouldUnregister: session.prefix) != false else { return }
  307. currentSession = nil
  308. layoutIfNeeded()
  309. delegate?.autocompleteManager(self, shouldBecomeVisible: false)
  310. }
  311. /// Calls the required methods to relayout the `AutocompleteTableView` in it's superview
  312. private func layoutIfNeeded() {
  313. tableView.reloadData()
  314. // Resize the table to be fit properly in an `InputStackView`
  315. tableView.invalidateIntrinsicContentSize()
  316. // Layout the table's superview
  317. tableView.superview?.layoutIfNeeded()
  318. }
  319. // MARK: - UITextViewDelegate
  320. public func textViewDidChange(_ textView: UITextView) {
  321. reloadData()
  322. }
  323. public func textView(_ textView: UITextView, shouldChangeTextIn range: NSRange, replacementText text: String) -> Bool {
  324. // Ensure that the text to be inserted is not using previous attributes
  325. preserveTypingAttributes()
  326. if let session = currentSession {
  327. let textToReplace = (textView.text as NSString).substring(with: range)
  328. let deleteSpaceCount = textToReplace.filter { $0 == .space }.count
  329. let insertSpaceCount = text.filter { $0 == .space }.count
  330. let spaceCountDiff = insertSpaceCount - deleteSpaceCount
  331. session.spaceCounter = spaceCountDiff
  332. }
  333. let totalRange = NSRange(location: 0, length: textView.attributedText.length)
  334. let selectedRange = textView.selectedRange
  335. // range.length > 0: Backspace/removing text
  336. // range.lowerBound < textView.selectedRange.lowerBound: Ignore trying to delete
  337. // the substring if the user is already doing so
  338. // range == selectedRange: User selected a chunk to delete
  339. if range.length > 0, range.location < selectedRange.location {
  340. // Backspace/removing text
  341. let attributes = textView.attributedText.attributes(at: range.location, longestEffectiveRange: nil, in: range)
  342. let isAutocompleted = attributes[.autocompleted] as? Bool ?? false
  343. if isAutocompleted {
  344. textView.attributedText.enumerateAttribute(.autocompleted, in: totalRange, options: .reverse) { _, subrange, stop in
  345. let intersection = NSIntersectionRange(range, subrange)
  346. guard intersection.length > 0 else { return }
  347. defer { stop.pointee = true }
  348. let nothing = NSAttributedString(string: "", attributes: typingTextAttributes)
  349. let textToReplace = textView.attributedText.attributedSubstring(from: subrange).string
  350. guard deleteCompletionByParts, let delimiterRange = textToReplace.rangeOfCharacter(from: .whitespacesAndNewlines, options: .backwards, range: Range(subrange, in: textToReplace)) else {
  351. // Replace entire autocomplete
  352. textView.attributedText = textView.attributedText.replacingCharacters(in: subrange, with: nothing)
  353. textView.selectedRange = NSRange(location: subrange.location, length: 0)
  354. return
  355. }
  356. // Delete up to delimiter
  357. let delimiterLocation = delimiterRange.lowerBound.utf16Offset(in: textToReplace)
  358. let length = subrange.length - delimiterLocation
  359. let rangeFromDelimiter = NSRange(location: delimiterLocation + subrange.location, length: length)
  360. textView.attributedText = textView.attributedText.replacingCharacters(in: rangeFromDelimiter, with: nothing)
  361. textView.selectedRange = NSRange(location: subrange.location + delimiterLocation, length: 0)
  362. }
  363. unregisterCurrentSession()
  364. return false
  365. }
  366. } else if range.length >= 0, range.location < totalRange.length {
  367. // Inserting text in the middle of an autocompleted string
  368. let attributes = textView.attributedText.attributes(at: range.location, longestEffectiveRange: nil, in: range)
  369. let isAutocompleted = attributes[.autocompleted] as? Bool ?? false
  370. if isAutocompleted {
  371. textView.attributedText.enumerateAttribute(.autocompleted, in: totalRange, options: .reverse) { _, subrange, stop in
  372. let compareRange = range.length == 0 ? NSRange(location: range.location, length: 1) : range
  373. let intersection = NSIntersectionRange(compareRange, subrange)
  374. guard intersection.length > 0 else { return }
  375. let mutable = NSMutableAttributedString(attributedString: textView.attributedText)
  376. mutable.setAttributes(typingTextAttributes, range: subrange)
  377. let replacementText = NSAttributedString(string: text, attributes: typingTextAttributes)
  378. textView.attributedText = mutable.replacingCharacters(in: range, with: replacementText)
  379. textView.selectedRange = NSRange(location: range.location + text.count, length: 0)
  380. stop.pointee = true
  381. }
  382. unregisterCurrentSession()
  383. return false
  384. }
  385. }
  386. return true
  387. }
  388. // MARK: - UITableViewDataSource
  389. open func numberOfSections(in tableView: UITableView) -> Int {
  390. return 1
  391. }
  392. open func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
  393. return currentAutocompleteOptions.count
  394. }
  395. open func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
  396. guard let session = currentSession else { fatalError("Attempted to render a cell for a nil `AutocompleteSession`") }
  397. session.completion = currentAutocompleteOptions[indexPath.row]
  398. guard let cell = dataSource?.autocompleteManager(self, tableView: tableView, cellForRowAt: indexPath, for: session) else {
  399. fatalError("Failed to return a cell from `dataSource: AutocompleteManagerDataSource`")
  400. }
  401. return cell
  402. }
  403. // MARK: - UITableViewDelegate
  404. open func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
  405. guard let session = currentSession else { return }
  406. session.completion = currentAutocompleteOptions[indexPath.row]
  407. autocomplete(with: session)
  408. }
  409. }