slackhq / SlackTextViewController

⛔️**DEPRECATED** ⛔️ A drop-in UIViewController subclass with a growing text input view and other useful messaging features
https://slack.com/
MIT License
8.32k stars 1.08k forks source link

Improving autocomplete for better mention support #658

Open eliburke opened 6 years ago

eliburke commented 6 years ago

I submitted PR #657 to allow autocomplete to submit attributed strings. The goal is to implement mentions in a more seamless manner. Here's a short gif showing improved handling. I think the majority of this could be incorporated into the framework if there is desire.

token

Here's my current implementation. Apologies if you don't know Swift; it should not be too hard to translate back to Objective C.

First, a global constant: let MentionAttributeStringKey = NSAttributedStringKey(rawValue: "UserMention")

A helper to generate the attributed text tags for a new mention token

func mentionAttributes(_ username: String = "") -> [NSAttributedStringKey: Any] {
    let attrs: [NSAttributedStringKey: Any] = [MentionAttributeStringKey: username,
                                               NSAttributedStringKey.foregroundColor: UIColor.red,
                                               NSAttributedStringKey.underlineStyle : NSUnderlineStyle.styleSingle.rawValue]
    return attrs
}

The meat of this feature is additional logic for SLKTextView's shouldChangeTextIn delegate. Here I override the implementation, and fall through to super if no mention token is being modified.

override func textView(_ textView: UITextView, shouldChangeTextIn range: NSRange, replacementText text: String) -> Bool {

    guard let textView = textView as? SLKTextView else {
        return true
    }

    var shouldReplace = false
    var tokenAttrRange = NSRange()
    var currentReplacementRange = range

    if range.length == 0 {
        // determine if user is inserting or deleting at the start of the string, with a token at the start
        if range.location == 0 &&
            textView.attributedText.length > 0 &&
            nil != textView.attributedText.attribute(MentionAttributeStringKey, at: range.location, effectiveRange: &tokenAttrRange) {
            currentReplacementRange = NSUnionRange(currentReplacementRange, tokenAttrRange)
            shouldReplace = true
        }
        // determine if user is inserting text at the end of the string, with a token at the end
        else if range.location > 0 &&
            nil != textView.attributedText.attribute(MentionAttributeStringKey, at: range.location - 1, effectiveRange: &tokenAttrRange) {
            currentReplacementRange = NSUnionRange(currentReplacementRange, tokenAttrRange)
            shouldReplace = true
        }
    } else {
        // search the range for any instances of the desired text attribute
        textView.attributedText.enumerateAttribute(MentionAttributeStringKey, in: range,
                                                   options: .longestEffectiveRangeNotRequired, using: { (value, attrRange, stop) in
            // get the attribute's full range and merge it with the original
            if nil != textView.attributedText.attribute(MentionAttributeStringKey, at: attrRange.location, effectiveRange: &tokenAttrRange) {
                currentReplacementRange = NSUnionRange(currentReplacementRange, tokenAttrRange)
                shouldReplace = true
            }
        })
    }

    if shouldReplace {
        // remove the token attr, and then replace the characters with the input str (which can be empty on a backspace)
        let mutableAttributedText = textView.attributedText.mutableCopy() as! NSMutableAttributedString
        let mentionAttrs = mentionAttributes()
        for (key, _) in mentionAttrs {
            mutableAttributedText.removeAttribute(key, range: currentReplacementRange)
        }

        // replace the text with the user's input, unless at the beginning of the line and the user hit backspace
        if text.lengthOfBytes(using: .utf8) <= 0 && currentReplacementRange.location == 0 && currentReplacementRange.length >= mutableAttributedText.length {
            mutableAttributedText.replaceCharacters(in: currentReplacementRange, with: " ")
        } else {
            mutableAttributedText.replaceCharacters(in: currentReplacementRange, with: text)
        }

        // update the string and set the cursor position to the end of the edited location
        textView.attributedText = mutableAttributedText
        if let cursorPosition = textView.position(from: textView.beginningOfDocument, offset: currentReplacementRange.location + text.lengthOfBytes(using: .utf8)) {
            textView.selectedTextRange = textView.textRange(from: cursorPosition, to: cursorPosition)
        }

        return false
    }

    return super.textView(textView, shouldChangeTextIn: range, replacementText: text)
}

After a token is detected (by looking for the custom MentionAttributeStringKey I first strip off all added attributes. This helps prevent styling future text entry incorrectly. Then I replace the selection with the desired text (unless they hit backspace-- a completely empty textView seems to incorrectly preserve some undesired text attributes, so I insert a space). Finally, place the cursor within the updated string.

The next piece of the puzzle is to update your didSelectRowAt, where the code processes selection of autocomplete entries:

            var mentionUser = "username"
            var mentionName = "full name"

            let range = NSMakeRange(0, mentionName.lengthOfBytes(using: .utf8))
            let tokenStr = NSMutableAttributedString(string: mentionName)
            let spaceStr = NSMutableAttributedString(string: " ")

            // copy current text attributes. if at the end of the string, back up one character
            if textView.attributedText.length > 0 {
                var cursorPosition = textView.selectedRange.location
                if cursorPosition >= textView.attributedText.length {
                    cursorPosition = textView.attributedText.length - 1
                }

                var attrRange = NSRange()
                let attrs = textView.attributedText.attributes(at: cursorPosition, effectiveRange: &attrRange)
                tokenStr.addAttributes(attrs, range: range)
                spaceStr.addAttributes(attrs, range: NSMakeRange(0, 1))
            }

            // apply desired @mention token attributes, using helper function above
            let mentionAttrs = mentionAttributes(mentionUser)
            tokenStr.addAttributes(mentionAttrs, range: range)

            // and finally, add a space at the end of the token using the original text attributes
            tokenStr.append(spaceStr)

            self.acceptAutoCompletion(attributedString: tokenStr, keepPrefix: false)

After this, things will become more customized based on your application and messaging protocol. When the user hits send, use textView.attributedText.enumerateAttribute to enumerate mentions and handle accordingly. You may probably need to do text substitutions or some other protocol-specific processing, both for sending the message and for displaying in text bubbles.

I hope this is useful for folks, and if the repo maintainers are interested in incorporating it, I'm happy to be involved.

eliburke commented 6 years ago

I should add.. if you want to play with this without merging #657, you can add this in your SLKTextViewController subclass:

public func acceptAutoCompletion(attributedString: NSAttributedString, keepPrefix: Bool) {

    if attributedString.length == 0 {
        return
    }

    var location = self.foundPrefixRange.location
    if (keepPrefix) {
        location += self.foundPrefixRange.length
    }

    var length = self.foundWord?.lengthOfBytes(using: .utf8) ?? 0
    if (!keepPrefix) {
        length += self.foundPrefixRange.length
    }

    let range = NSMakeRange(location, length)
    let insertionRange = textView.slk_insertAttributedText(attributedString, in: range)

    self.textView.selectedRange = NSMakeRange(insertionRange.location, 0);
    self.textView.slk_scrollToCaretPositon(animated: false)
    self.cancelAutoCompletion()
}