Skip to content

Commit a7636d0

Browse files
nathantannar4rnystrom
authored andcommitted
Highlighting with NSAttributedString's (#15)
* Highlighting with NSAttributedString's * Some highlight tests * Suggestions made from #15, README Edit Suggestions from @rnystrom in PR and edits in the README to show users how to use text highlighting * Tiny bug fix
1 parent fa5c856 commit a7636d0

File tree

8 files changed

+226
-4
lines changed

8 files changed

+226
-4
lines changed

Examples/Examples/ViewController.swift

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,11 @@ class ViewController: MessageViewController, UITableViewDataSource, UITableViewD
3838
messageAutocompleteController.tableView.dataSource = self
3939
messageAutocompleteController.tableView.delegate = self
4040
messageAutocompleteController.register(prefix: "@")
41+
42+
// Set custom attributes for an autocompleted string
43+
let tintColor = UIColor(red: 0, green: 122/255, blue: 1, alpha: 1)
44+
messageAutocompleteController.autocompleteTextAttributes = ["@": [.font: UIFont.preferredFont(forTextStyle: .body), .foregroundColor: tintColor, .backgroundColor: tintColor.withAlphaComponent(0.1)]]
45+
4146
messageAutocompleteController.delegate = self
4247

4348
setup(scrollView: tableView)

MessageViewController.xcodeproj/project.pbxproj

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@
2323
29CC29471FF42687006B6DE7 /* String+WordAtRange.swift in Sources */ = {isa = PBXBuildFile; fileRef = 29CC29451FF42687006B6DE7 /* String+WordAtRange.swift */; };
2424
29CC29481FF42687006B6DE7 /* UIView+iOS11.swift in Sources */ = {isa = PBXBuildFile; fileRef = 29CC29461FF42687006B6DE7 /* UIView+iOS11.swift */; };
2525
29CC29491FF81F1F006B6DE7 /* String+WordAtRange.swift in Sources */ = {isa = PBXBuildFile; fileRef = 29CC29451FF42687006B6DE7 /* String+WordAtRange.swift */; };
26+
38199E112022792600ADFE76 /* NSAttributedString+ReplaceRange.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38199E102022792600ADFE76 /* NSAttributedString+ReplaceRange.swift */; };
27+
38D26FB12023D01900B2B7B5 /* NSAttributedString+HighlightingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38D26FB02023D01900B2B7B5 /* NSAttributedString+HighlightingTests.swift */; };
2628
/* End PBXBuildFile section */
2729

2830
/* Begin PBXContainerItemProxy section */
@@ -54,6 +56,8 @@
5456
29CC29431FF4267F006B6DE7 /* String+WordAtRangeTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "String+WordAtRangeTests.swift"; sourceTree = "<group>"; };
5557
29CC29451FF42687006B6DE7 /* String+WordAtRange.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "String+WordAtRange.swift"; sourceTree = "<group>"; };
5658
29CC29461FF42687006B6DE7 /* UIView+iOS11.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "UIView+iOS11.swift"; sourceTree = "<group>"; };
59+
38199E102022792600ADFE76 /* NSAttributedString+ReplaceRange.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSAttributedString+ReplaceRange.swift"; sourceTree = "<group>"; };
60+
38D26FB02023D01900B2B7B5 /* NSAttributedString+HighlightingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSAttributedString+HighlightingTests.swift"; sourceTree = "<group>"; };
5761
/* End PBXFileReference section */
5862

5963
/* Begin PBXFrameworksBuildPhase section */
@@ -108,6 +112,7 @@
108112
2904821D1FED90340053978C /* UIButton+BottomHeightOffset.swift */,
109113
290482161FED90340053978C /* UIScrollView+StopScrolling.swift */,
110114
2904821E1FED90340053978C /* UITextView+Prefixes.swift */,
115+
38199E102022792600ADFE76 /* NSAttributedString+ReplaceRange.swift */,
111116
29CC29461FF42687006B6DE7 /* UIView+iOS11.swift */,
112117
);
113118
path = MessageViewController;
@@ -119,6 +124,7 @@
119124
29CC293C1FF4266D006B6DE7 /* Info.plist */,
120125
29CC293A1FF4266D006B6DE7 /* MessageViewControllerTests.swift */,
121126
29CC29431FF4267F006B6DE7 /* String+WordAtRangeTests.swift */,
127+
38D26FB02023D01900B2B7B5 /* NSAttributedString+HighlightingTests.swift */,
122128
);
123129
path = MessageViewControllerTests;
124130
sourceTree = "<group>";
@@ -242,6 +248,7 @@
242248
290482201FED90340053978C /* MessageViewController.swift in Sources */,
243249
29CC29481FF42687006B6DE7 /* UIView+iOS11.swift in Sources */,
244250
29792B151FFAE7FC007A0C57 /* MessageAutocompleteController.swift in Sources */,
251+
38199E112022792600ADFE76 /* NSAttributedString+ReplaceRange.swift in Sources */,
245252
2904821F1FED90340053978C /* UIScrollView+StopScrolling.swift in Sources */,
246253
29CC29471FF42687006B6DE7 /* String+WordAtRange.swift in Sources */,
247254
290482271FED90340053978C /* UITextView+Prefixes.swift in Sources */,
@@ -253,6 +260,7 @@
253260
buildActionMask = 2147483647;
254261
files = (
255262
29CC293B1FF4266D006B6DE7 /* MessageViewControllerTests.swift in Sources */,
263+
38D26FB12023D01900B2B7B5 /* NSAttributedString+HighlightingTests.swift in Sources */,
256264
29CC29441FF4267F006B6DE7 /* String+WordAtRangeTests.swift in Sources */,
257265
29CC29491FF81F1F006B6DE7 /* String+WordAtRange.swift in Sources */,
258266
);

MessageViewController/MessageAutocompleteController.swift

Lines changed: 90 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,25 @@ public final class MessageAutocompleteController: MessageTextViewListener {
2929
public let range: NSRange
3030
}
3131
public private(set) var selection: Selection?
32+
33+
/// Adds an additional space after the autocompleted text when true. Default value is `TRUE`
34+
open var appendSpaceOnCompletion = true
35+
36+
/// The default text attributes
37+
open var defaultTextAttributes: [NSAttributedStringKey: Any] = [.font: UIFont.preferredFont(forTextStyle: .body), .foregroundColor: UIColor.black]
38+
39+
/// The text attributes applied to highlighted substrings for each prefix
40+
open var autocompleteTextAttributes: [String: [NSAttributedStringKey: Any]] = [:]
41+
42+
/// A key used for referencing which substrings were autocompletes
43+
private let NSAttributedAutocompleteKey = NSAttributedStringKey.init("com.messageviewcontroller.autocompletekey")
44+
45+
/// A reference to `defaultTextAttributes` that adds the NSAttributedAutocompleteKey
46+
private var typingTextAttributes: [NSAttributedStringKey: Any] {
47+
var attributes = defaultTextAttributes
48+
attributes[NSAttributedAutocompleteKey] = false
49+
return attributes
50+
}
3251

3352
internal var registeredPrefixes = Set<String>()
3453
internal let border = CALayer()
@@ -80,12 +99,18 @@ public final class MessageAutocompleteController: MessageTextViewListener {
8099
)
81100

82101
guard let range = Range(insertionRange, in: text) else { return }
83-
84-
textView.text = text.replacingCharacters(in: range, with: autocomplete)
102+
103+
// Create an NSRange to use with attributedText replacement
104+
let nsrange = NSRange(range, in: textView.text)
105+
insertAutocomplete(autocomplete, at: selection, for: nsrange, keepPrefix: keepPrefix)
106+
107+
let selectedLocation = insertionRange.location + autocomplete.utf16.count + (appendSpaceOnCompletion ? 1 : 0)
85108
textView.selectedRange = NSRange(
86-
location: insertionRange.location + autocomplete.utf16.count,
109+
location: selectedLocation,
87110
length: 0
88111
)
112+
113+
preserveTypingAttributes(for: textView)
89114
}
90115

91116
internal func cancel() {
@@ -139,6 +164,26 @@ public final class MessageAutocompleteController: MessageTextViewListener {
139164
}
140165

141166
// MARK: Private API
167+
168+
private func insertAutocomplete(_ autocomplete: String, at selection: Selection, for range: NSRange, keepPrefix: Bool) {
169+
170+
// Apply the autocomplete attributes
171+
var attrs = autocompleteTextAttributes[selection.prefix] ?? defaultTextAttributes
172+
attrs[NSAttributedAutocompleteKey] = true
173+
let newString = (keepPrefix ? selection.prefix : "") + autocomplete
174+
let newAttributedString = NSAttributedString(string: newString, attributes: attrs)
175+
176+
// Modify the NSRange to include the prefix length
177+
let rangeModifier = keepPrefix ? selection.prefix.count : 0
178+
let highlightedRange = NSRange(location: range.location - rangeModifier, length: range.length + rangeModifier)
179+
180+
// Replace the attributedText with a modified version including the autocompete
181+
let newAttributedText = textView.attributedText.replacingCharacters(in: highlightedRange, with: newAttributedString)
182+
if appendSpaceOnCompletion {
183+
newAttributedText.append(NSAttributedString(string: " ", attributes: typingTextAttributes))
184+
}
185+
textView.attributedText = newAttributedText
186+
}
142187

143188
internal func check() {
144189
guard let result = textView.find(prefixes: registeredPrefixes) else {
@@ -156,13 +201,54 @@ public final class MessageAutocompleteController: MessageTextViewListener {
156201
else { return }
157202
keyboardHeight = keyboardFrame.height
158203
}
204+
205+
/// Ensures new text typed is not styled
206+
///
207+
/// - Parameter textView: The `UITextView` to apply `typingTextAttributes` to
208+
internal func preserveTypingAttributes(for textView: UITextView) {
209+
var typingAttributes = [String: Any]()
210+
typingTextAttributes.forEach { typingAttributes[$0.key.rawValue] = $0.value }
211+
textView.typingAttributes = typingAttributes
212+
}
159213

160214
// MARK: MessageTextViewListener
161215

162216
public func didChangeSelection(textView: MessageTextView) {
163217
check()
164218
}
165219

166-
public func didChange(textView: MessageTextView) {}
220+
public func didChange(textView: MessageTextView) {
221+
preserveTypingAttributes(for: textView)
222+
}
223+
224+
public func willChangeRange(textView: MessageTextView, to range: NSRange) {
225+
226+
// range.length > 0: Backspace/removing text
227+
// range.lowerBound < textView.selectedRange.lowerBound: Ignore trying to delete
228+
// the substring if the user is already doing so
229+
if range.length > 0, range.lowerBound < textView.selectedRange.lowerBound {
230+
231+
// Backspace/removing text
232+
let attribute = textView.attributedText
233+
.attributes(at: range.lowerBound, longestEffectiveRange: nil, in: range)
234+
.filter { return $0.key == NSAttributedAutocompleteKey }
235+
236+
if (attribute[NSAttributedAutocompleteKey] as? Bool ?? false) == true {
237+
238+
// Remove the autocompleted substring
239+
let lowerRange = NSRange(location: 0, length: range.location + 1)
240+
textView.attributedText.enumerateAttribute(NSAttributedAutocompleteKey, in: lowerRange, options: .reverse, using: { (_, range, stop) in
241+
242+
// Only delete the first found range
243+
defer { stop.pointee = true }
244+
245+
let emptyString = NSAttributedString(string: "", attributes: typingTextAttributes)
246+
textView.attributedText = textView.attributedText.replacingCharacters(in: range, with: emptyString)
247+
textView.selectedRange = NSRange(location: range.location, length: 0)
248+
self.preserveTypingAttributes(for: textView)
249+
})
250+
}
251+
}
252+
}
167253

168254
}

MessageViewController/MessageTextView.swift

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import UIKit
1010
public protocol MessageTextViewListener: class {
1111
func didChange(textView: MessageTextView)
1212
func didChangeSelection(textView: MessageTextView)
13+
func willChangeRange(textView: MessageTextView, to range: NSRange)
1314
}
1415

1516
open class MessageTextView: UITextView, UITextViewDelegate {
@@ -131,5 +132,10 @@ open class MessageTextView: UITextView, UITextViewDelegate {
131132
public func textViewDidChangeSelection(_ textView: UITextView) {
132133
enumerateListeners { $0.didChangeSelection(textView: self) }
133134
}
135+
136+
public func textView(_ textView: UITextView, shouldChangeTextIn range: NSRange, replacementText text: String) -> Bool {
137+
enumerateListeners { $0.willChangeRange(textView: self, to: range) }
138+
return true
139+
}
134140

135141
}

MessageViewController/MessageView.swift

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -240,5 +240,7 @@ public final class MessageView: UIView, MessageTextViewListener {
240240
public func didChangeSelection(textView: MessageTextView) {
241241
delegate?.selectionDidChange(messageView: self)
242242
}
243+
244+
public func willChangeRange(textView: MessageTextView, to range: NSRange) {}
243245

244246
}
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
//
2+
// NSAttributedString+ReplaceRange.swift
3+
// MessageViewController
4+
//
5+
// Created by Nathan Tannar on 1/31/18.
6+
// Copyright © 2018 Ryan Nystrom. All rights reserved.
7+
//
8+
9+
import Foundation
10+
11+
extension NSAttributedString {
12+
13+
func replacingCharacters(in range: NSRange, with attributedString: NSAttributedString) -> NSMutableAttributedString {
14+
let ns = NSMutableAttributedString(attributedString: self)
15+
ns.replaceCharacters(in: range, with: attributedString)
16+
return ns
17+
}
18+
19+
static func +(lhs: NSAttributedString, rhs: NSAttributedString) -> NSAttributedString {
20+
let ns = NSMutableAttributedString(attributedString: lhs)
21+
ns.append(rhs)
22+
return NSAttributedString(attributedString: ns)
23+
}
24+
}
25+
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
//
2+
// NSAttributedString+HighlightingTests.swift
3+
// MessageViewControllerTests
4+
//
5+
// Created by Nathan Tannar on 2/1/18.
6+
// Copyright © 2018 Ryan Nystrom. All rights reserved.
7+
//
8+
9+
import XCTest
10+
import MessageViewController
11+
12+
class NSAttributedString_HighlightingTests: XCTestCase {
13+
14+
var controller: MessageAutocompleteController?
15+
var textView: MessageTextView?
16+
17+
/// A key used for referencing which substrings were autocompletes
18+
private let NSAttributedAutocompleteKey = NSAttributedStringKey.init("com.system.autocompletekey")
19+
20+
override func setUp() {
21+
super.setUp()
22+
// Put setup code here. This method is called before the invocation of each test method in the class.
23+
24+
textView = MessageTextView()
25+
controller = MessageAutocompleteController(textView: textView!)
26+
}
27+
28+
override func tearDown() {
29+
// Put teardown code here. This method is called after the invocation of each test method in the class.
30+
controller = nil
31+
textView = nil
32+
33+
super.tearDown()
34+
}
35+
36+
func test_TailHighlight() {
37+
38+
guard let textView = textView else { return XCTAssert(false, "textView nil") }
39+
guard let controller = controller else { return XCTAssert(false, "controller nil") }
40+
41+
let prefix = "@"
42+
controller.register(prefix: prefix)
43+
44+
let nonAttributedText = "Some text " + prefix
45+
textView.attributedText = NSAttributedString(string: nonAttributedText)
46+
controller.didChangeSelection(textView: textView)
47+
guard controller.selection != nil else {
48+
return XCTAssert(false, "Selection nil")
49+
}
50+
let autocompleteText = "username"
51+
controller.accept(autocomplete: autocompleteText)
52+
let range = NSRange(location: nonAttributedText.count - 1, length: autocompleteText.count)
53+
let attributes = textView.attributedText.attributes(at: range.lowerBound, longestEffectiveRange: nil, in: range)
54+
guard let isAutocompleted = attributes[NSAttributedAutocompleteKey] as? Bool else {
55+
return XCTAssert(false, attributes.debugDescription)
56+
}
57+
XCTAssert(isAutocompleted, attributes.debugDescription)
58+
}
59+
60+
func test_HeadHighlight() {
61+
62+
guard let textView = textView else { return XCTAssert(false, "textView nil") }
63+
guard let controller = controller else { return XCTAssert(false, "controller nil") }
64+
65+
let prefix = "@"
66+
controller.register(prefix: prefix)
67+
68+
let nonAttributedText = prefix
69+
textView.attributedText = NSAttributedString(string: nonAttributedText)
70+
controller.didChangeSelection(textView: textView)
71+
guard controller.selection != nil else {
72+
return XCTAssert(false, "Selection nil")
73+
}
74+
let autocompleteText = "username"
75+
controller.accept(autocomplete: autocompleteText)
76+
77+
let highlightRange = NSRange(location: nonAttributedText.count - 1, length: autocompleteText.count)
78+
let attributes = textView.attributedText.attributes(at: highlightRange.lowerBound, longestEffectiveRange: nil, in: highlightRange)
79+
guard let isAutocompleted = attributes[NSAttributedAutocompleteKey] as? Bool else {
80+
return XCTAssert(false, attributes.debugDescription)
81+
}
82+
XCTAssert(isAutocompleted, attributes.debugDescription)
83+
}
84+
85+
}
86+

README.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,10 @@ messageView.font = .systemFont(ofSize: 17)
4949
messageView.set(buttonTitle: "Send", for: .normal)
5050
messageView.addButton(target: self, action: #selector(onButton))
5151
messageView.buttonTint = .blue
52+
53+
// Set custom attributes for an autocompleted string
54+
let tintColor = .blue
55+
messageAutocompleteController.autocompleteTextAttributes = ["@": [.font: UIFont.preferredFont(forTextStyle: .body), .foregroundColor: tintColor, .backgroundColor: tintColor.withAlphaComponent(0.1)]]
5256
```
5357

5458
## Autocomplete

0 commit comments

Comments
 (0)