Skip to content
article

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

Dita Anggraini@dita_kotlin

September 11, 2026 · 3 min read

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

11

Sign in to join the conversation.

Sign in to comment
Anoop Menon@anoop_flutter#4 in Thumb Zone
Pwoli post! Machane all of this works in Flutter too, viewInsets, SafeArea, same idea 😅 native is not required for good UX
4 net votes
Dita Anggraini@dita_kotlin#1 in Thumb Zone
Can, yes. Does, on a 3GB phone at 60fps? Show me the jank numbers dong. Every Flutter app I profiled on this phone dropped frames on the payment sheet animation
5 net votes
Anoop Menon@anoop_flutter#4 in Thumb Zone
Ayyo the jank numbers. Ok ok, I will profile our app on my cousin's phone this weekend and come back with numbers. If Flutter wins you buy me Kerala parotta
4 net votes
Dita Anggraini@dita_kotlin#1 in Thumb Zone
Deal. If native wins you write a post called "Dita was right"
4 net votes
Hamza Butt@hamza_flutter#3 in Thumb Zone
As a Flutter dev who defends Flutter in every thread: points 5 and 6 are platform independent and the most important part. Idempotency key at screen open, words on loading states. Yaar this should be standard everywhere
6 net votes
Tunde Adebayo@tunde_ade#2 in Thumb Zone
the "cancel was where the thumb lands" detail is so real. we had "delete account" in that spot once. once
2 net votes
Giulia Bellandi@giulia_designs#8 in Thumb Zone
Designer confession: the Pay button at the top right because it looked nice in Figma is 100% something I have done ✨ I am sorry. Moving all our pay buttons this sprint
4 net votes
Emeka Nwosu@emeka_golang1 rep in Thumb Zone
Point 5 is my whole life. Thank you for saying "the UI is not enough" 🙏
6 net votes
Nurul Aisyah@nurul_sql#8 in Thumb Zone
Dita this is so good lah. Our bank app team needs point 6, our app shows a spinner for 20 seconds with zero text, confirm people pay twice
9 net votes
Brian Kiprono@kiprono_codes1 rep in Thumb Zone
Point 6 "Do not pay again" text, we added the same on our M-Pesa flow last month. Support calls dropped too. Words are cheaper than refunds
7 net votes
Dita Anggraini@dita_kotlin#1 in Thumb Zone
"Words are cheaper than refunds." Stealing that for our guild meeting
5 net votes