ANTIGRAVITY LABJP
Articles/App Development
App Development/2026-06-17Advanced

Stop Dialogs From Stacking: One Gate for Paywalls, Review Prompts, and Rewarded Ads

A field record of curing the bug where a paywall, a review prompt, and a rewarded ad all surface at once, fixed with a single priority-based modal gate. I let an Antigravity agent sweep up the scattered show() calls, but kept the display policy in my own hands.

antigravity369android20monetization27uxkotlin5

Premium Article

A rewarded ad finished, and the very next instant a review prompt and a paywall both jumped onto the screen. It happened in one of my own wallpaper apps (over 50 million cumulative downloads across iOS and Android) during a staged rollout. To the user it reads as one thing: "I was made to watch an ad, and then double-charged with two stacked requests."

The cause was mundane. The show() calls that present dialogs were scattered all over the codebase, and no single place knew what was currently on screen. The ad callback fired independently of the review-prompt timer, and both decided, correctly in isolation, that now was a fine time to appear. Each rule was right; combined, they were broken.

This article walks through how I cured that collision with a single priority-based coordinator I call ModalGate. The code is Android (Kotlin), but the idea is identical in SwiftUI or React Native. I also cover the line I drew: hand the sweep of scattered calls to an Antigravity agent, and keep the decision of which modal wins for myself.

The root cause is that nobody knows what is already on screen

Apps with colliding modals share a structure: the decision to present is scattered across the callers.

  • Paywall: the moment a gated feature is touched, PaywallDialog.show()
  • Review prompt: once launch count crosses a threshold, ReviewInduction.maybeShow()
  • Rewarded ad: after the back button or a specific action, RewardedIntersDialog.show()

Each only checks its own "am I allowed to appear" condition. The invariant of at most one modal on screen at a time is written nowhere, so the second one stacks on the first without noticing it. Worse, priority is implicit. You want the paywall, which drives revenue, to win, but if the review-prompt timer happens to fire first, a low-value modal eats the display slot of a high-value one.

There is one fix: route every modal through a single coordinator. No one calls show() directly. The gate decides, in one place, whether something is on screen and which request goes next.

A minimal central gate: the single-display invariant and priority

Here is the core ModalGate. It does exactly three things: show one modal at a time, show higher priority first, and present the next one once the current closes.

// Higher weight wins. Put revenue-critical modals on top.
enum class ModalPriority(val weight: Int) {
    PAYWALL(100),          // feature gating / purchase funnel
    REWARDED_AD(60),       // rewarded ad
    REVIEW_PROMPT(20),     // store review prompt
}
 
// A request to present. `show` defers the actual presentation as a lambda.
data class ModalRequest(
    val id: String,
    val priority: ModalPriority,
    val show: (onDismiss: () -> Unit) -> Unit,
)
 
object ModalGate {
    private val pending = mutableListOf<ModalRequest>()
    private var active: ModalRequest? = null
    private var canPresent = false   // is it safe to present in the foreground?
 
    // Callers use only this. Calling show() directly is forbidden.
    @MainThread
    fun enqueue(request: ModalRequest) {
        // guard against double-enqueue of the same kind
        if (pending.any { it.id == request.id } || active?.id == request.id) return
        pending.add(request)
        pump()
    }
 
    @MainThread
    fun setPresentable(value: Boolean) {
        canPresent = value
        if (value) pump()
    }
 
    private fun pump() {
        if (!canPresent || active != null) return
        val next = pending.maxByOrNull { it.priority.weight } ?: return
        pending.remove(next)
        active = next
        next.show { onDismissed(next) }
    }
 
    private fun onDismissed(request: ModalRequest) {
        if (active?.id == request.id) active = null
        pump()  // on to the next one
    }
}

The caller changes like this. Stop hitting show() directly; just enqueue a request.

// Before: each site presented on its own
PaywallDialog(activity).show()
 
// After: via the gate. Whether and when it shows is the gate's call.
ModalGate.enqueue(
    ModalRequest(id = "paywall", priority = ModalPriority.PAYWALL) { onDismiss ->
        PaywallDialog(activity).apply { setOnDismissListener { onDismiss() } }.show()
    }
)

"Two at once" can no longer happen by construction. If the review prompt and the paywall are enqueued in the same instant, the gate presents the higher-weight paywall first and only then decides whether to show the review prompt. The moment priority is explicit in code, the implicit tug-of-war disappears.

Thank you for reading this far.

Continue Reading

What follows includes implementation code, benchmarks, and practical content we hope you'll find useful. This site runs without ads — server and development costs are supported entirely by members like you. If it's been helpful, we'd be truly grateful for your support.

WHAT YOU'LL LEARN
Collapse modal collisions, like a paywall firing right on top of a review prompt, into one priority-ordered gate you can drop into your app today
Carry over the exact code for the three places a naive gate breaks: async ad callbacks, Activity lifecycle, and a duplicated back-button ad gate
Take home a clear line: hand the mechanical sweep of scattered show() calls to an agent, and keep the display-priority decisions with the human
Secure payment via Stripe · Cancel anytime

Unlock This Article

Get full access to the rest of this article. Buy once, read anytime. This site is ad-free — your support goes directly toward keeping it running.

or
Unlock all articles with Membership →
Share

Thank You for Reading

Antigravity Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.

  • Copy-paste ready implementation code
  • New advanced guides published daily
  • $5/mo or $10 for lifetime access
View Membership →

Related Articles

App Dev2026-04-05
Antigravity × Compose Multiplatform: The Complete Guide to Shared UI Across iOS, Android, and Desktop in 2026
A deep-dive production guide to building high-quality cross-platform apps with Compose Multiplatform and Antigravity IDE. Covers architecture, expect/actual patterns, Desktop support, automated testing, and full release pipelines for iOS, Android, and JVM Desktop.
App Dev2026-03-29
Building Android UI with Antigravity and Jetpack Compose: A Practical Guide
Learn how to supercharge your Android UI development by combining Antigravity IDE with Jetpack Compose. From AI-generated composables and Material 3 theming to performance-optimized lists and type-safe navigation.
App Dev2026-05-17
Where Is the Source of Truth for Billing State? Designing the ad-free Pattern with Antigravity
A proven pattern from a 50 million download app: centralizing billing state with AdFreeManager, BillingManager, and ModalGate — designed and implemented using Antigravity IDE.
📚RECOMMENDED BOOKS
Build a Large Language Model (From Scratch)
Sebastian Raschka
LLM Dev
Prompt Engineering for LLMs
Berryman & Ziegler
Prompting
AI Engineering
Chip Huyen
AI Eng
* Contains affiliate links
See all →