Skip to content

Commit

Permalink
Fixed emoji glyph and unicode scalar support
Browse files Browse the repository at this point in the history
Added support for UTF16 ranges to UnicodeScalar ranges.

Fixed incorrect UTF16 String indexes being used for parsing Substrings
instead of Unicode scalar ranges.

Updated Unicode scalar index ranges to be used for String parsing, and UTF16 NSRange used for AttributedText and SelectedText ranges.

Fixed new text using emoji fonts if previous or next word, on detection it now defaults to using default text attributes.

Improved performance by setting attributes on textStorage instead of
reassignment. This may cause an animation which can be cancelled by
using UIView.performWithoutAnimation(actions:).

Removed String and Substring extensions for retrieving substrings.

Added character extension for detecting glyphs or 3byte unicode
characters.

Version 1.1.0
  • Loading branch information
philip-bui committed Mar 15, 2019
1 parent 5be2dd2 commit 1fb086a
Show file tree
Hide file tree
Showing 6 changed files with 86 additions and 124 deletions.
2 changes: 1 addition & 1 deletion AsYouTypeFormatter.podspec
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
Pod::Spec.new do |s|
s.name = 'AsYouTypeFormatter'
s.version = '1.0.0'
s.version = '1.1.0'
s.license= { :type => 'MIT', :file => 'LICENSE' }
s.summary = 'As You Type Formatter.'
s.description = 'Format text as you type, given certain character prefixes such as hashtags and mentions.'
Expand Down
12 changes: 6 additions & 6 deletions AsYouTypeFormatter.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -8,16 +8,16 @@

/* Begin PBXBuildFile section */
D4127EE62217DDFF00CF4C72 /* AsYouTypeFormatter.swift in Sources */ = {isa = PBXBuildFile; fileRef = D4127EE52217DDFF00CF4C72 /* AsYouTypeFormatter.swift */; };
D4127EE92217DE3800CF4C72 /* String.swift in Sources */ = {isa = PBXBuildFile; fileRef = D4127EE82217DE3800CF4C72 /* String.swift */; };
D4127F132217EC9F00CF4C72 /* String.swift in Sources */ = {isa = PBXBuildFile; fileRef = D4127EE82217DE3800CF4C72 /* String.swift */; };
D4127EE92217DE3800CF4C72 /* Character.swift in Sources */ = {isa = PBXBuildFile; fileRef = D4127EE82217DE3800CF4C72 /* Character.swift */; };
D4127F132217EC9F00CF4C72 /* Character.swift in Sources */ = {isa = PBXBuildFile; fileRef = D4127EE82217DE3800CF4C72 /* Character.swift */; };
D4127F142217EC9F00CF4C72 /* AsYouTypeFormatter.swift in Sources */ = {isa = PBXBuildFile; fileRef = D4127EE52217DDFF00CF4C72 /* AsYouTypeFormatter.swift */; };
/* End PBXBuildFile section */

/* Begin PBXFileReference section */
D4127EDA2217DB3800CF4C72 /* AsYouTypeFormatter_iOS.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = AsYouTypeFormatter_iOS.framework; sourceTree = BUILT_PRODUCTS_DIR; };
D4127EDE2217DB3900CF4C72 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
D4127EE52217DDFF00CF4C72 /* AsYouTypeFormatter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AsYouTypeFormatter.swift; sourceTree = "<group>"; };
D4127EE82217DE3800CF4C72 /* String.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = String.swift; sourceTree = "<group>"; };
D4127EE82217DE3800CF4C72 /* Character.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Character.swift; sourceTree = "<group>"; };
D4127F092217EB5700CF4C72 /* AsYouTypeFormatter_tvOS.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = AsYouTypeFormatter_tvOS.framework; sourceTree = BUILT_PRODUCTS_DIR; };
/* End PBXFileReference section */

Expand Down Expand Up @@ -69,7 +69,7 @@
D4127EE72217DE2D00CF4C72 /* Extensions */ = {
isa = PBXGroup;
children = (
D4127EE82217DE3800CF4C72 /* String.swift */,
D4127EE82217DE3800CF4C72 /* Character.swift */,
);
path = Extensions;
sourceTree = "<group>";
Expand Down Expand Up @@ -188,7 +188,7 @@
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
D4127EE92217DE3800CF4C72 /* String.swift in Sources */,
D4127EE92217DE3800CF4C72 /* Character.swift in Sources */,
D4127EE62217DDFF00CF4C72 /* AsYouTypeFormatter.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
Expand All @@ -197,7 +197,7 @@
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
D4127F132217EC9F00CF4C72 /* String.swift in Sources */,
D4127F132217EC9F00CF4C72 /* Character.swift in Sources */,
D4127F142217EC9F00CF4C72 /* AsYouTypeFormatter.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
Expand Down
9 changes: 4 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
# As You Type Formatter
[![CI Status](http://img.shields.io/travis/philip-bui/as-you-type-formatter.svg?style=flat)](https://travis-ci.org/philip-bui/as-you-type-formatter)
[![CodeCov](https://codecov.io/gh/philip-bui/as-you-type-formatter/branch/master/graph/badge.svg)](https://codecov.io/gh/philip-bui/as-you-type-formatter)
[![Version](https://img.shields.io/cocoapods/v/AsYouTypeFormatter.svg?style=flat)](http://cocoapods.org/pods/AsYouTypeFormatter)
[![Carthage Compatible](https://img.shields.io/badge/Carthage-compatible-4BC51D.svg?style=flat)](https://github.com/Carthage/Carthage)
[![Platform](https://img.shields.io/cocoapods/p/AsYouTypeFormatter.svg?style=flat)](http://cocoapods.org/pods/AsYouTypeFormatter)
Expand Down Expand Up @@ -39,21 +38,21 @@ github "philip-bui/as-you-type-formatter"

### Swift Package Manager

The [Swift Package Manager](https://swift.org/package-manager/) is a tool for automating the distribution of Swift code and is integrated into the `swift` compiler. It is in early development, but As You Type Formatter does support its use on supported platforms.
The [Swift Package Manager](https://swift.org/package-manager/) is a tool for automating the distribution of Swift code and is integrated into the `swift` compiler. It is in early development, but AsYouTypeFormatter does support its use on supported platforms.

Once you have your Swift package set up, adding AsYouTypeFormatter as a dependency is as easy as adding it to the `dependencies` value of your `Package.swift`.

```swift
dependencies: [
.package(url: "https://github.com/philip-bui/as-you-type-formatter.git", from: "1.0.0"))
.package(url: "https://github.com/philip-bui/as-you-type-formatter.git", from: "1.1.0"))
]
```

## Usage

- Character Prefix. Default `#` `@`, words beginning with character prefixes use their assigned text attributes.

- Delimiters. Default ` ` `\n`, delimiters indicate when a word has ended to use normal text attributes.
- Delimiters. Default emojis and non-alphanumeric characters, delimiters indicate when a word has ended to use normal text attributes.

AsYouTypeFormatter overrides two `UITextView` methods, `textView(shouldChangeTextIn:text:)` and `textViewDidChangeSelection()`. You can delegate your `UITextView` or call the methods within your own delegate.

Expand All @@ -77,7 +76,7 @@ private lazy var typeFormatter: AsYouTypeFormatter = {

## Design Decisions

- Default Delimiters. The default delimiters are ` ` and `\n`. This means that other special characters `?` do not delimit a word, but delegate methods can customize this.
- Default Delimiters. The default delimiters are Emojis, and characters not in Unicode Category L* or 0-9. Delegate methods can customize this.
- No suggestion support for `UITextField`. Suggestion support replies on detecting when the user selects a new word, and `UITextFieldDelegate` doesn't expose text selection events.
- Link supports. Enabling selectable and clickable text is not very practical.
- On multi-text selection, suggestions are disabled. The assumption is that text is usually selected when the user wants to copy and paste.
Expand Down
103 changes: 58 additions & 45 deletions Sources/AsYouTypeFormatter.swift
Original file line number Diff line number Diff line change
Expand Up @@ -27,10 +27,20 @@ public class AsYouTypeFormatter: NSObject, UITextViewDelegate {
delegate?.typeFormatter(self, recommendationRangeDidChange: recommendationRange)
}
}
public func recommendedText(text: String, recommendationRange range: NSRange) -> Substring? {
guard let unicodeRange = Range(range, in: text) else {
return nil
}
return text[unicodeRange]
}

public static var normalAttributes: [NSAttributedString.Key: Any] = [NSAttributedString.Key.font: UIFont.systemFont(ofSize: 16)]
public static var tagAttributes: [NSAttributedString.Key: Any] = [NSAttributedString.Key.font: UIFont.boldSystemFont(ofSize: 16)]
public static var mentionAttributes: [NSAttributedString.Key: Any] = [NSAttributedString.Key.font: UIFont.italicSystemFont(ofSize: 16)]
public static var alphabetAllowed: CharacterSet = CharacterSet.letters.subtracting(CharacterSet.nonBaseCharacters).union(CharacterSet(charactersIn: "0123456789"))
private var defaultAttributes: [NSAttributedString.Key: Any] {
return attributes[nil] ?? AsYouTypeFormatter.normalAttributes
}

public init(textView: UITextView? = nil, delegate: AsYouTypeFormatterDelegate? = nil, attributes: [Character?: [NSAttributedString.Key: Any]] = [
"#": AsYouTypeFormatter.tagAttributes,
Expand All @@ -47,16 +57,21 @@ public class AsYouTypeFormatter: NSObject, UITextViewDelegate {
}

public func textView(_ textView: UITextView, shouldChangeTextIn range: NSRange, replacementText text: String) -> Bool {
let isTypingAtEnd = range.location + range.length == textView.text.count
let typingAttributes = textView.typingAttributes
guard let prevWordAttributes = range.location == 0
? attributes[nil]
: textView.attributedText.attributes(at: range.location - 1, effectiveRange: nil) else {
fatalError("Invalid prevWordAttributes")
// Range is UTF16, create UnicodeScalar Range to match text.
guard let unicodeRange = Range(range, in: textView.text) else {
return true
}
let isTypingAtEnd = range.location + range.length == textView.text.utf16.count ||
(range.location + range.length > textView.text.utf16.count && !textView.text[textView.text.index(unicodeRange.upperBound, offsetBy: 1)].isUTF16)
let typingAttributes = textView.typingAttributes
// If prev word ends with emoji, use default attributes instead of Apple Emoji Font.
let prevWordAttributes = range.location == 0 ||
!textView.text[textView.text.index(unicodeRange.lowerBound, offsetBy: -1)].isUTF16
? defaultAttributes
: textView.textStorage.attributes(at: range.location - 1, effectiveRange: nil)
let nextWordAttributes = isTypingAtEnd
? prevWordAttributes
: textView.attributedText.attributes(at: range.location + range.length, effectiveRange: nil)
: textView.textStorage.attributes(at: range.location + range.length, effectiveRange: nil)
if !text.isEmpty {
// TODO: Merge two loops together for performance.
// If typingAttributes == nextWordAttributes, and no other attributes besides typingAttributes, let textView handle change.
Expand All @@ -65,8 +80,8 @@ public class AsYouTypeFormatter: NSObject, UITextViewDelegate {
}) {
return true
}
// If firstChar is character prefix or delimiter, (isTypingAtEnd || firstCharAttributes == nextWordAttributes), and no other attributes besides firstCharAttributes, merge typingAttributes and let textView handle change.
if let firstCharAttributes = attributes(fromCharacter: textView.text.first),
// If firstChar is character prefix or delimiter, (isTypingAtEnd || firstCharAttributes == nextWordAttributes), and no other attributes besides firstCharAttributes, update typingAttributes and let textView handle change.
if let firstCharAttributes = attributes(fromCharacter: text.first),
(isTypingAtEnd || attributes(isEqual: firstCharAttributes, nextWordAttributes)),
!text.contains(where: { c -> Bool in
!attributes(isEqual: attributes(fromCharacter: c), firstCharAttributes)
Expand All @@ -78,39 +93,36 @@ public class AsYouTypeFormatter: NSObject, UITextViewDelegate {
}
}
let attributedText = NSMutableAttributedString(string: text)
attributedText.addAttributes(textView.typingAttributes, range: NSRange(location: 0, length: text.count))
attributedText.addAttributes(textView.typingAttributes, range: NSRange(text.startIndex..<text.endIndex, in: text))
var textAttributes = prevWordAttributes
var attributesIndex = 0
// Starting with prevWordAttributes, iterate through characters finding and applying new textAttributes as required.
for (i, character) in text.enumerated() {
if let characterAttributes = attributes(fromCharacter: character) {
attributedText.addAttributes(textAttributes, range: NSRange(location: attributesIndex, length: i - attributesIndex))
attributedText.addAttributes(textAttributes, range: NSRange(text.index(text.startIndex, offsetBy: attributesIndex)..<text.index(text.startIndex, offsetBy: i), in: text))
// Mark this index as starting point for new textAttributes.
textAttributes = characterAttributes
attributesIndex = i
}
}
attributedText.addAttributes(textAttributes, range: NSRange(location: attributesIndex, length: text.count - attributesIndex))
guard let mutableText = textView.attributedText.mutableCopy() as? NSMutableAttributedString else {
fatalError("Invalid attributedText")
}
attributedText.addAttributes(textAttributes, range: NSRange(text.index(text.startIndex, offsetBy: attributesIndex)..<text.endIndex, in: text))
// If nextWordAttributes doesn't match with current textAttributes, apply textAttributes to nextWord.
if !isTypingAtEnd, !attributes(isEqual: textAttributes, nextWordAttributes) {
let firstWord = textView.text[range.location + range.length..<textView.text.count].enumerated().first { _, character -> Bool in
let unicodeRangeEnd = unicodeRange.upperBound
let firstWord = textView.text?[unicodeRangeEnd..<textView.text.endIndex].enumerated().first { _, character -> Bool in
attributes(fromCharacter: character) != nil
}
let length: Int
let range: NSRange
if let firstWord = firstWord {
length = firstWord.offset
range = NSRange(unicodeRangeEnd..<textView.text.index(unicodeRangeEnd, offsetBy: firstWord.offset), in: textView.text)
} else {
length = textView.text.count - range.location - range.length
range = NSRange(unicodeRangeEnd..<textView.text.endIndex, in: textView.text)
}
mutableText.addAttributes(textAttributes, range: NSRange(location: range.location + range.length, length: length))
textView.textStorage.addAttributes(textAttributes, range: range)
}
mutableText.replaceCharacters(in: range, with: attributedText)
textView.attributedText = mutableText.copy() as? NSAttributedString
textView.textStorage.replaceCharacters(in: range, with: attributedText)
// Assign new range at replacedText location + newText count.
textView.selectedRange = NSRange(location: range.location + text.count, length: 0)
textView.selectedRange = NSRange(location: range.location + text.utf16.count, length: 0)
return false
}

Expand Down Expand Up @@ -149,9 +161,11 @@ public class AsYouTypeFormatter: NSObject, UITextViewDelegate {
}

public func typeFormatter(isDelimiter character: Character) -> Bool {
return character == " " || character == "\n"
return !character.isUTF16 || character.unicodeScalars.contains { unicodeScalar in
!AsYouTypeFormatter.alphabetAllowed.contains(unicodeScalar)
}
}

private func characterSetNil() {
// NOTE: didSet does not invoke on nil being set on optional properties.
if character != nil {
Expand All @@ -161,45 +175,44 @@ public class AsYouTypeFormatter: NSObject, UITextViewDelegate {
}

public func textViewDidChangeSelection(_ textView: UITextView) {
guard let firstWord = textView.text[0..<textView.selectedRange.lowerBound]
.reversed().enumerated().first(where: { _, character -> Bool in
// If user is selecting multiple characters, don't provide suggestions.
guard textView.selectedRange.length == 0 else {
characterSetNil()
return
}
// Find first delimiter, or character prefix. If not found, return nil.
guard let unicodeRange = Range(textView.selectedRange, in: textView.text),
let firstWord = textView.text[textView.text.startIndex..<unicodeRange.lowerBound].reversed()
.enumerated()
.first(where: { _, character -> Bool in
attributes(fromCharacter: character) != nil
}) else {
characterSetNil()
return
}
// If not delimiter, we provide a recommendation range.
guard attributes[firstWord.element] != nil else {
characterSetNil()
return
}
// If user is selecting multiple characters, don't provide suggestions.
guard textView.selectedRange.length == 0 else {
// If not delimiter, we provide a recommendation range.
guard attributes[firstWord.element] != nil else {
characterSetNil()
return
}
character = firstWord.element
let firstWordIndex = textView.selectedRange.lowerBound - firstWord.offset
guard let secondWord = textView.text[firstWordIndex..<textView.text.count].enumerated().first(where: { _, c -> Bool in
let firstWordIndex = textView.text.index(unicodeRange.lowerBound, offsetBy: -firstWord.offset)
guard let secondWord = textView.text[firstWordIndex..<textView.text.endIndex].enumerated().first(where: { _, c -> Bool in
attributes(fromCharacter: c) != nil
}) else {
// No delimiter or new word found.
recommendationRange = NSRange(location: firstWordIndex, length: textView.text.count - firstWordIndex)
recommendationRange = NSRange(firstWordIndex..<textView.text.endIndex, in: textView.text)
return
}
recommendationRange = NSRange(location: firstWordIndex, length: secondWord.offset)
recommendationRange = NSRange(firstWordIndex..<textView.text.index(firstWordIndex, offsetBy: secondWord.offset), in: textView.text)
}

public func typeFormatter(_ textView: UITextView, replaceRecommendationRange recommendationRange: NSRange, withText text: String) {
guard let mutableText = textView.attributedText.mutableCopy() as? NSMutableAttributedString else {
fatalError("Invalid attributedText")
}
let attributedText = NSMutableAttributedString(string: text)
attributedText.addAttributes(textView.typingAttributes, range: NSRange(location: 0, length: text.count))
mutableText.replaceCharacters(in: recommendationRange, with: attributedText)
textView.attributedText = mutableText.copy() as? NSAttributedString
attributedText.addAttributes(textView.typingAttributes, range: NSRange(location: 0, length: text.utf16.count))
textView.textStorage.replaceCharacters(in: recommendationRange, with: attributedText)
// Assign new range at replacedText location + newText count.
textView.selectedRange = NSRange(location: recommendationRange.location + text.count, length: 0)
textView.selectedRange = NSRange(location: recommendationRange.location + text.utf16.count, length: 0)
}
}

Expand Down
17 changes: 17 additions & 0 deletions Sources/Extensions/Character.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
//
// Character.swift
// AsYouTypeFormatter
//
// Created by Philip on 16/03/19.
// Copyright © 2018 Next Generation. All rights reserved.
//

import Foundation

extension Character {
var isUTF16: Bool {
return unicodeScalars.count == 1 && !unicodeScalars.contains(where: { unicodeScalar -> Bool in
unicodeScalar.value > UTF16Char.max
})
}
}
Loading

0 comments on commit 1fb086a

Please sign in to comment.