Mentions in UITextView using Proton Editor
Mentioning someone is one of the most common feature in social/work apps these days, and it looks a trivial task to implement the same too — or at least, one would hope so. In a typical app, there would be a UITextView in which users may type the content, and also mention someone. However, as you might know, to get a simple mention in a UITextView is not as simple after all. This is one of the motivations of extending the Open-Source library, Proton to add support for a better background than what is provided by iOS natively.
Let’s see what options do iOS provides and what can we build on top of it to not only make user experience better, but developer experience too.
Plain vanilla Mentions
The simplest approach to adding a specific UI treatment for mentions in UITextView would be to use NSAttributedString with background
attribute. The code looks pretty simple to add background to given range of text:
let mention = NSAttributedString(string: "@rajdeep", attributes: [
.backgroundColor: UIColor.systemBlue,
.foregroundColor: UIColor.white
])editor.attributedText = mention
However, as you can see, it looks pretty bland and there’s not much that we can do with just attributes. For instance, at the very least, it would be desirable to have rounded corners for the mention text. Unfortunately, it is not doable without really changing how backgrounds are drawn in NSLayoutManager.
Extending UITextView and NSAttributedString
Mentions is one of initial requirements that I looked at when I started working on Proton. I always felt that background style in NSAttributedString is severely limited in terms of what developers can do with it and hence I developed my own version of background style attribute, appropriately called backgroundStyle
in Proton. Proton just extends a UITextView and NSAttributedString to add powerful new features, and a more intuitive API for existing features.
Let’s see how we can utilize backgroundStyle
along with a few other attributes to build a perfect mention:
Starting off
Just like background
example above, we can use backgroundStyle
to get exact same output:
let mention = NSAttributedString(string: "@rajdeep", attributes: [
.backgroundStyle: BackgroundStyle(color: .systemBlue),
.foregroundColor: UIColor.white
])
editor.attributedText = mention
Adding style
Unlike background
attribute though, it is just as simple to add rounding to the backgroundStyle
:
let mention = NSAttributedString(string: "@rajdeep", attributes: [
.backgroundStyle:
BackgroundStyle(color: .systemBlue,
roundedCornerStyle: .relative(percent: 50)),
.foregroundColor: UIColor.white
])
editor.attributedText = mention
roundedCornersStyle
may be a relative
percentage of height of text to get perfectly rounded corners irrespective of text size, or it can have a absolute
value for corner radius.
Not only that, if you wish, you can add also shadow and borders:
let mention = NSAttributedString(string: "@rajdeep", attributes: [
.backgroundStyle:
BackgroundStyle(color: .systemBlue,
roundedCornerStyle: .relative(percent: 50),
border: BorderStyle(lineWidth: 1, color: .systemRed),
shadow: ShadowStyle(color: .systemGray,
offset: CGSize(width: 1, height: 1),blur: 2)),
.foregroundColor: UIColor.white
])
editor.attributedText = mention
What about editing
Editing text with attributes like that for mentions bring along a whole new set of issues because of how attributes are treated in UITextView
.
Bleeding attributes
This is one of the issues you might not realise until you see one. When a set of attributes are applied to string, these are automatically added to typingAttributes
. This means, if you have applied background
or backgroundStyle
or for that matter any other attribute, it will automatically be carried forward in the following range:
Of course, this is not desirable. This can be fixed very easily in Proton by using another attribute called lockedAttributes
. Any attribute that is not desired to be carry forward (in this case, backgroundStyle
and foregroundColor
), can be added to lockedAttribute
to prevent bleeding these into the following range.
let mention = NSAttributedString(string: "@rajdeep", attributes: [
.backgroundStyle:
BackgroundStyle(color: .systemBlue,
roundedCornerStyle: .relative(percent: 50)),
.foregroundColor: UIColor.white,
.lockedAttributes:
[
NSAttributedString.Key.foregroundColor,
.backgroundStyle
]
])
editor.attributedText = mention
Partial deletion
Another issue that this implementation would show is, that user can delete part of the mention. This is because it’s still just text in the UITextView which is editable.
A typical requirement would be that if a part of mention is deleted, the entire mention should be removed. This can be solved by adding yet another attribute called textBlock
to the range. This makes the entire range of text with this attribute act as a single block i.e. you can only delete it as a single unit, and you can not have the cursor anywhere in the middle of it:
let mention = NSAttributedString(string: "@rajdeep", attributes: [
.backgroundStyle:
BackgroundStyle(color: .systemBlue,
roundedCornerStyle: .relative(percent: 50)),
.foregroundColor: UIColor.white,
.lockedAttributes:
[
NSAttributedString.Key.foregroundColor,
.backgroundStyle
],
.textBlock: true
])
editor.attributedText = mention
Wrapping
As with any other part of text, a mention may also be long enough that it wraps to next line. By default, it will show up as 2 capsules.
However, we may want to show the wrapping as a continuity. This can be achieved by providing another argument, hasSquaredOffJoins
to backgroundStyle
. This ensures that at for all the continuous ranges, having wrapped text, the inner edges would be squared off giving an impression that it is continuing from previous line:
let mention = NSAttributedString(string: "@rajdeep", attributes: [
.backgroundStyle:
BackgroundStyle(color: .systemBlue,
roundedCornerStyle: .relative(percent: 50),
hasSquaredOffJoins: true),
.foregroundColor: UIColor.white,
.lockedAttributes:
[
NSAttributedString.Key.foregroundColor,
.backgroundStyle
],
.textBlock: true
])
editor.attributedText = mention
And as simple as that, we have a mention that works just as we expect in an editable text view.