Subscribe for UI recipes
Animated Text Field Hint in Jetpack Compose
Build a playful text field hint animation
Text field hints give a user an idea of what they should be filling in. When the user starts typing in the field, this hint disappears to make way for typed text. This had the potential of being bad UX when a user forgets the hint for the information they are supposed to give.
Thus, a UI pattern of moving the hint text to a title above the field was born.
Today, we shall create this pattern along with a fun and playful transition, made possible by one of the latest Jetpack Compose features, Shared element transitions.
Basics
Let's start with the basics. Literally! We will be using BasicTextField
from the compose foundation libraries for this animation. This gives us a complete barebones implementation, upon which we can build our animation.
var text by remember { mutableStateOf("") }
BasicTextField(
value = text,
onValueChange = { text = it },
)
This is all you need to set up BasicTextField
and will give us this result:
Next, we need to set up some values to know the state of our text field. We want to know when the text field is in focus or has some text in it so that we can determine whether to put the hint inside or above the field.
val interactionSource = remember { MutableInteractionSource() }
val isFocused by interactionSource.collectIsFocusedAsState()
val showHintAbove by remember {
derivedStateOf {
isFocused || text.isNotEmpty()
}
}
BasicTextField(
value = text,
onValueChange = { text = it },
interactionSource = interactionSource,
)
First we create a MutableInteractionSource
that we later pass as an argument in to the BasicTextField
.
Using this interactionSource
we can collect various user events on our field. For now, we just want to know if the field is being focused on so let's collect that and store it in isFocused
.
Finally we create showHintAbove
boolean state, which is true when the field is focused on or the field is not empty.
Shared Element Transition
To style our field, BasicTextField
has a decorationBox
parameter. Using this, we can arrange and style the field however we want.
BasicTextField(
...
decorationBox = { innerTextField ->
innerTextField()
}
)
Inside decorationBox
, we receive innerTextField
that we can place and wrap with however many Composables in order to achieve our animation.
The code above gives us exactly the same result we had before, so let's add some animation to it.
For any shared element transitions, we need to first create a SharedTransitionLayout
and an AnimatedContent
inside it.
BasicTextField(
...
decorationBox = { innerTextField ->
SharedTransitionLayout {
AnimatedContent(
targetState = showHintAbove,
transitionSpec = {
EnterTransition.None togetherWith ExitTransition.None
},
label = "hintAnimation"
) { showHintAbove ->
...
}
}
}
)
We use showHintAbove
from earlier as the targetState
. For the transition, we do not need any animation so we pass in no transition.
Inside AnimatedContent
, we add a Column
that will first have our text field's title.
Column {
Box(Modifier.padding(start = 2.dp)) {
if (showHintAbove) {
TextAsIndividualLetters(
animatedContentScope = this@AnimatedContent,
text = hint,
style = exteriorHintTextStyle(),
)
}
}
...
}
Note that instead of using a regular Text
composable, we are using a super custom TextAsIndividualLetters
that splits our text into individual letters. Let's take a look at its definition:
@Composable
fun SharedTransitionScope.TextAsIndividualLetters(
animatedContentScope: AnimatedContentScope,
text: String,
modifier: Modifier = Modifier,
style: TextStyle = TextStyle(),
) {
Row(modifier) {
text.forEachIndexed { index, letter ->
Text(
text = "$letter",
modifier = Modifier.sharedBounds(
sharedContentState = rememberSharedContentState(key = "hint_$index"),
animatedVisibilityScope = animatedContentScope,
boundsTransform = { _, _ ->
spring(
dampingRatio = Spring.DampingRatioLowBouncy,
stiffness = 25f * (text.length - index),
)
}
),
style = style,
)
}
}
}
This places each letter in individual Text
Composables inside a Row
. Each Text
has the sharedBounds
modifier applied to it with the letter's index used in the key. This modifier will handle animating the text and its bounds.
To achieve the staggered timing of the animation of the letters, we need to define the boundsTransform
. We return a spring
in which we tweak the stiffness
so that the first letters have a higher value than the last ones.
Back to our Column
, we can continue and render the text field box itself.
Box(
modifier = Modifier
.sharedElement(
rememberSharedContentState(key = "input"),
animatedVisibilityScope = this@AnimatedContent
)
.defaultMinSize(minWidth = 300.dp)
.background(
color = MaterialTheme.colorScheme.surface,
shape = RoundedCornerShape(10.dp)
)
.border(
width = Dp.Hairline,
shape = RoundedCornerShape(10.dp),
color = MaterialTheme.colorScheme.onSurface.copy(
alpha = .3f
)
)
.padding(horizontal = 10.dp, vertical = 8.dp),
contentAlignment = Alignment.CenterStart
) {
if (!showHintAbove) {
TextAsIndividualLetters(
animatedContentScope = this@AnimatedContent,
text = hint,
style = interiorHintTextStyle(),
)
}
innerTextField()
}
Here, we can create a Box
that we can style however we want. Here I just used a simple background and a border. But the important part is that we need to set the sharedElement
modifier on the whole thing. We do this in order to avoid creating multiple text fields while animating.
Inside the Box
we add the text hint that will be seen inside the text field. To do this, we use the same TextAsIndividualLetters
Composable from earlier, but with a different text style. This will appear when showHintAbove
is false.
On top of that, we can render innerTextField
, which will render the text as the user is typing.
And with that, we get our desired animation.
Full code available here.
Thanks for reading and good luck!