Skip to content

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

Merged
merged 4 commits into from
Feb 3, 2018
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions Examples/Examples/ViewController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,11 @@ class ViewController: MessageViewController, UITableViewDataSource, UITableViewD
messageAutocompleteController.tableView.dataSource = self
messageAutocompleteController.tableView.delegate = self
messageAutocompleteController.register(prefix: "@")

// Set custom attributes for an autocompleted string
let tintColor = UIColor(red: 0, green: 122/255, blue: 1, alpha: 1)
messageAutocompleteController.autocompleteTextAttributes = ["@": [.font: UIFont.preferredFont(forTextStyle: .body), .foregroundColor: tintColor, .backgroundColor: tintColor.withAlphaComponent(0.1)]]

messageAutocompleteController.delegate = self

setup(scrollView: tableView)
Expand Down
8 changes: 8 additions & 0 deletions MessageViewController.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@
29CC29471FF42687006B6DE7 /* String+WordAtRange.swift in Sources */ = {isa = PBXBuildFile; fileRef = 29CC29451FF42687006B6DE7 /* String+WordAtRange.swift */; };
29CC29481FF42687006B6DE7 /* UIView+iOS11.swift in Sources */ = {isa = PBXBuildFile; fileRef = 29CC29461FF42687006B6DE7 /* UIView+iOS11.swift */; };
29CC29491FF81F1F006B6DE7 /* String+WordAtRange.swift in Sources */ = {isa = PBXBuildFile; fileRef = 29CC29451FF42687006B6DE7 /* String+WordAtRange.swift */; };
38199E112022792600ADFE76 /* NSAttributedString+ReplaceRange.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38199E102022792600ADFE76 /* NSAttributedString+ReplaceRange.swift */; };
38D26FB12023D01900B2B7B5 /* NSAttributedString+HighlightingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38D26FB02023D01900B2B7B5 /* NSAttributedString+HighlightingTests.swift */; };
/* End PBXBuildFile section */

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

/* Begin PBXFrameworksBuildPhase section */
Expand Down Expand Up @@ -108,6 +112,7 @@
2904821D1FED90340053978C /* UIButton+BottomHeightOffset.swift */,
290482161FED90340053978C /* UIScrollView+StopScrolling.swift */,
2904821E1FED90340053978C /* UITextView+Prefixes.swift */,
38199E102022792600ADFE76 /* NSAttributedString+ReplaceRange.swift */,
29CC29461FF42687006B6DE7 /* UIView+iOS11.swift */,
);
path = MessageViewController;
Expand All @@ -119,6 +124,7 @@
29CC293C1FF4266D006B6DE7 /* Info.plist */,
29CC293A1FF4266D006B6DE7 /* MessageViewControllerTests.swift */,
29CC29431FF4267F006B6DE7 /* String+WordAtRangeTests.swift */,
38D26FB02023D01900B2B7B5 /* NSAttributedString+HighlightingTests.swift */,
);
path = MessageViewControllerTests;
sourceTree = "<group>";
Expand Down Expand Up @@ -242,6 +248,7 @@
290482201FED90340053978C /* MessageViewController.swift in Sources */,
29CC29481FF42687006B6DE7 /* UIView+iOS11.swift in Sources */,
29792B151FFAE7FC007A0C57 /* MessageAutocompleteController.swift in Sources */,
38199E112022792600ADFE76 /* NSAttributedString+ReplaceRange.swift in Sources */,
2904821F1FED90340053978C /* UIScrollView+StopScrolling.swift in Sources */,
29CC29471FF42687006B6DE7 /* String+WordAtRange.swift in Sources */,
290482271FED90340053978C /* UITextView+Prefixes.swift in Sources */,
Expand All @@ -253,6 +260,7 @@
buildActionMask = 2147483647;
files = (
29CC293B1FF4266D006B6DE7 /* MessageViewControllerTests.swift in Sources */,
38D26FB12023D01900B2B7B5 /* NSAttributedString+HighlightingTests.swift in Sources */,
29CC29441FF4267F006B6DE7 /* String+WordAtRangeTests.swift in Sources */,
29CC29491FF81F1F006B6DE7 /* String+WordAtRange.swift in Sources */,
);
Expand Down
94 changes: 90 additions & 4 deletions MessageViewController/MessageAutocompleteController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Copy link
Member

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 😝

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

😝 Every devs favorite task

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()
Expand Down Expand Up @@ -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() {
Expand Down Expand Up @@ -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 {
Expand All @@ -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)
Copy link
Member

Choose a reason for hiding this comment

The 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 }
Copy link
Member

Choose a reason for hiding this comment

The 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)
})
}
}
}

}
6 changes: 6 additions & 0 deletions MessageViewController/MessageTextView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import UIKit
public protocol MessageTextViewListener: class {
func didChange(textView: MessageTextView)
func didChangeSelection(textView: MessageTextView)
func willChangeRange(textView: MessageTextView, to range: NSRange)
}

open class MessageTextView: UITextView, UITextViewDelegate {
Expand Down Expand Up @@ -131,5 +132,10 @@ open class MessageTextView: UITextView, UITextViewDelegate {
public func textViewDidChangeSelection(_ textView: UITextView) {
enumerateListeners { $0.didChangeSelection(textView: self) }
}

public func textView(_ textView: UITextView, shouldChangeTextIn range: NSRange, replacementText text: String) -> Bool {
enumerateListeners { $0.willChangeRange(textView: self, to: range) }
return true
}

}
2 changes: 2 additions & 0 deletions MessageViewController/MessageView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -240,5 +240,7 @@ public final class MessageView: UIView, MessageTextViewListener {
public func didChangeSelection(textView: MessageTextView) {
delegate?.selectionDidChange(messageView: self)
}

public func willChangeRange(textView: MessageTextView, to range: NSRange) {}

}
25 changes: 25 additions & 0 deletions MessageViewController/NSAttributedString+ReplaceRange.swift
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 {
Copy link
Member

Choose a reason for hiding this comment

The 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)
}

}

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yaaaay tests 🎉

4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,10 @@ messageView.font = .systemFont(ofSize: 17)
messageView.set(buttonTitle: "Send", for: .normal)
messageView.addButton(target: self, action: #selector(onButton))
messageView.buttonTint = .blue

// Set custom attributes for an autocompleted string
let tintColor = .blue
messageAutocompleteController.autocompleteTextAttributes = ["@": [.font: UIFont.preferredFont(forTextStyle: .body), .foregroundColor: tintColor, .backgroundColor: tintColor.withAlphaComponent(0.1)]]
```

## Autocomplete
Expand Down