-
Notifications
You must be signed in to change notification settings - Fork 93
Highlighting with NSAttributedString's #15
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -29,6 +29,25 @@ public final class MessageAutocompleteController: MessageTextViewListener { | |
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 | ||
open 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 | ||
return attributes | ||
} | ||
|
||
internal var registeredPrefixes = Set<String>() | ||
internal let border = CALayer() | ||
|
@@ -80,12 +99,18 @@ public final class MessageAutocompleteController: MessageTextViewListener { | |
) | ||
|
||
guard let range = Range(insertionRange, in: text) else { return } | ||
|
||
textView.text = text.replacingCharacters(in: range, with: autocomplete) | ||
|
||
// 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: insertionRange.location + autocomplete.utf16.count, | ||
location: selectedLocation, | ||
length: 0 | ||
) | ||
|
||
preserveTypingAttributes(for: textView) | ||
} | ||
|
||
internal func cancel() { | ||
|
@@ -139,6 +164,26 @@ public final class MessageAutocompleteController: MessageTextViewListener { | |
} | ||
|
||
// 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 { | ||
|
@@ -156,13 +201,54 @@ public final class MessageAutocompleteController: MessageTextViewListener { | |
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) {} | ||
public func didChange(textView: MessageTextView) { | ||
preserveTypingAttributes(for: textView) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This is great |
||
} | ||
|
||
public func willChangeRange(textView: MessageTextView, to range: NSRange) { | ||
|
||
// range.length > 0: Backspace/removing text | ||
// range.lowerBound < textView.selectedRange.lowerBound: Ignore trying to delete | ||
// the substring if the user is already doing so | ||
if range.length > 0, 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 (attribute[NSAttributedAutocompleteKey] as? Bool ?? false) == true { | ||
|
||
// Remove the autocompleted substring | ||
let lowerRange = NSRange(location: 0, length: range.location + 1) | ||
textView.attributedText.enumerateAttribute(NSAttributedAutocompleteKey, in: lowerRange, options: .reverse, using: { (_, range, stop) in | ||
|
||
// Only delete the first found range | ||
defer { stop.pointee = true } | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. ❤️ |
||
|
||
let emptyString = NSAttributedString(string: "", attributes: typingTextAttributes) | ||
textView.attributedText = textView.attributedText.replacingCharacters(in: range, with: emptyString) | ||
textView.selectedRange = NSRange(location: range.location, length: 0) | ||
self.preserveTypingAttributes(for: textView) | ||
}) | ||
} | ||
} | ||
} | ||
|
||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,25 @@ | ||
// | ||
// NSAttributedString+ReplaceRange.swift | ||
// MessageViewController | ||
// | ||
// Created by Nathan Tannar on 1/31/18. | ||
// Copyright © 2018 Ryan Nystrom. All rights reserved. | ||
// | ||
|
||
import Foundation | ||
|
||
extension NSAttributedString { | ||
|
||
func replacingCharacters(in range: NSRange, with attributedString: NSAttributedString) -> NSMutableAttributedString { | ||
let ns = NSMutableAttributedString(attributedString: self) | ||
ns.replaceCharacters(in: range, with: attributedString) | ||
return ns | ||
} | ||
|
||
static func +(lhs: NSAttributedString, rhs: NSAttributedString) -> NSAttributedString { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Love it!! |
||
let ns = NSMutableAttributedString(attributedString: lhs) | ||
ns.append(rhs) | ||
return NSAttributedString(attributedString: ns) | ||
} | ||
} | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,86 @@ | ||
// | ||
// NSAttributedString+HighlightingTests.swift | ||
// MessageViewControllerTests | ||
// | ||
// Created by Nathan Tannar on 2/1/18. | ||
// Copyright © 2018 Ryan Nystrom. All rights reserved. | ||
// | ||
|
||
import XCTest | ||
import MessageViewController | ||
|
||
class NSAttributedString_HighlightingTests: XCTestCase { | ||
|
||
var controller: MessageAutocompleteController? | ||
var textView: MessageTextView? | ||
|
||
/// A key used for referencing which substrings were autocompletes | ||
private let NSAttributedAutocompleteKey = NSAttributedStringKey.init("com.system.autocompletekey") | ||
|
||
override func setUp() { | ||
super.setUp() | ||
// Put setup code here. This method is called before the invocation of each test method in the class. | ||
|
||
textView = MessageTextView() | ||
controller = MessageAutocompleteController(textView: textView!) | ||
} | ||
|
||
override func tearDown() { | ||
// Put teardown code here. This method is called after the invocation of each test method in the class. | ||
controller = nil | ||
textView = nil | ||
|
||
super.tearDown() | ||
} | ||
|
||
func test_TailHighlight() { | ||
|
||
guard let textView = textView else { return XCTAssert(false, "textView nil") } | ||
guard let controller = controller else { return XCTAssert(false, "controller nil") } | ||
|
||
let prefix = "@" | ||
controller.register(prefix: prefix) | ||
|
||
let nonAttributedText = "Some text " + prefix | ||
textView.attributedText = NSAttributedString(string: nonAttributedText) | ||
controller.didChangeSelection(textView: textView) | ||
guard controller.selection != nil else { | ||
return XCTAssert(false, "Selection nil") | ||
} | ||
let autocompleteText = "username" | ||
controller.accept(autocomplete: autocompleteText) | ||
let range = NSRange(location: nonAttributedText.count - 1, length: autocompleteText.count) | ||
let attributes = textView.attributedText.attributes(at: range.lowerBound, longestEffectiveRange: nil, in: range) | ||
guard let isAutocompleted = attributes[NSAttributedAutocompleteKey] as? Bool else { | ||
return XCTAssert(false, attributes.debugDescription) | ||
} | ||
XCTAssert(isAutocompleted, attributes.debugDescription) | ||
} | ||
|
||
func test_HeadHighlight() { | ||
|
||
guard let textView = textView else { return XCTAssert(false, "textView nil") } | ||
guard let controller = controller else { return XCTAssert(false, "controller nil") } | ||
|
||
let prefix = "@" | ||
controller.register(prefix: prefix) | ||
|
||
let nonAttributedText = prefix | ||
textView.attributedText = NSAttributedString(string: nonAttributedText) | ||
controller.didChangeSelection(textView: textView) | ||
guard controller.selection != nil else { | ||
return XCTAssert(false, "Selection nil") | ||
} | ||
let autocompleteText = "username" | ||
controller.accept(autocomplete: autocompleteText) | ||
|
||
let highlightRange = NSRange(location: nonAttributedText.count - 1, length: autocompleteText.count) | ||
let attributes = textView.attributedText.attributes(at: highlightRange.lowerBound, longestEffectiveRange: nil, in: highlightRange) | ||
guard let isAutocompleted = attributes[NSAttributedAutocompleteKey] as? Bool else { | ||
return XCTAssert(false, attributes.debugDescription) | ||
} | ||
XCTAssert(isAutocompleted, attributes.debugDescription) | ||
} | ||
|
||
} | ||
|
||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yaaaay tests 🎉 |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Reminder for me: I should start adding docs 😝
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
😝 Every devs favorite task