We moved the Pay button to the bottom and abandoned payments dropped 11%. Payment UX notes for cheap Android phones
Thumb reach, the keyboard that hides your button, the double tap that pays twice, and measuring jank on a Rp1,5 juta phone
Contents
Our test phone is a Rp1,5 juta Android: 6.5 inch 720p screen, 3GB RAM, an old mid-range chip. Most of our users have something like it. Most of our designers have a recent iPhone. Ya ampun. Here is what we changed in our payment flow, with numbers.
1. The button moved, abandonment dropped
Our Pay button sat at the top right, under the amount, because it looked nice in Figma. On a 6.5 inch phone held in one hand on a moving TransJakarta bus, nobody reaches the top right. We moved it to a sticky bottom bar.
- Abandoned payment screens: -11% in the A/B test (two weeks, about 180k sessions)
- Accidental taps on Cancel, which used to sit exactly where the thumb rests: almost gone
2. The keyboard hides your button
The user types the amount, the keyboard comes up, the Pay button is now under the keyboard. They close the keyboard, lose focus, get confused. In Compose, make the bottom bar respect the IME insets:
@Composable
fun PayBar(amountLabel: String, enabled: Boolean, onPay: () -> Unit) {
var submitted by rememberSaveable { mutableStateOf(false) }
Surface(
tonalElevation = 3.dp,
modifier = Modifier
.fillMaxWidth()
.navigationBarsPadding()
.imePadding(),
) {
Button(
onClick = {
if (!submitted) {
submitted = true
onPay()
}
},
enabled = enabled && !submitted,
modifier = Modifier
.fillMaxWidth()
.padding(16.dp)
.heightIn(min = 56.dp),
) {
Text(if (submitted) "Processing..." else "Pay $amountLabel")
}
}
}
And call enableEdgeToEdge() in the activity so the insets actually reach you. Without it, imePadding() may do nothing useful and you will think Compose is broken. It is not. Your window is.
3. The right keyboard
KeyboardOptions(keyboardType = KeyboardType.Number) for amounts, KeyboardType.NumberPassword for PINs. A full QWERTY keyboard for a 6 digit PIN is just mean.
4. Tap targets
48dp minimum, like the Material guidelines say. Our "change payment method" link was 32dp tall. In usability testing two people tapped Pay while trying to change the method. Bisa bayangin?
5. The double tap that pays twice
On a slow network the first tap seems to do nothing, so people tap again. The submitted flag above blocks the second tap in the UI. But the UI is not enough: the process can die and come back, the user can go back and forward. So we generate the idempotency key when the payment screen opens and keep it in the ViewModel's SavedStateHandle. Every retry sends the same key. The backend does the rest.
6. Loading states with words
A spinner for 8 seconds with no text means the user kills the app, we do not know if they paid, and a support ticket arrives. After 3 seconds we now show "Waiting for the bank...". After 10: "This is taking longer than usual. Do not pay again, we will notify you." Tickets asking "did I pay?" dropped by about a third.
7. Measure jank on the cheap phone
We run Macrobenchmark on the test phone in CI and added a Baseline Profile. Cold start went from 2.9s to 1.9s on that device. On the designers' iPhones there was nothing to measure, which is exactly the problem kok.
None of these changes needed a redesign. They needed somebody to hold the app with one hand on a bus. Coba deh.
Written by
Dita Anggraini
Android engineer, Jakarta. Kotlin, Compose, and apps that must work on a Rp1,5 juta phone on a moving TransJakarta bus.
Get the best of #thumb_zone weekly
One email each Monday with the most useful posts. No spam; unsubscribe in one click.
Comments
Sign in to join the conversation.
Sign in to comment