Apply ripple shape clip on a sibling Box to avoid clipping content#3395
Conversation
…#3324) ### Motivation Resolves #3321 The ripple effect in Paywalls V2 ignores rounded corners and extends into the component's margin area. This happens because `Modifier.clickable()` is applied before `StackComponentView` applies `.clip(shape)` and margin internally — so the ripple fills the entire rectangular area including margins. ### Description Introduces an `interactionModifier` parameter on `StackComponentView` that gets threaded down to `MainStackComponent` and applied **after** `.clip(composeShape)` in all 4 code paths (standard, video background, nested badge, overlay). This ensures the ripple is bounded by the component's actual shape. Updated all callers: - **ButtonComponentView** — moves `clickable` into `interactionModifier` - **PackageComponentView** — moves conditional `clickable` into `interactionModifier`, removes unused `conditional` import - **TabControlButtonView** — moves `clickable` into `interactionModifier` Also adds a `ButtonComponent` to the bless sample paywall (#8) to make it easier to reproduce and verify the fix visually. **Testing:** All existing unit tests pass — StackComponentViewTests, StackComponentViewWindowTests, ButtonComponentViewTests, PackageComponentViewTests, TabsComponentViewTests. Verified visually via the updated sample paywall. <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Medium Risk** > Touches the core `StackComponentView` container and changes where click/interaction modifiers are applied, which could subtly affect touch targets and interaction behavior across multiple paywall components. Functionality is UI-scoped (ripple/press feedback) with no data/auth implications. > > **Overview** > Fixes Compose ripple/press feedback on paywall components so it respects rounded corners and does not extend into margins by adding an `interactionModifier` to `StackComponentView` and applying it **after** `clip(shape)` across all rendering paths (standard, video background, nested badge, overlay/badge variants). > > Updates callers (`ButtonComponentView`, `PackageComponentView`, `TabControlButtonView`) to pass their `Modifier.clickable` via `interactionModifier` (instead of on the outer `modifier`), and tweaks the paywall tester sample (#8) to use a `ButtonComponent` to make the issue easy to reproduce/verify. > > <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit c7368b0. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot).</sup> <!-- /CURSOR_SUMMARY --> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…x/ripple_clipping # Conflicts: # ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/button/ButtonComponentView.kt # ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/pkg/PackageComponentView.kt # ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/stack/StackComponentView.kt # ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/components/tabs/TabControlButtonView.kt
Replaces the outer `.clip(composeShape)` added on the clickable layout node in the plain stack path — that clip also clipped descendants, so nested children that intentionally extend outside the parent (badge offsets, overlay icons) disappeared at the rounded edge. Refactors `interactionModifier: Modifier` into declarative `onClick`/`enabled`/`interactionSource` parameters on `StackComponentView` so `MainStackComponent` can split the gesture from the indication. The plain path now wraps in a `Box` containing the stack (with `clickable(indication = null)`, no clip) and a sibling `matchParentSize().padding(margin).clip(composeShape).indication(...)` Box that draws the ripple inside the rounded shape. Children render unclipped. Pre-existing render paths (video background, nested badge, overlay) keep their existing `.clip(composeShape)` behavior — they swap `interactionModifier` for an equivalent `clickable` built from the new parameters. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
A regression guard: if a future change reintroduces a graphics-layer clip on the clickable stack's layout node, the inner stack's shadow will be visibly clipped at the outer's rounded corners in the IDE preview / Paparazzi HTML report. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
📸 Snapshot Test3 added, 588 unchanged
🛸 Powered by Emerge Tools |
…ix/ripple-shape-via-sibling-layer
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## main #3395 +/- ##
=======================================
Coverage 79.45% 79.45%
=======================================
Files 362 362
Lines 14539 14539
Branches 1976 1976
=======================================
Hits 11552 11552
Misses 2190 2190
Partials 797 797 ☔ View full report in Codecov by Sentry. 🚀 New features to boost your workflow:
|
vegaro
left a comment
There was a problem hiding this comment.
I tested it and it works great. Just one question on modifier vs Modifier
| .then(borderModifier), | ||
| ) { | ||
| stack( | ||
| modifier, |
There was a problem hiding this comment.
Should this be Modifier? the modifier is already being applied to WithOptionalBackgroundOverlay. Like 813 has a similar case and applies Modifier.
There was a problem hiding this comment.
So... I think you're right, however, I think we were doing that before the changes in this PR... But not sure why 😕. Going to test that change anyway with Emerge snapshots and see if there are any changes and think in case there might be any unintended consequences.
There was a problem hiding this comment.
I can confirm before we were passing it again and applying the modifier twice 🥴 So... I guess now we need to decide whether to apply the change in behavior or not... If we want to, I would suggest doing it on a different PR. For context, I added some new previews to track this. This is how those looks before this PR:

And this is how it looks if I change this to not apply the modifier twice:

cc @RevenueCat/paywallsengine in case we want to fix this. For now, I'm planning to leave as is in this PR. Lmk what you think!!
There was a problem hiding this comment.
oh damn, I wasn't expecting it to affect that much!
I would leave it as is, if we were already doing it, and fix it in another moment
| ) { | ||
| WithOptionalBackgroundOverlay(state, background = backgroundStyle) { | ||
| stack(Modifier.then(innerShapeModifier)) | ||
| stack(modifier, Modifier.then(innerShapeModifier)) |
| ) { | ||
| WithOptionalBackgroundOverlay(state, background = backgroundStyle) { | ||
| stack(borderModifier.then(innerShapeModifier)) | ||
| stack(modifier, borderModifier.then(innerShapeModifier)) |
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, have a team admin enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit e6d502f. Configure here.
| enabled = state.selectedPackageInfo?.uniqueId != style.uniqueId, | ||
| ) { | ||
| modifier = modifier, | ||
| enabled = state.selectedPackageInfo?.uniqueId != style.uniqueId, |
There was a problem hiding this comment.
Unconditional state read causes unnecessary recompositions for non-selectable packages
Low Severity
The enabled expression state.selectedPackageInfo?.uniqueId != style.uniqueId is evaluated unconditionally for every package, including non-selectable ones where onStackClick is null and enabled has no effect. In the old code, this state read was inside conditional(style.isSelectable) so it only executed for selectable packages. Now, reading state.selectedPackageInfo for non-selectable packages creates an unnecessary Compose state subscription, triggering recomposition of those packages whenever the user selects a different package.
Reviewed by Cursor Bugbot for commit e6d502f. Configure here.
**This is an automatic release.** ## RevenueCat SDK ### ✨ New Features * Add optional support for setting obfuscated account id to product changes (RevenueCat#3428) via Mark Villacampa (@MarkVillacampa) ## RevenueCatUI SDK ### Paywallv2 #### ✨ New Features * Add slide transition to workflow paywalls (RevenueCat#3418) via Cesar de la Vega (@vegaro) * Workflow state & ViewModel infrastructure (RevenueCat#3416) via Cesar de la Vega (@vegaro) #### 🐞 Bugfixes * Fix paywall layout direction for RTL locale overrides (PWENG-39) (RevenueCat#3425) via Monika Mateska (@MonikaMateska) * Apply ripple shape clip on a sibling Box to avoid clipping content (RevenueCat#3395) via Toni Rico (@tonidero) ### 🔄 Other Changes * build(deps): bump fastlane-plugin-revenuecat_internal from `21e02ec` to `af7bb5c` (RevenueCat#3442) via dependabot[bot] (@dependabot[bot]) * Abstract workflow page transition animation behind sealed class (RevenueCat#3430) via Cesar de la Vega (@vegaro) * Add `single_step_fallback_id` field to `PublishedWorkflow` (RevenueCat#3436) via Cesar de la Vega (@vegaro) * build(deps): bump fastlane-plugin-revenuecat_internal from `2d11430` to `21e02ec` (RevenueCat#3429) via dependabot[bot] (@dependabot[bot]) * Generalize `PaywallComponentsScaffold` for workflow reuse (RevenueCat#3417) via Cesar de la Vega (@vegaro) * perf: pre-warm workflow paywall step states off-thread (RevenueCat#3420) via Cesar de la Vega (@vegaro) * Update baseline profiles (RevenueCat#3427) via RevenueCat Git Bot (@RCGitBot) * build(deps): bump fastlane-plugin-revenuecat_internal from `d24ab26` to `2d11430` (RevenueCat#3426) via dependabot[bot] (@dependabot[bot]) * Replace unauthenticated SDKMAN install with SHA-pinned orb command (RevenueCat#3407) via Rick (@rickvdl) * Auto load paywall in paywall tester via local.properties (RevenueCat#3405) via Cesar de la Vega (@vegaro) <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Low Risk** > Low risk: this is a version/release cut that mainly updates version strings, changelogs, and doc deployment targets with no functional logic changes beyond version identifiers. > > **Overview** > Cuts the `10.4.0` release by removing `-SNAPSHOT` across the project (core `VERSION_NAME`, `Config.frameworkVersion`, sample/test app dependency versions, and the root `.version` file). > > Updates release collateral and publishing to point at `10.4.0`, including changelogs (`CHANGELOG.md`/`CHANGELOG.latest.md`), docs redirect (`docs/index.html`), and the CircleCI `docs-deploy` S3 sync path (from `10.4.0-SNAPSHOT` to `10.4.0`). > > <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit f7b3604. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot).</sup> <!-- /CURSOR_SUMMARY -->


Summary
.clip(composeShape)on the click target so the ripple respects rounded corners. That fix uses a graphics-layer clip, which also crops descendants — stacks with nested children that intentionally extend outside the parent (badges with offsets, drop shadows, decorative elements) get visibly clipped during press.onStackClick: (() -> Unit)?,enabled: Boolean,interactionSource: MutableInteractionSource?parameters onStackComponentView, threaded throughStackWithOverlaidBadge,StackWithLongEdgeToEdgeBadge,StackWithShortEdgeToEdgeBadge, andMainStackComponent. This letsMainStackComponentsplit the click gesture from the indication.Box {}containing the stack withclickable(indication = null)(gesture only, no clip on the stack) and a siblingBox(Modifier.matchParentSize().padding(margin).clip(composeShape).indication(source, LocalIndication.current))that draws the shape-clipped ripple. Children render unclipped..clip(composeShape)on the click target — only the click wiring is swapped over to the newonStackClickparameter. The overflow-clip fix therefore only applies to the plain render path; this limitation is documented in theonStackClickkdoc so future callers know what to expect.ButtonComponentView,PackageComponentView,TabControlButtonView) to use the new declarative API.StackComponentView_Preview_Clickable_With_Overflowing_Child_Shadow) as a regression guard for the overflowing-child shadow case.Test plan
./gradlew :ui:revenuecatui:testDefaultsBc8DebugUnitTest(full suite + Paparazziverify) — green./gradlew detektAll— green🤖 Generated with Claude Code
Note
Medium Risk
Touches core Compose layout/click/semantics for paywall components, so regressions could affect tap handling, accessibility, or visual clipping across many paywalls despite being UI-only.
Overview
Fixes clickable
StackComponentViewripple rendering so the ripple remains clipped to the stack shape without clipping overflowing children (e.g. shadows/badges) by splitting gesture handling and indication onto separate sibling nodes.Introduces a new declarative click API on
StackComponentView(onStackClick,enabled,interactionSource) and updates existing button/tab/package components to use it instead of applyingModifier.clickableexternally; adds Compose previews as regression guards. The paywall-tester sample paywall is also updated to use aButtonComponentfor its CTA.Reviewed by Cursor Bugbot for commit 2471d57. Bugbot is set up for automated code reviews on this repo. Configure here.