In order to implement a custom UITextInputTokenizer
you can either create an entirely new class that implements the UITextInputTokenizer
protocol or you can subclass UITextInputStringTokenizer
. Since you only want to change what the tokenizer thinks of as a word it is simpler to subclass UITextInputStringTokenizer
and implement the required methods like so:
class MyUITextInputStringTokenizer: UITextInputStringTokenizer {
weak var textInput: (UIResponder & UITextInput)? = nil
override init(textInput: UIResponder & UITextInput) {
self.textInput = textInput
super.init(textInput: textInput)
}
// MARK: required methods to override
override func isPosition(_ position: UITextPosition, atBoundary granularity: UITextGranularity, inDirection direction: UITextDirection) -> Bool {
guard let textInput = textInput else {
return false
}
if granularity == .word {
guard let start = textInput.position(from: position, offset: -1) else {
// position is at the beginning of the document
guard let end = textInput.position(from: position, offset: 1) else {
// the document is empty
return false
}
guard let range = textInput.textRange(from: position, to: end) else {
return false
}
guard let text = textInput.text(in: range) else {
return false
}
if direction == .storage(.forward) || direction == .layout(.right) {
return false
} else {
return !self.charIsWhitespace(text[text.startIndex])
}
}
guard let end = textInput.position(from: position, offset: 1) else {
// position is at the end of a nonempty document
guard let range = textInput.textRange(from: start, to: position) else {
return false
}
guard let text = textInput.text(in: range) else {
return false
}
if direction == .storage(.forward) || direction == .layout(.right) {
return !self.charIsWhitespace(text[text.startIndex])
} else {
return false
}
}
guard let range = textInput.textRange(from: start, to: end) else {
return false
}
guard let text = textInput.text(in: range) else {
return false
}
if direction == .storage(.forward) || direction == .layout(.right) {
return !self.charIsWhitespace(text[text.startIndex]) && self.charIsWhitespace(text[text.index(before: text.endIndex)])
} else {
return self.charIsWhitespace(text[text.startIndex]) && !self.charIsWhitespace(text[text.index(before: text.endIndex)])
}
} else {
return super.isPosition(position, atBoundary: granularity, inDirection: direction)
}
}
override func isPosition(_ position: UITextPosition, withinTextUnit granularity: UITextGranularity, inDirection direction: UITextDirection) -> Bool {
guard let textInput = textInput else {
return false
}
if granularity == .word {
var offset = 1
if direction == .storage(.backward) || direction == .layout(.left) {
offset = -1
}
let start = position
guard let end = textInput.position(from: position, offset: offset) else {
return false
}
guard let range = textInput.textRange(from: start , to: end) else {
return false
}
guard let text = textInput.text(in: range) else {
return false
}
return !self.charIsWhitespace(text[text.startIndex])
} else {
return super.isPosition(position, withinTextUnit: granularity, inDirection: direction)
}
}
override func position(from position: UITextPosition, toBoundary granularity: UITextGranularity, inDirection direction: UITextDirection) -> UITextPosition? {
guard let textInput = textInput else {
return nil
}
if granularity == .word {
var offset = 1
if direction == .storage(.backward) || direction == .layout(.left) {
offset = -1
}
var pos = position
while let newPos = textInput.position(from: pos, offset: offset) {
pos = newPos
if self.isPosition(newPos, atBoundary: .word, inDirection: .storage(.forward)) || self.isPosition(newPos, atBoundary: .word, inDirection: .storage(.backward)) {
break
}
}
return pos
} else {
return super.position(from: position, toBoundary: granularity, inDirection: direction)
}
}
override func rangeEnclosingPosition(_ position: UITextPosition, with granularity: UITextGranularity, inDirection direction: UITextDirection) -> UITextRange? {
guard let textInput = textInput else {
return nil
}
if granularity == .word {
let start = self.find(textInput: textInput, position: position, direction: .backward, condition: self.charIsWhitespace)
let end = self.find(textInput: textInput, position: position, direction: .forward, condition: self.charIsWhitespace)
if textInput.compare(start, to: end) == .orderedSame {
return nil
}
return textInput.textRange(from: start, to: end)
} else {
return super.rangeEnclosingPosition(position, with: granularity, inDirection: direction)
}
}
// MARK: - Helper methods
func find(textInput: UITextInput, position: UITextPosition, direction: UITextStorageDirection, condition: (Character) -> Bool) -> UITextPosition {
let offset = direction == .forward ? 1 : -1
var pos = position
while let newPos = textInput.position(from: pos, offset: offset) {
guard let range = textInput.textRange(from: pos, to: newPos) else {
break
}
guard let text = textInput.text(in: range) else {
break
}
if condition(text[text.startIndex]) {
break
}
pos = newPos
}
return pos
}
func charIsWhitespace(_ ch: Character) -> Bool {
return ch == " "
}
}
In the overriden functions if granularity
is equal to .word
we provide our own implementation. Otherwise we use the one from UITextInputStringTokenizer
. The textInput
property needs to be declared as weak
in order to avoid retain cycles.
UITextInput
objects generally do not provide a public setter for their tokenizer
property. To use this tokenizer with e.g. an UITextView
you have to subclass UITextView
, override it's getter and return an instance of it in the getter.
class MyUITextView: UITextView {
lazy var _tokenizer: UITextInputTokenizer = MyUITextInputStringTokenizer(textInput: self)
override var tokenizer: UITextInputTokenizer {
get {
return _tokenizer
}
}
}
When being called with granularity
as .word
, the overridden functions are expected to behave as follows:
The function rangeEnclosingPosition(_, with:, inDirection:)
is expected to return a UITextRange
containing position
whose text is a word or nil
if no such range exists.
position(from:, toBoundary:, inDirection:)
returns the position of the next word boundary in direction
. More precisely it is supposed to return the next position in direction
, that is a word boundary in any direction.
isPosition(, withinTextUnit:, inDirection:)
is expected to return true
if position
is located within a word. On positions that are also word boundaries this function should return true
if the word is located relative to position
in same direction as direction
.
isPosition(, atBoundary:, inDirection:)
should return true
if position
is a word boundary. In this implementation a position is a word boundary if no word is present in direction
and if a word is present in the opposite direction to direction
.