24

I have a UITextView and am using its tokenizer to check which words the user taps on.

My goal is to change what the tokenizer thinks of as a word. Currently it seems to define words as consecutive alphanumeric characters, I want a word to be defined as consecutive characters that aren't a space character (" ").

For example: 'foo-bar', 'foo/bar' and 'foo@@bar' will all currently be treated as two separate words ('foo' and 'bar') but I want them all to be treated as a single word (as none of them contain spaces).

The documentation talks about subclassing the UITextInputStringTokenizer class but I can't find a single example of someone doing this and I can't figure out how I would go about implementing the required methods:

func isPosition(position: UITextPosition, atBoundary granularity: UITextGranularity, inDirection direction: UITextDirection) -> Bool

func isPosition(position: UITextPosition, withinTextUnit granularity: UITextGranularity, inDirection direction: UITextDirection) -> Bool

func positionFromPosition(position: UITextPosition, toBoundary granularity: UITextGranularity, inDirection direction: UITextDirection) -> UITextPosition?

func rangeEnclosingPosition(position: UITextPosition, withGranularity granularity: UITextGranularity, inDirection direction: UITextDirection) -> UITextRange?
Ridho Octanio
  • 543
  • 5
  • 14
Joshua Burr
  • 241
  • 1
  • 6

3 Answers3

0

To summarize, create your implementation that extends UITextInputStringTokenizer and leave most methods untouched (or just calling super)

You just need to override isPosition(_:atBoundary:inDirection:) and isPosition(_:withinTextUnit:inDirection:) when granularity is word to check if the characters next to that position are considered to be in a word boundary, i.e., alphanumeric character and space together. The default implementation will return true also for other non-spaces that are considered not part of a word, you instead consider those as forming part of a word. When granularity is not word, you can default to super as well.

Victor Jalencas
  • 1,216
  • 11
  • 23
0

// Create a subclass of UITextInputStringTokenizer class CustomTokenizer: UITextInputStringTokenizer {

// Override the rangeEnclosingPosition method so that it looks for characters that are not a space (" ")
override func rangeEnclosingPosition(position: UITextPosition, withGranularity granularity: UITextGranularity, inDirection direction: UITextDirection) -> UITextRange? {
    // First check if the range enclosing the position is not nil
    guard let range = super.rangeEnclosingPosition(position: position, withGranularity: granularity, inDirection: direction) else {
        return nil
    }
    
    // Then define a string of characters that are not a space (" ")
    let nonSpaceCharacterSet = CharacterSet.init(charactersIn: "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLKMNOPQRSTUVWXYZ1234567890_")
    
    // Then define a range which will be used to search for the non space characters
    let startIndex = range.start
    let endIndex = range.end
    let searchRange = startIndex..<endIndex
    
    // Then define a string based on the range
    let text = self.textInput.text(in: searchRange)
    
    // Then search the string for any characters that are not a space (" ")
    if let _ = text?.rangeOfCharacter(from: nonSpaceCharacterSet) {
        // If any characters that are not a space (" ") are found, then return the range
        return range
    } else {
        // Otherwise, return nil
        return nil
    }
}

}

// Then set the tokenizer of the UITextView to the CustomTokenizer textView.tokenizer = CustomTokenizer(textInput: textView)

0

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.

Johann Schwarze
  • 161
  • 1
  • 4