Highlighting Text Input with Jetpack Compose
We recently launched a new feature at Buffer, called Ideas. With Ideas, you can store all your best ideas, tweak them until they’re ready, and drop them straight into your Buffer queue. Now that Ideas has launched in our web and mobile apps, we have some time to share some learnings from the development of this feature. In this blog post, we’ll dive into how we added support for URL highlighting to the Ideas Composer on Android, using Jetpack Compose.
We started adopting Jetpack Compose into our app in 2021 – using it as standard to build all our new features, while gradually adopting it into existing parts of our application. We built the whole of the Ideas feature using Jetpack Compose – so alongside faster feature development and greater predictability within the state of our UI, we had plenty of opportunities to further explore Compose and learn more about how to achieve certain requirements in our app.
Within the Ideas composer, we support dynamic link highlighting. This means that if you type a URL into the text area, then the link will be highlighted – tapping on this link will then show an “Open link” pop-up, which will launch the link in the browser when clicked.
In this blog post, we’re going to focus on the link highlighting implementation and how this can be achieved in Jetpack Compose using the
For the Ideas composer, we’re utilising the
TextField composable to support text entry. This composable contains an argument,
visualTransformation, which is used to apply visual changes to the entered text.
TextField( ... visualTransformation = ... )
This argument requires a
VisualTransformation implementation which is used to apply the visual transformation to the entered text. If we look at the source code for this interface, we’ll notice a filter function which takes the content of the TextField and returns a
TransformedText reference that contains the modified text.
@Immutable fun interface VisualTransformation fun filter(text: AnnotatedString): TransformedText
When it comes to this modified text, we are required to provide the implementation that creates a new
AnnotatedString reference with our applied changes. This changed content then gets bundled in the
TransformedText type and returned back to the
TextField for composition.
So that we can define and apply transformations to the content of our
TextField, we need to start by creating a new implementation of the
VisualTransformation interface for which we’ll create a new class,
UrlTransformation. This class will implement the
VisualTransformation argument, along with taking a single argument in the form of a
Color. We define this argument so that we can pass a theme color reference to be applied within our logic, as we are going to be outside of composable scope and won’t have access to our composable theme.
class UrlTransformation( val color: Color ) : VisualTransformation
With this class defined, we now need to implement the filter function from the
VisualTransformation interface. Within this function we’re going to return an instance of the
TransformedText class – we can jump into the source code for this class and see that there are two properties required when instantiating this class.
/** * The transformed text with offset offset mapping */ class TransformedText( /** * The transformed text */ val text: AnnotatedString, /** * The map used for bidirectional offset mapping from original to transformed text. */ val offsetMapping: OffsetMapping )
Both of these arguments are required, so we’re going to need to provide a value for each when instantiating the
- text – this will be the modified version of the text that is provided to the filter function
- offsetMapping – as per the documentation, this is the map used for bidirectional offset mapping from original to transformed text
class UrlTransformation( val color: Color ) : VisualTransformation override fun filter(text: AnnotatedString): TransformedText return TransformedText( ..., OffsetMapping.Identity )
offsetMapping argument, we simply pass the
OffsetMapping.Identity value – this is the predefined default value used for the
OffsetMapping interface, used for when that can be used for the text transformation that does not change the character count. When it comes to the text argument we’ll need to write some logic that will take the current content, apply the highlighting and return it as a new
AnnotatedString reference to be passed into our
TransformedText reference. For this logic, we’re going to create a new function,
buildAnnotatedStringWithUrlHighlighting. This is going to take two arguments – the text that is to be highlighted, along with the color to be used for the highlighting.
fun buildAnnotatedStringWithUrlHighlighting( text: String, color: Color ): AnnotatedString
From this function, we need to return an
AnnotatedString reference, which we’ll create using
buildAnnotatedString. Within this function, we’ll start by using the append operation to set the textual content of the
fun buildAnnotatedStringWithUrlHighlighting( text: String, color: Color ): AnnotatedString return buildAnnotatedString append(text)
Next, we’ll need to take the contents of our string and apply highlighting to any URLs that are present. Before we can do this, we need to identify the URLs in the string. URL detection might vary depending on the use case, so to keep things simple let’s write some example code that will find the URLs in a given piece of text. This code will take the given string and filter the URLs, providing a list of URL strings as the result.
text?.split("\\s+".toRegex())?.filter word -> Patterns.WEB_URL.matcher(word).matches()
Now that we know what URLs are in the string, we’re going to need to apply highlighting to them. This is going to be in the form of an annotated string style, which is applied using the addStyle operation.
fun addStyle(style: SpanStyle, start: Int, end: Int)
When calling this function, we need to pass the
SpanStyle that we wish to apply, along with the start and end index that this styling should be applied to. We’re going to start by calculating this start and end index – to keep things simple, we’re going to assume there are only unique URLs in our string.
text?.split("\\s+".toRegex())?.filter word -> Patterns.WEB_URL.matcher(word).matches() .forEach val startIndex = text.indexOf(it) val endIndex = startIndex + it.length
Here we locate the start index by using the
indexOf function, which will give us the starting index of the given URL. We’ll then use this start index and the length of the URL to calculate the end index. We can then pass these values to the corresponding arguments for the
text?.split("\\s+".toRegex())?.filter word -> Patterns.WEB_URL.matcher(word).matches() .forEach val startIndex = text.indexOf(it) val endIndex = startIndex + it.length addStyle( start = startIndex, end = endIndex )
Next, we need to provide the
SpanStyle that we want to be applied to the given index range. Here we want to simply highlight the text using the provided color, so we’ll pass the color value from our function arguments as the color argument for the
text?.split("\\s+".toRegex())?.filter word -> Patterns.WEB_URL.matcher(word).matches() .forEach val startIndex = text.indexOf(it) val endIndex = startIndex + it.length addStyle( style = SpanStyle( color = color ), start = startIndex, end = endIndex )
With this in place, we now have a complete function that will take the provided text and highlight any URLs using the provided
fun buildAnnotatedStringWithUrlHighlighting( text: String, color: Color ): AnnotatedString return buildAnnotatedString append(text) text?.split("\\s+".toRegex())?.filter word -> Patterns.WEB_URL.matcher(word).matches() .forEach val startIndex = text.indexOf(it) val endIndex = startIndex + it.length addStyle( style = SpanStyle( color = color, textDecoration = TextDecoration.None ), start = startIndex, end = endIndex )
We’ll then need to hop back into our
UrlTransformation class and pass the result of the
buildAnnotatedStringWithUrlHighlighting function call for the
class UrlTransformation( val color: Color ) : VisualTransformation override fun filter(text: AnnotatedString): TransformedText return TransformedText( buildAnnotatedStringWithUrlHighlighting(text, color), OffsetMapping.Identity )
Now that our
UrlTransformation implementation is complete, we can instantiate this and pass the reference for the
visualTransformation argument of the
TextField composable. Here we are using the desired color from our
MaterialTheme reference, which will be used when highlighting the URLs in our
TextField( ... visualTransformation = UrlTransformation( MaterialTheme.colors.secondary) )
With the above in place, we now have dynamic URL highlighting support within our
TextField composable. This means that now whenever the user inserts a URL into the composer for an Idea, we identify this as a URL by highlighting it using a the secondary color from our theme.
In this post, we’ve learnt how we can apply dynamic URL highlighting to the contents of a
TextField composable. In the next post, we’ll explore how we added the “Open link” pop-up when a URL is tapped within the composer input area.