Skip to content

Apply ripple shape clip on a sibling Box to avoid clipping content#3395

Merged
tonidero merged 23 commits into
mainfrom
fix/ripple-shape-via-sibling-layer
Apr 30, 2026
Merged

Apply ripple shape clip on a sibling Box to avoid clipping content#3395
tonidero merged 23 commits into
mainfrom
fix/ripple-shape-via-sibling-layer

Conversation

@tonidero

@tonidero tonidero commented Apr 28, 2026

Copy link
Copy Markdown
Contributor

Summary

  • Follow-up to Fix ripple effect ignoring component shape and extending into margins #3324, which fixed the original ripple-vs-margin issue (Ripple effect ignores component shape and extends into margins #3321) by adding .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.
  • Introduces declarative onStackClick: (() -> Unit)?, enabled: Boolean, interactionSource: MutableInteractionSource? parameters on StackComponentView, threaded through StackWithOverlaidBadge, StackWithLongEdgeToEdgeBadge, StackWithShortEdgeToEdgeBadge, and MainStackComponent. This lets MainStackComponent split the click gesture from the indication.
  • Plain stack path: wraps in a Box {} containing the stack with clickable(indication = null) (gesture only, no clip on the stack) and a sibling Box(Modifier.matchParentSize().padding(margin).clip(composeShape).indication(source, LocalIndication.current)) that draws the shape-clipped ripple. Children render unclipped.
  • Other render paths (video background, nested badge, overlay) keep their existing .clip(composeShape) on the click target — only the click wiring is swapped over to the new onStackClick parameter. The overflow-clip fix therefore only applies to the plain render path; this limitation is documented in the onStackClick kdoc so future callers know what to expect.
  • Updates the three call sites (ButtonComponentView, PackageComponentView, TabControlButtonView) to use the new declarative API.
  • Adds a Compose preview (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 + Paparazzi verify) — green
  • ./gradlew detektAll — green
  • Manual: build a paywall whose plain stack has a nested child positioned to extend outside the parent (offset, negative padding, or drop shadow); tap the parent — overflowing child must remain fully visible during the press, and the ripple must follow the parent's rounded corners

🤖 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 StackComponentView ripple 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 applying Modifier.clickable externally; adds Compose previews as regression guards. The paywall-tester sample paywall is also updated to use a ButtonComponent for its CTA.

Reviewed by Cursor Bugbot for commit 2471d57. Bugbot is set up for automated code reviews on this repo. Configure here.

bahdaniec and others added 7 commits April 15, 2026 12:31
…#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>
@tonidero tonidero added the pr:fix A bug fix label Apr 28, 2026
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>
@emerge-tools

emerge-tools Bot commented Apr 28, 2026

Copy link
Copy Markdown

📸 Snapshot Test

3 added, 588 unchanged

Name Added Removed Modified Renamed Unchanged Errored Approval
TestPurchasesUIAndroidCompatibility
com.revenuecat.testpurchasesuiandroidcompatibility
3 0 0 0 331 0 ⏳ Needs approval
TestPurchasesUIAndroidCompatibility Paparazzi
com.revenuecat.testpurchasesuiandroidcompatibility.paparazzi
0 0 0 0 257 0 N/A

🛸 Powered by Emerge Tools

@tonidero tonidero marked this pull request as ready for review April 28, 2026 14:12
@tonidero tonidero requested a review from a team as a code owner April 28, 2026 14:12
@tonidero tonidero marked this pull request as draft April 28, 2026 14:25
@codecov

codecov Bot commented Apr 28, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 79.45%. Comparing base (1ace9ec) to head (2471d57).
⚠️ Report is 4 commits behind head on main.

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.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@tonidero tonidero marked this pull request as ready for review April 29, 2026 10:05
@tonidero tonidero requested review from a team April 29, 2026 10:05
@vegaro vegaro self-requested a review April 29, 2026 17:08

@vegaro vegaro left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I tested it and it works great. Just one question on modifier vs Modifier

.then(borderModifier),
) {
stack(
modifier,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should this be Modifier? the modifier is already being applied to WithOptionalBackgroundOverlay. Like 813 has a similar case and applies Modifier.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:
image

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

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!!

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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))

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same Modifier question.

) {
WithOptionalBackgroundOverlay(state, background = backgroundStyle) {
stack(borderModifier.then(innerShapeModifier))
stack(modifier, borderModifier.then(innerShapeModifier))

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

and here as well

@cursor cursor Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Fix All in Cursor

❌ 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,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit e6d502f. Configure here.

@tonidero tonidero requested a review from vegaro April 30, 2026 11:15
@tonidero tonidero added this pull request to the merge queue Apr 30, 2026
Merged via the queue into main with commit 696d39c Apr 30, 2026
35 of 36 checks passed
@tonidero tonidero deleted the fix/ripple-shape-via-sibling-layer branch April 30, 2026 14:59
matteinn pushed a commit to matteinn/purchases-android that referenced this pull request Jun 5, 2026
**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 -->
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants