Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import androidx.compose.foundation.verticalScroll
import androidx.compose.runtime.Composable
import androidx.compose.runtime.key
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.layout.layout
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalLayoutDirection
import com.revenuecat.purchases.InternalRevenueCatAPI
Expand All @@ -38,6 +38,25 @@ internal data class WorkflowHeaderPresentation(
val role: WorkflowHeaderTransitionRole,
)

/**
* Fades a header, reading the animation in the layout phase so frames cost no recomposition.
*
* Compose does not hit-test unplaced nodes, whereas an alpha 0 node stays interactive while being
* excluded from rendering and from the accessibility tree.
*/
internal fun Modifier.workflowHeaderFade(
role: WorkflowHeaderTransitionRole,
transitionState: WorkflowTransitionState,
): Modifier = layout { measurable, constraints ->
val headerAlpha = headerAlpha(role, transitionState.animatable.value)
val placeable = measurable.measure(constraints)
layout(placeable.width, placeable.height) {
if (headerAlpha > 0f) {
placeable.placeWithLayer(x = 0, y = 0) { alpha = headerAlpha }
}
}
}

internal fun headerAlpha(role: WorkflowHeaderTransitionRole, progress: Float): Float = when (role) {
WorkflowHeaderTransitionRole.ENTERING -> progress
WorkflowHeaderTransitionRole.LEAVING -> 1f - progress
Expand Down Expand Up @@ -97,11 +116,7 @@ internal fun LoadedWorkflowPaywall(
onClick = headerOnClick,
modifier = Modifier
.fillMaxWidth()
// Read animatable.value inside graphicsLayer (draw phase), like workflowTransition,
// so the fade stays in lock-step with the slide without recomposing every frame.
.graphicsLayer {
alpha = headerAlpha(headerPresentation.role, transitionState.animatable.value)
},
.workflowHeaderFade(headerPresentation.role, transitionState),
)
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
package com.revenuecat.purchases.ui.revenuecatui.components

import androidx.compose.animation.core.Animatable
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.ui.Modifier
import androidx.compose.ui.test.click
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onRoot
import androidx.compose.ui.test.performTouchInput
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.revenuecat.purchases.ui.revenuecatui.workflow.NavigationDirection
import org.assertj.core.api.Assertions.assertThat
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith

/**
* A header that outlives its transition must not receive touches: at alpha 0 a node stays
* hit-testable while being invisible to both screenshots and the accessibility tree.
*/
@RunWith(AndroidJUnit4::class)
internal class WorkflowHeaderFadeTest {

@get:Rule
val composeTestRule = createComposeRule()

private fun transitionStateAt(progress: Float) = WorkflowTransitionState.SlideInOut(
animatingFromStepId = "from",
animatingToStepId = "to",
animatingDirection = NavigationDirection.FORWARD,
animatable = Animatable(progress),
)

private fun clickThroughHeader(
role: WorkflowHeaderTransitionRole,
progress: Float,
): Pair<Int, Int> {
var headerClicks = 0
var stepClicks = 0
composeTestRule.setContent {
Box(Modifier.fillMaxSize()) {
Box(Modifier.fillMaxSize().clickable { stepClicks++ })
Box(
Modifier
.fillMaxSize()
.workflowHeaderFade(role, transitionStateAt(progress))
.clickable { headerClicks++ },
)
}
}
composeTestRule.onRoot().performTouchInput { click() }
composeTestRule.waitForIdle()
return headerClicks to stepClicks
}

@Test
fun `fully faded leaving header does not intercept touches`() {
val (headerClicks, stepClicks) = clickThroughHeader(
role = WorkflowHeaderTransitionRole.LEAVING,
progress = 1f,
)

assertThat(headerClicks).isZero()
assertThat(stepClicks).isOne()
}

@Test
fun `partially faded leaving header still intercepts touches`() {
val (headerClicks, stepClicks) = clickThroughHeader(
role = WorkflowHeaderTransitionRole.LEAVING,
progress = 0.5f,
)

assertThat(headerClicks).isOne()
assertThat(stepClicks).isZero()
}

@Test
fun `stable header intercepts touches`() {
val (headerClicks, stepClicks) = clickThroughHeader(
role = WorkflowHeaderTransitionRole.STABLE,
progress = 1f,
)

assertThat(headerClicks).isOne()
assertThat(stepClicks).isZero()
}

@Test
fun `entering header at the start of its fade does not intercept touches`() {
val (headerClicks, stepClicks) = clickThroughHeader(
role = WorkflowHeaderTransitionRole.ENTERING,
progress = 0f,
)

assertThat(headerClicks).isZero()
assertThat(stepClicks).isOne()
}
}