forked from GitHawkApp/MessageViewController
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathMessageAutocompleteController.swift
More file actions
270 lines (215 loc) · 10.1 KB
/
MessageAutocompleteController.swift
File metadata and controls
270 lines (215 loc) · 10.1 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
//
// MessageAutocompleteController.swift
// MessageViewController
//
// Created by Ryan Nystrom on 12/31/17.
//
import UIKit
public protocol MessageAutocompleteControllerDelegate: class {
func didFind(controller: MessageAutocompleteController, prefix: String, word: String)
}
public protocol MessageAutocompleteControllerLayoutDelegate: class {
func needsLayout(controller: MessageAutocompleteController)
}
public final class MessageAutocompleteController: MessageTextViewListener {
public let textView: MessageTextView
public let tableView = UITableView()
public weak var delegate: MessageAutocompleteControllerDelegate?
public weak var layoutDelegate: MessageAutocompleteControllerLayoutDelegate?
public struct Selection {
public let prefix: String
public let word: String
public let range: NSRange
}
public private(set) var selection: Selection?
/// Adds an additional space after the autocompleted text when true. Default value is `TRUE`
open var appendSpaceOnCompletion = true
/// The default text attributes
open var defaultTextAttributes: [NSAttributedStringKey: Any] = [.font: UIFont.preferredFont(forTextStyle: .body), .foregroundColor: UIColor.black]
/// The text attributes applied to highlighted substrings for each prefix
private var autocompleteTextAttributes: [String: [NSAttributedStringKey: Any]] = [:]
/// A key used for referencing which substrings were autocompletes
private let NSAttributedAutocompleteKey = NSAttributedStringKey.init("com.messageviewcontroller.autocompletekey")
/// A reference to `defaultTextAttributes` that adds the NSAttributedAutocompleteKey
private var typingTextAttributes: [NSAttributedStringKey: Any] {
var attributes = defaultTextAttributes
attributes[NSAttributedAutocompleteKey] = false
attributes[.paragraphStyle] = paragraphStyle
return attributes
}
/// The NSAttributedStringKey.paragraphStyle value applied to attributed strings
private let paragraphStyle: NSMutableParagraphStyle = {
let style = NSMutableParagraphStyle()
style.paragraphSpacingBefore = 2
style.lineHeightMultiple = 1
return style
}()
internal var registeredPrefixes = Set<String>()
internal let border = CALayer()
internal var keyboardHeight: CGFloat = 0
public init(textView: MessageTextView) {
self.textView = textView
textView.add(listener: self)
tableView.isHidden = true
border.isHidden = true
let notificationCenter = NotificationCenter.default
notificationCenter.addObserver(
self,
selector: #selector(keyboardWillChangeFrame(notification:)),
name: .UIKeyboardWillChangeFrame,
object: nil
)
}
// MARK: Public API
public final func register(prefix: String) {
registeredPrefixes.insert(prefix)
}
public final func show(_ doShow: Bool) {
tableView.reloadData()
tableView.layoutIfNeeded()
tableView.isHidden = !doShow
border.isHidden = !doShow
layoutDelegate?.needsLayout(controller: self)
}
public final func accept(autocomplete: String, keepPrefix: Bool = true) {
defer { cancel() }
guard let selection = self.selection,
let text = textView.text
else { return }
let prefixLength = selection.prefix.utf16.count
let insertionRange = NSRange(
location: selection.range.location + (keepPrefix ? prefixLength : 0),
length: selection.word.utf16.count + (!keepPrefix ? prefixLength : 0)
)
guard let range = Range(insertionRange, in: text) else { return }
// Create an NSRange to use with attributedText replacement
let nsrange = NSRange(range, in: textView.text)
insertAutocomplete(autocomplete, at: selection, for: nsrange, keepPrefix: keepPrefix)
let selectedLocation = insertionRange.location + autocomplete.utf16.count + (appendSpaceOnCompletion ? 1 : 0)
textView.selectedRange = NSRange(
location: selectedLocation,
length: 0
)
preserveTypingAttributes(for: textView)
}
internal func cancel() {
selection = nil
show(false)
}
public final var maxHeight: CGFloat = 200 {
didSet { layoutDelegate?.needsLayout(controller: self) }
}
public func layout(in view: UIView, bottomY: CGFloat? = nil) {
if tableView.superview != view {
view.addSubview(tableView)
view.layer.addSublayer(border)
}
let bounds = view.bounds
let pinY = bottomY ?? (bounds.height - keyboardHeight)
let height = min(maxHeight, tableView.contentSize.height)
let frame = CGRect(
x: bounds.minX,
y: pinY - height,
width: bounds.width,
height: height
)
tableView.frame = frame
let borderHeight = 1 / UIScreen.main.scale
CATransaction.begin()
CATransaction.setDisableActions(true)
border.frame = CGRect(
x: bounds.minX,
y: frame.minY - borderHeight,
width: bounds.width,
height: borderHeight
)
CATransaction.commit()
}
public var borderColor: UIColor? {
get {
guard let color = border.backgroundColor else { return nil }
return UIColor(cgColor: color)
}
set {
border.backgroundColor = newValue?.cgColor
}
}
public func registerAutocomplete(prefix: String, attributes: [NSAttributedStringKey: Any]) {
autocompleteTextAttributes[prefix] = attributes
autocompleteTextAttributes[prefix]?[.paragraphStyle] = paragraphStyle
}
// MARK: Private API
private func insertAutocomplete(_ autocomplete: String, at selection: Selection, for range: NSRange, keepPrefix: Bool) {
// Apply the autocomplete attributes
var attrs = autocompleteTextAttributes[selection.prefix] ?? defaultTextAttributes
attrs[NSAttributedAutocompleteKey] = true
let newString = (keepPrefix ? selection.prefix : "") + autocomplete
let newAttributedString = NSAttributedString(string: newString, attributes: attrs)
// Modify the NSRange to include the prefix length
let rangeModifier = keepPrefix ? selection.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))
}
textView.attributedText = newAttributedText
}
internal func check() {
guard let result = textView.find(prefixes: registeredPrefixes) else {
cancel()
return
}
let wordWithoutPrefix = (result.word as NSString).substring(from: result.prefix.utf16.count)
selection = Selection(prefix: result.prefix, word: wordWithoutPrefix, range: result.range)
delegate?.didFind(controller: self, prefix: result.prefix, word: wordWithoutPrefix)
}
@objc internal func keyboardWillChangeFrame(notification: Notification) {
guard let keyboardFrame = notification.userInfo?[UIKeyboardFrameEndUserInfoKey] as? CGRect
else { return }
keyboardHeight = keyboardFrame.height
}
/// Ensures new text typed is not styled
///
/// - Parameter textView: The `UITextView` to apply `typingTextAttributes` to
internal func preserveTypingAttributes(for textView: UITextView) {
var typingAttributes = [String: Any]()
typingTextAttributes.forEach { typingAttributes[$0.key.rawValue] = $0.value }
textView.typingAttributes = typingAttributes
}
// MARK: MessageTextViewListener
public func didChangeSelection(textView: MessageTextView) {
check()
}
public func didChange(textView: MessageTextView) {
preserveTypingAttributes(for: textView)
}
public func willChangeText(textView: MessageTextView, inRange range: NSRange, to: String) -> Bool {
// range.length == 1: Remove single character
// range.lowerBound < textView.selectedRange.lowerBound: Ignore trying to delete
// the substring if the user is already doing so
if range.length == 1, range.lowerBound < textView.selectedRange.lowerBound {
// Backspace/removing text
let attribute = textView.attributedText
.attributes(at: range.lowerBound, longestEffectiveRange: nil, in: range)
.filter { return $0.key == NSAttributedAutocompleteKey }
if let isAutocomplete = attribute[NSAttributedAutocompleteKey] as? Bool, isAutocomplete {
// Remove the autocompleted substring
let lowerRange = NSRange(location: 0, length: range.location + 1)
var shouldPreserveTypedText = true
textView.attributedText.enumerateAttribute(NSAttributedAutocompleteKey, in: lowerRange, options: .reverse, using: { (_, range, stop) in
// Only delete the first found range
defer { stop.pointee = true }
let emptyString = NSAttributedString(string: "", attributes: typingTextAttributes)
textView.attributedText = textView.attributedText.replacingCharacters(in: range, with: emptyString)
textView.selectedRange = NSRange(location: range.location, length: 0)
self.textView.textViewDidChange(textView)
self.preserveTypingAttributes(for: textView)
shouldPreserveTypedText = false
})
return shouldPreserveTypedText
}
}
return true
}
}