123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503 |
- //
- // AttachmentManager.swift
- // InputBarAccessoryView
- //
- // Copyright © 2017-2019 Nathan Tannar.
- //
- // 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.
- //
- // Created by Nathan Tannar on 10/4/17.
- //
- import UIKit
- public extension NSAttributedString.Key {
-
- /// A key used for referencing which substrings were autocompleted
- /// by InputBarAccessoryView.AutocompleteManager
- static let autocompleted = NSAttributedString.Key("com.system.autocompletekey")
-
- /// A key used for referencing the context of autocompleted substrings
- /// by InputBarAccessoryView.AutocompleteManager
- static let autocompletedContext = NSAttributedString.Key("com.system.autocompletekey.context")
- }
- open class AutocompleteManager: NSObject, InputPlugin, UITextViewDelegate, UITableViewDelegate, UITableViewDataSource {
-
- // MARK: - Properties [Public]
-
- /// A protocol that passes data to the `AutocompleteManager`
- open weak var dataSource: AutocompleteManagerDataSource?
-
- /// A protocol that more precisely defines `AutocompleteManager` logic
- open weak var delegate: AutocompleteManagerDelegate?
-
- /// A reference to the `InputTextView` that the `AutocompleteManager` is using
- private(set) public weak var textView: UITextView?
-
- @available(*, deprecated, message: "`inputTextView` has been renamed to `textView` of type `UITextView`")
- public var inputTextView: InputTextView? { return textView as? InputTextView }
-
- /// An ongoing session reference that holds the prefix, range and text to complete with
- private(set) public var currentSession: AutocompleteSession?
-
- /// The `AutocompleteTableView` that renders available autocompletes for the `currentSession`
- open lazy var tableView: AutocompleteTableView = { [weak self] in
- let tableView = AutocompleteTableView()
- tableView.register(AutocompleteCell.self, forCellReuseIdentifier: AutocompleteCell.reuseIdentifier)
- tableView.separatorStyle = .none
- tableView.backgroundColor = .white
- tableView.rowHeight = 44
- tableView.delegate = self
- tableView.dataSource = self
- return tableView
- }()
-
- /// Adds an additional space after the autocompleted text when true.
- /// Default value is `TRUE`
- open var appendSpaceOnCompletion = true
-
- /// Keeps the prefix typed when text is autocompleted.
- /// Default value is `TRUE`
- open var keepPrefixOnCompletion = true
-
- /// Allows a single space character to be entered mid autocompletion.
- ///
- /// For example, your autocomplete is "Nathan Tannar", the .whitespace deliminater
- /// set would terminate the session after "Nathan". By setting `maxSpaceCountDuringCompletion`
- /// the session termination will disregard that number of spaces
- ///
- /// Default value is `0`
- open var maxSpaceCountDuringCompletion: Int = 0
- /// When enabled, autocomplete completions that contain whitespace will be deleted in parts.
- /// This meands backspacing on "@Nathan Tannar" will result in " Tannar" being removed first
- /// with a second backspace action required to delete "@Nathan"
- ///
- /// Default value is `TRUE`
- open var deleteCompletionByParts = true
-
- /// The default text attributes
- open var defaultTextAttributes: [NSAttributedString.Key: Any] =
- [.font: UIFont.preferredFont(forTextStyle: .body), .foregroundColor: UIColor.black]
-
- /// The NSAttributedString.Key.paragraphStyle value applied to attributed strings
- public let paragraphStyle: NSMutableParagraphStyle = {
- let style = NSMutableParagraphStyle()
- style.paragraphSpacingBefore = 2
- style.lineHeightMultiple = 1
- return style
- }()
- /// A block that filters the `AutocompleteCompletion`'s sourced
- /// from the `dataSource`, based on the `AutocompleteSession`.
- /// The default function requires the `AutocompleteCompletion.text`
- /// string contains the `AutocompleteSession.filter`
- /// string ignoring case
- open var filterBlock: (AutocompleteSession, AutocompleteCompletion) -> (Bool) = { session, completion in completion.text.lowercased().contains(session.filter.lowercased())
- }
-
- // MARK: - Properties [Private]
-
- /// The prefices that the manager will recognize
- public private(set) var autocompletePrefixes = Set<String>()
-
- /// The delimiters that the manager will terminate a session with
- /// The default value is: [.whitespaces, .newlines]
- public private(set) var autocompleteDelimiterSets: Set<CharacterSet> = [.whitespaces, .newlines]
-
- /// The text attributes applied to highlighted substrings for each prefix
- public private(set) var autocompleteTextAttributes = [String: [NSAttributedString.Key: Any]]()
-
- /// A reference to `defaultTextAttributes` that adds the NSAttributedAutocompleteKey
- private var typingTextAttributes: [NSAttributedString.Key: Any] {
- var attributes = defaultTextAttributes
- attributes[.autocompleted] = false
- attributes[.autocompletedContext] = nil
- attributes[.paragraphStyle] = paragraphStyle
- return attributes
- }
-
- /// The current autocomplete text options filtered by the text after the prefix
- private var currentAutocompleteOptions: [AutocompleteCompletion] {
-
- guard let session = currentSession, let completions = dataSource?.autocompleteManager(self, autocompleteSourceFor: session.prefix) else { return [] }
- guard !session.filter.isEmpty else { return completions }
- return completions.filter { completion in
- return filterBlock(session, completion)
- }
- }
-
- // MARK: - Initialization
-
- public init(for textView: UITextView) {
- super.init()
- self.textView = textView
- self.textView?.delegate = self
- }
-
- // MARK: - InputPlugin
-
- /// Reloads the InputPlugin's session
- open func reloadData() {
- var delimiterSet = autocompleteDelimiterSets.reduce(CharacterSet()) { result, set in
- return result.union(set)
- }
- let query = textView?.find(prefixes: autocompletePrefixes, with: delimiterSet)
-
- guard let result = query else {
- if let session = currentSession, session.spaceCounter <= maxSpaceCountDuringCompletion {
- delimiterSet = delimiterSet.subtracting(.whitespaces)
- guard let result = textView?.find(prefixes: [session.prefix], with: delimiterSet) else {
- unregisterCurrentSession()
- return
- }
- let wordWithoutPrefix = (result.word as NSString).substring(from: result.prefix.utf16.count)
- updateCurrentSession(to: wordWithoutPrefix)
- } else {
- unregisterCurrentSession()
- }
- return
- }
- let wordWithoutPrefix = (result.word as NSString).substring(from: result.prefix.utf16.count)
- guard let session = AutocompleteSession(prefix: result.prefix, range: result.range, filter: wordWithoutPrefix) else { return }
- guard let currentSession = currentSession else {
- registerCurrentSession(to: session)
- return
- }
- if currentSession == session {
- updateCurrentSession(to: wordWithoutPrefix)
- } else {
- registerCurrentSession(to: session)
- }
- }
-
- /// Invalidates the InputPlugin's session
- open func invalidate() {
- unregisterCurrentSession()
- }
-
- /// Passes an object into the InputPlugin's session to handle
- ///
- /// - Parameter object: A string to append
- @discardableResult
- open func handleInput(of object: AnyObject) -> Bool {
- guard let newText = object as? String, let textView = textView else { return false }
- let attributedString = NSMutableAttributedString(attributedString: textView.attributedText)
- let newAttributedString = NSAttributedString(string: newText, attributes: typingTextAttributes)
- attributedString.append(newAttributedString)
- textView.attributedText = attributedString
- reloadData()
- return true
- }
-
- // MARK: - API [Public]
-
- /// Registers a prefix and its the attributes to apply to its autocompleted strings
- ///
- /// - Parameters:
- /// - prefix: The prefix such as: @, # or !
- /// - attributedTextAttributes: The attributes to apply to the NSAttributedString
- open func register(prefix: String, with attributedTextAttributes: [NSAttributedString.Key:Any]? = nil) {
- autocompletePrefixes.insert(prefix)
- autocompleteTextAttributes[prefix] = attributedTextAttributes
- autocompleteTextAttributes[prefix]?[.paragraphStyle] = paragraphStyle
- }
-
- /// Unregisters a prefix and removes its associated cached attributes
- ///
- /// - Parameter prefix: The prefix such as: @, # or !
- open func unregister(prefix: String) {
- autocompletePrefixes.remove(prefix)
- autocompleteTextAttributes[prefix] = nil
- }
-
- /// Registers a CharacterSet as a delimiter
- ///
- /// - Parameter delimiterSet: The `CharacterSet` to recognize as a delimiter
- open func register(delimiterSet set: CharacterSet) {
- autocompleteDelimiterSets.insert(set)
- }
-
- /// Unregisters a CharacterSet
- ///
- /// - Parameter delimiterSet: The `CharacterSet` to recognize as a delimiter
- open func unregister(delimiterSet set: CharacterSet) {
- autocompleteDelimiterSets.remove(set)
- }
-
- /// Replaces the current prefix and filter text with the supplied text
- ///
- /// - Parameters:
- /// - text: The replacement text
- open func autocomplete(with session: AutocompleteSession) {
-
- guard let textView = textView else { return }
- guard delegate?.autocompleteManager(self, shouldComplete: session.prefix, with: session.filter) != false else { return }
-
- // Create a range that overlaps the prefix
- let prefixLength = session.prefix.utf16.count
- let insertionRange = NSRange(
- location: session.range.location + (keepPrefixOnCompletion ? prefixLength : 0),
- length: session.filter.utf16.count + (!keepPrefixOnCompletion ? prefixLength : 0)
- )
-
- // Transform range
- guard let range = Range(insertionRange, in: textView.text) else { return }
- let nsrange = NSRange(range, in: textView.text)
-
- // Replace the attributedText with a modified version
- let autocomplete = session.completion?.text ?? ""
- insertAutocomplete(autocomplete, at: session, for: nsrange)
-
- // Move Cursor to the end of the inserted text
- let selectedLocation = insertionRange.location + autocomplete.utf16.count + (appendSpaceOnCompletion ? 1 : 0)
- textView.selectedRange = NSRange(
- location: selectedLocation,
- length: 0
- )
-
- // End the session
- unregisterCurrentSession()
- }
-
- /// Returns an attributed string with bolded characters matching the characters typed in the session
- ///
- /// - Parameter session: The `AutocompleteSession` to form an `NSMutableAttributedString` with
- /// - Returns: An `NSMutableAttributedString`
- open func attributedText(matching session: AutocompleteSession,
- fontSize: CGFloat = 15,
- keepPrefix: Bool = true) -> NSMutableAttributedString {
-
- guard let completion = session.completion else {
- return NSMutableAttributedString()
- }
-
- // Bolds the text that currently matches the filter
- let matchingRange = (completion.text as NSString).range(of: session.filter, options: .caseInsensitive)
- let attributedString = NSMutableAttributedString().normal(completion.text, fontSize: fontSize)
- attributedString.addAttributes([.font: UIFont.boldSystemFont(ofSize: fontSize)], range: matchingRange)
-
- guard keepPrefix else { return attributedString }
- let stringWithPrefix = NSMutableAttributedString().normal(String(session.prefix), fontSize: fontSize)
- stringWithPrefix.append(attributedString)
- return stringWithPrefix
- }
-
- // MARK: - API [Private]
-
- /// Resets the `InputTextView`'s typingAttributes to `defaultTextAttributes`
- private func preserveTypingAttributes() {
- textView?.typingAttributes = typingTextAttributes
- }
-
-
- /// Inserts an autocomplete for a given selection
- ///
- /// - Parameters:
- /// - autocomplete: The 'String' to autocomplete to
- /// - sesstion: The 'AutocompleteSession'
- /// - range: The 'NSRange' to insert over
- private func insertAutocomplete(_ autocomplete: String, at session: AutocompleteSession, for range: NSRange) {
-
- guard let textView = textView else { return }
-
- // Apply the autocomplete attributes
- var attrs = autocompleteTextAttributes[session.prefix] ?? defaultTextAttributes
- attrs[.autocompleted] = true
- attrs[.autocompletedContext] = session.completion?.context
- let newString = (keepPrefixOnCompletion ? session.prefix : "") + autocomplete
- let newAttributedString = NSAttributedString(string: newString, attributes: attrs)
-
- // Modify the NSRange to include the prefix length
- let rangeModifier = keepPrefixOnCompletion ? session.prefix.count : 0
- let highlightedRange = NSRange(location: range.location - rangeModifier, length: range.length + rangeModifier)
-
- // Replace the attributedText with a modified version including the autocompete
- let newAttributedText = textView.attributedText.replacingCharacters(in: highlightedRange, with: newAttributedString)
- if appendSpaceOnCompletion {
- newAttributedText.append(NSAttributedString(string: " ", attributes: typingTextAttributes))
- }
-
- // Set to a blank attributed string to prevent keyboard autocorrect from cloberring the insert
- textView.attributedText = NSAttributedString()
- textView.attributedText = newAttributedText
- }
-
- /// Initializes a session with a new `AutocompleteSession` object
- ///
- /// - Parameters:
- /// - session: The session to register
- private func registerCurrentSession(to session: AutocompleteSession) {
-
- guard delegate?.autocompleteManager(self, shouldRegister: session.prefix, at: session.range) != false else { return }
- currentSession = session
- layoutIfNeeded()
- delegate?.autocompleteManager(self, shouldBecomeVisible: true)
- }
-
- /// Updates the session to a new String to filter results with
- ///
- /// - Parameters:
- /// - filterText: The String to filter `AutocompleteCompletion`s
- private func updateCurrentSession(to filterText: String) {
-
- currentSession?.filter = filterText
- layoutIfNeeded()
- delegate?.autocompleteManager(self, shouldBecomeVisible: true)
- }
-
- /// Invalidates the `currentSession` session if it existed
- private func unregisterCurrentSession() {
-
- guard let session = currentSession else { return }
- guard delegate?.autocompleteManager(self, shouldUnregister: session.prefix) != false else { return }
- currentSession = nil
- layoutIfNeeded()
- delegate?.autocompleteManager(self, shouldBecomeVisible: false)
- }
-
- /// Calls the required methods to relayout the `AutocompleteTableView` in it's superview
- private func layoutIfNeeded() {
-
- tableView.reloadData()
-
- // Resize the table to be fit properly in an `InputStackView`
- tableView.invalidateIntrinsicContentSize()
-
- // Layout the table's superview
- tableView.superview?.layoutIfNeeded()
- }
-
- // MARK: - UITextViewDelegate
-
- public func textViewDidChange(_ textView: UITextView) {
- reloadData()
- }
-
- public func textView(_ textView: UITextView, shouldChangeTextIn range: NSRange, replacementText text: String) -> Bool {
-
- // Ensure that the text to be inserted is not using previous attributes
- preserveTypingAttributes()
-
- if let session = currentSession {
- let textToReplace = (textView.text as NSString).substring(with: range)
- let deleteSpaceCount = textToReplace.filter { $0 == .space }.count
- let insertSpaceCount = text.filter { $0 == .space }.count
- let spaceCountDiff = insertSpaceCount - deleteSpaceCount
- session.spaceCounter = spaceCountDiff
- }
-
- let totalRange = NSRange(location: 0, length: textView.attributedText.length)
- let selectedRange = textView.selectedRange
-
- // range.length > 0: Backspace/removing text
- // range.lowerBound < textView.selectedRange.lowerBound: Ignore trying to delete
- // the substring if the user is already doing so
- // range == selectedRange: User selected a chunk to delete
- if range.length > 0, range.location < selectedRange.location {
-
- // Backspace/removing text
- let attributes = textView.attributedText.attributes(at: range.location, longestEffectiveRange: nil, in: range)
- let isAutocompleted = attributes[.autocompleted] as? Bool ?? false
-
- if isAutocompleted {
- textView.attributedText.enumerateAttribute(.autocompleted, in: totalRange, options: .reverse) { _, subrange, stop in
-
- let intersection = NSIntersectionRange(range, subrange)
- guard intersection.length > 0 else { return }
- defer { stop.pointee = true }
- let nothing = NSAttributedString(string: "", attributes: typingTextAttributes)
- let textToReplace = textView.attributedText.attributedSubstring(from: subrange).string
- guard deleteCompletionByParts, let delimiterRange = textToReplace.rangeOfCharacter(from: .whitespacesAndNewlines, options: .backwards, range: Range(subrange, in: textToReplace)) else {
- // Replace entire autocomplete
- textView.attributedText = textView.attributedText.replacingCharacters(in: subrange, with: nothing)
- textView.selectedRange = NSRange(location: subrange.location, length: 0)
- return
- }
- // Delete up to delimiter
- let delimiterLocation = delimiterRange.lowerBound.utf16Offset(in: textToReplace)
- let length = subrange.length - delimiterLocation
- let rangeFromDelimiter = NSRange(location: delimiterLocation + subrange.location, length: length)
- textView.attributedText = textView.attributedText.replacingCharacters(in: rangeFromDelimiter, with: nothing)
- textView.selectedRange = NSRange(location: subrange.location + delimiterLocation, length: 0)
- }
- unregisterCurrentSession()
- return false
- }
- } else if range.length >= 0, range.location < totalRange.length {
-
- // Inserting text in the middle of an autocompleted string
- let attributes = textView.attributedText.attributes(at: range.location, longestEffectiveRange: nil, in: range)
- let isAutocompleted = attributes[.autocompleted] as? Bool ?? false
- if isAutocompleted {
- textView.attributedText.enumerateAttribute(.autocompleted, in: totalRange, options: .reverse) { _, subrange, stop in
-
- let compareRange = range.length == 0 ? NSRange(location: range.location, length: 1) : range
- let intersection = NSIntersectionRange(compareRange, subrange)
- guard intersection.length > 0 else { return }
-
- let mutable = NSMutableAttributedString(attributedString: textView.attributedText)
- mutable.setAttributes(typingTextAttributes, range: subrange)
- let replacementText = NSAttributedString(string: text, attributes: typingTextAttributes)
- textView.attributedText = mutable.replacingCharacters(in: range, with: replacementText)
- textView.selectedRange = NSRange(location: range.location + text.count, length: 0)
- stop.pointee = true
- }
- unregisterCurrentSession()
- return false
- }
- }
- return true
- }
-
- // MARK: - UITableViewDataSource
-
- open func numberOfSections(in tableView: UITableView) -> Int {
- return 1
- }
-
- open func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
- return currentAutocompleteOptions.count
- }
-
- open func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
-
- guard let session = currentSession else { fatalError("Attempted to render a cell for a nil `AutocompleteSession`") }
- session.completion = currentAutocompleteOptions[indexPath.row]
- guard let cell = dataSource?.autocompleteManager(self, tableView: tableView, cellForRowAt: indexPath, for: session) else {
- fatalError("Failed to return a cell from `dataSource: AutocompleteManagerDataSource`")
- }
- return cell
- }
-
- // MARK: - UITableViewDelegate
-
- open func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
-
- guard let session = currentSession else { return }
- session.completion = currentAutocompleteOptions[indexPath.row]
- autocomplete(with: session)
- }
-
- }
|