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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@ Unreleased

Rebuilt `continuityRetainedStateRegistry` as a common `lifecycleRetainedStateRegistry` and made `ViewModel` an implementation detail of it.

### Misc:

- Fix a crash when using `AndroidPredictiveBackNavDecorator` and having previously called `resetRoot()` with `restoreState=false`.

0.29.1
------

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -205,7 +205,8 @@ private fun <R : Record> buildCircuitContentProviders(
previousContentProviders.keys.filterNot {
it in activeRecordKeys ||
it in recordKeys ||
latestBackStack.isRecordReachable(key = it, depth = 1, includeSaved = true)
// Depth of 2 to exclude records that are late at leaving the composition.
latestBackStack.isRecordReachable(key = it, depth = 2, includeSaved = true)
}
onDispose {
// Only remove the keys that are no longer in the backstack or composition.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -179,8 +179,8 @@ internal class AndroidPredictiveBackNavDecorator<T : NavArgument>(
) {
Box(
Modifier.predictiveBackMotion(
enabled = showPrevious,
isSeeking = isSeeking,
enabled = { showPrevious },
isSeeking = { isSeeking },
shape = MaterialTheme.shapes.extraLarge,
elevation = if (SharedElementTransitionScope.isTransitionActive) 0.dp else 6.dp,
transition = transition,
Expand All @@ -206,8 +206,8 @@ private val DecelerateEasing = CubicBezierEasing(0f, 0f, 0f, 1f)
* https://developer.android.com/design/ui/mobile/guides/patterns/predictive-back
*/
private fun Modifier.predictiveBackMotion(
enabled: Boolean,
isSeeking: Boolean,
enabled: () -> Boolean,
isSeeking: () -> Boolean,
shape: Shape,
elevation: Dp,
transition: Transition<EnterExitState>,
Expand All @@ -217,14 +217,14 @@ private fun Modifier.predictiveBackMotion(
val p = progress()
val o = offset()
// If we're at progress 0f, skip setting any parameters
if (!enabled || p == 0f || !o.isValid()) return@graphicsLayer
if (!enabled() || p == 0f || !o.isValid()) return@graphicsLayer

sharedElementTransition(isSeeking, shape, elevation, transition, p, o)
}

// https://developer.android.com/design/ui/mobile/guides/patterns/predictive-back#shared-element-transition
private fun GraphicsLayerScope.sharedElementTransition(
isSeeking: Boolean,
isSeeking: () -> Boolean,
shape: Shape,
elevation: Dp,
transition: Transition<EnterExitState>,
Expand Down Expand Up @@ -266,7 +266,7 @@ private fun GraphicsLayerScope.sharedElementTransition(
(maxTranslationY - marginY).coerceAtLeast(0f),
)

if (!isSeeking) {
if (!isSeeking()) {
alpha = lerp(1f, 0f, progress.absoluteValue)
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
// Copyright (C) 2025 Slack Technologies, LLC
// SPDX-License-Identifier: Apache-2.0
package com.slack.circuitx.gesturenavigation

import androidx.activity.ComponentActivity
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.text.BasicText
import androidx.compose.material.ExperimentalMaterialApi
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.test.assertTextEquals
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import com.slack.circuit.backstack.rememberSaveableBackStack
import com.slack.circuit.foundation.Circuit
import com.slack.circuit.foundation.CircuitCompositionLocals
import com.slack.circuit.foundation.NavigableCircuitContent
import com.slack.circuit.foundation.rememberCircuitNavigator
import com.slack.circuit.internal.test.TestContentTags
import com.slack.circuit.internal.test.TestContentTags.TAG_LABEL
import com.slack.circuit.internal.test.TestScreen
import com.slack.circuit.runtime.CircuitUiState
import com.slack.circuit.runtime.Navigator
import com.slack.circuit.runtime.presenter.Presenter
import com.slack.circuit.runtime.ui.ui
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runTest
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config

@OptIn(ExperimentalMaterialApi::class)
@Config(minSdk = 34)
@RunWith(RobolectricTestRunner::class)
class GestureNavigationCrashTest {
@get:Rule val composeTestRule = createAndroidComposeRule<ComponentActivity>()

@OptIn(ExperimentalCoroutinesApi::class)
@Test
fun `gesture crash`() = runTest {
composeTestRule.run {
val circuit =
Circuit.Builder()
.addPresenterFactory { screen, navigator, _ ->
TestCrashPresenter(screen as TestScreen, navigator)
}
.addUiFactory { _, _ ->
ui<TestCrashState> { state, modifier -> TestCrashContent(state, modifier) }
}
.build()

lateinit var navigator: Navigator
setContent {
CircuitCompositionLocals(circuit) {
val backStack = rememberSaveableBackStack(TestScreen.RootAlpha)
navigator = rememberCircuitNavigator(backStack = backStack)
NavigableCircuitContent(
navigator = navigator,
backStack = backStack,
decoratorFactory =
remember { AndroidPredictiveBackNavDecorator.Factory(onBackInvoked = navigator::pop) },
)
}
}

// current alpha at startup
onTopNavigationRecordNodeWithTag(TAG_LABEL).assertTextEquals("Root Alpha")

// reset to beta with saveState=true restoreState=true
navigator.resetRoot(TestScreen.RootBeta, saveState = true, restoreState = true)
onTopNavigationRecordNodeWithTag(TAG_LABEL).assertTextEquals("Root Beta")

// reset back to alpha with saveState=true restoreState=true
navigator.resetRoot(TestScreen.RootAlpha, saveState = true, restoreState = true)
onTopNavigationRecordNodeWithTag(TAG_LABEL).assertTextEquals("Root Alpha")

// go to A
navigator.goTo(TestScreen.ScreenA)
onTopNavigationRecordNodeWithTag(TAG_LABEL).assertTextEquals("A")

// reset to beta with saveState=true restoreState=false - !!! important to be false not true
// !!!
navigator.resetRoot(TestScreen.RootBeta, saveState = true, restoreState = false)
onTopNavigationRecordNodeWithTag(TAG_LABEL).assertTextEquals("Root Beta")

// reset back to alpha with saveState=true restoreState=true
navigator.resetRoot(TestScreen.RootAlpha, saveState = true, restoreState = true)
onTopNavigationRecordNodeWithTag(TAG_LABEL).assertTextEquals("A")

// pop Screen A - should go to alpha root
activityRule.scenario.performGestureNavigationBackSwipe()
onTopNavigationRecordNodeWithTag(TAG_LABEL).assertTextEquals("Root Alpha")

// reset back to beta with saveState=true restoreState=true - will crash if previous pop was
// done with gesture navigation
navigator.resetRoot(TestScreen.RootBeta, saveState = true, restoreState = true)
onTopNavigationRecordNodeWithTag(TAG_LABEL).assertTextEquals("Root Beta")
}
}
}

private class TestCrashPresenter(private val screen: TestScreen, private val navigator: Navigator) :
Presenter<TestCrashState> {
@Composable
override fun present(): TestCrashState {
return TestCrashState(screen.label) { event ->
when (event) {
TestCrashEvent.PopNavigation -> navigator.pop()
TestCrashEvent.GoToNextScreen -> {
when (screen) {
// Root screens all go to ScreenA
TestScreen.RootAlpha -> navigator.goTo(TestScreen.ScreenA)
TestScreen.RootBeta -> navigator.goTo(TestScreen.ScreenA)
// Otherwise each screen navigates to the next screen
TestScreen.ScreenA -> navigator.goTo(TestScreen.ScreenB)
TestScreen.ScreenB -> navigator.goTo(TestScreen.ScreenC)
else -> error("Can't navigate from $screen")
}
}
is TestCrashEvent.ResetRootAlpha ->
navigator.resetRoot(TestScreen.RootAlpha, true, event.restoreState)
is TestCrashEvent.ResetRootBeta ->
navigator.resetRoot(TestScreen.RootBeta, true, event.restoreState)
}
}
}
}

@Composable
private fun TestCrashContent(state: TestCrashState, modifier: Modifier = Modifier) {
Box(modifier = modifier.testTag(TestContentTags.TAG_ROOT)) {
BasicText(text = state.label, modifier = Modifier.testTag(TAG_LABEL))
}
}

data class TestCrashState(val label: String, val eventSink: (TestCrashEvent) -> Unit) :
CircuitUiState

sealed interface TestCrashEvent {
data object GoToNextScreen : TestCrashEvent

data object PopNavigation : TestCrashEvent

data class ResetRootAlpha(val restoreState: Boolean = true) : TestCrashEvent

data class ResetRootBeta(val restoreState: Boolean = true) : TestCrashEvent
}
Loading