Swipe To Reveal Previous Screen State in Jetpack Compose.

Manasseh Kola
5 min readJul 27, 2023

Stateful and Lifecycle-aware navigation is a crucial aspect of every Android mobile application. Improper implementation of navigation in an Android App can lead to crashes, memory leaks, and an overall poor User Experience.

Thankfully Google’s Navigation Component comes to the rescue. Well, not entirely. Google’s Navigation Component encapsulates the navigation state and offers no public APIs to access or mutate the state; this is good for most navigation patterns as it prevents memory leaks and crashes that could occur. However, this can be very limiting in unique cases that require dynamically rendering a composition from the saved navigation state.

For example, consider the navigation pattern for Instagram below.

Instagram Swipe to reveal previous screen state

Dragging the top screen reveals the state of the previous screen. This can only be achieved if the state of the previous screen is accessible to be rendered/composed before the top screen is dragged.

Unleashing the Power of Compose!

With Jetpack Compose smart recompositions and composable call sites, the above navigation pattern can be achieved with a simple array/list and for-loop!

The source code below shows how to implement this navigation pattern in a simple App. The App contains only 2 Screens, Screen1 and Screen2. Screen1 has a button to change the background color; this is used to show the previous state of a screen is retained. Check out my InstagramClone Github repo, where I implement this pattern on a larger scale.

Prerequisites — Jetpack Compose, dependency injection with Dagger-Hilt

  1. Create a data class for the UI state
data class NavigationUIState(
//--Back Stack for navigation--
val navigationBackStack: SnapshotStateList<AppScreenTypes> = mutableStateListOf(AppScreenTypes.Screen1()),

//--Horizontal Draggable Screen offsets--
val screenXOffset: Float = 0.0f,
val topScreenXOffset: Float = 0.0f,
val prevScreenXOffset: Float = 0.0f,

//-- Index of previous screen, this is used to fine tune animations.--
val prevScreenIndex: Int = -1,
)

2. Setup Navigation View Model

@HiltViewModel
class NavigationViewModel @Inject constructor(
private val savedStateHandle: SavedStateHandle
): ViewModel(){

private val _navigationState = MutableStateFlow(NavigationUIState())
val navigationState = _navigationState.asStateFlow()

//-- Set value of screenXOffset and prevScreenXOffset --
fun setScreenXOffset(offset: Float){
_navigationState.update{state ->
state.copy(
screenXOffset = offset,
prevScreenXOffset = -offset / 8
)
}
}

//-- Push new screen on stack --
fun pushToBackStack(screenRoute: AppScreenTypes){
_navigationState.value.navigationBackStack.add(screenRoute)
}

//-- Pop top of stack --
fun popBackStack(){
val currentStack = _navigationState.value.navigationBackStack
navigationState.value.navigationBackStack.removeAt(currentStack.lastIndex)
}

//-- Update XOffset for top and previous Screen --
fun updateTopScreenXOffset(delta: Float){
val topScreenXOffset = _navigationState.value.topScreenXOffset
val prevXOffset = _navigationState.value.prevScreenXOffset
if ( topScreenXOffset + delta >= 0.0f ){
_navigationState.update { state ->
state.copy(
topScreenXOffset = topScreenXOffset + delta,
prevScreenXOffset = prevXOffset + delta / 8
)
}
}
}

//-- Reset values for Top and previous screen XOffsets --
fun resetScreenXOffset(){
val screenXOffset = _navigationState.value.screenXOffset
_navigationState.update { state -> state.copy(
topScreenXOffset = 0.0f,
prevScreenXOffset = -screenXOffset / 8,
prevScreenIndex = -1,
) }
}

//-- Clean Up Top Screen XOffset when composition is destroyed --
fun cleanUpXOffset(){
_navigationState.update{ state -> state.copy(
topScreenXOffset = 0.0f,
)}
}

//-- Set the Index of the prevScreen --
private fun setPrevScreenIndex(){
val nextPrevScreenIndex = _navigationState.value.navigationBackStack.lastIndex - 1
_navigationState.update { state ->
state.copy(
prevScreenXOffset = 0.0f,
prevScreenIndex = nextPrevScreenIndex
)
}
}

//-- CallBack for when a screen drag ends --
fun horizontalScreenDragEnded(xBreakPoint: Float){
val screenXOffset = _navigationState.value.screenXOffset
val topScreenXOffset = _navigationState.value.topScreenXOffset
if( topScreenXOffset > xBreakPoint){
setPrevScreenIndex()
popBackStack()
}
else{
_navigationState.update { state ->
state.copy(
topScreenXOffset = 0.0f,
prevScreenXOffset = -screenXOffset / 8f,
)
}
}
}
}

3. Create a custom Component to make screens draggable

/*
Allows Screen to be dragged horizontally (to the right)
*/

@Composable
fun HorizontalDraggableScreen(
screenStackIndex: Int = 0,
navigationViewModel: NavigationViewModel,
content: @Composable (ColumnScope.() -> Unit),

){

val navigationUiState = navigationViewModel.navigationState.collectAsState()
val screenXOffset = navigationUiState.value.screenXOffset
val topScreenXOffset = navigationUiState.value.topScreenXOffset
val prevScreenXOffset = navigationUiState.value.prevScreenXOffset


val prevScreenIndex = navigationUiState.value.prevScreenIndex
val animatedTopXOffset = animateFloatAsState(targetValue = topScreenXOffset)
val animatedPrevXOffset = animateFloatAsState(targetValue = prevScreenXOffset)
val navigationBackStack = navigationUiState.value.navigationBackStack
val isTopScreen = screenStackIndex == navigationBackStack.lastIndex && screenStackIndex != prevScreenIndex



LaunchedEffect(key1 = navigationBackStack.size){
if(prevScreenIndex == screenStackIndex){
delay(500)
navigationViewModel.resetScreenXOffset()
}
}

DisposableEffect(key1 = Unit){
onDispose {
navigationViewModel.cleanUpXOffset()
}
}

Column(
content = content,
modifier = Modifier
.offset {
if (isTopScreen) {
IntOffset(animatedTopXOffset.value.roundToInt(), 0)
} else {
IntOffset(animatedPrevXOffset.value.roundToInt(), 0)
}
}
.draggable(
enabled = ((screenStackIndex != 0) && prevScreenIndex != screenStackIndex),
orientation = Orientation.Horizontal,
onDragStopped = {
navigationViewModel.horizontalScreenDragEnded(xBreakPoint = screenXOffset / 2)

},
state = rememberDraggableState { delta ->
navigationViewModel.updateTopScreenXOffset(delta)
}
)
)

}

4. Create Composables for the screens

//--Sealed Class for Screen Types--
sealed class AppScreenTypes(val route: String, args: String? = null){
class Screen1(args: String? = null): AppScreenTypes(route = "screen1", args = args)
class Screen2(args: String? = null): AppScreenTypes(route = "screen1", args = args)
}

@Composable
fun Screen1(
screenStackIndex: Int,
navigationViewModel: NavigationViewModel
){
val screenColor = remember{ mutableStateOf(Color.White)}
val animatedScreenColor = animateColorAsState(targetValue = screenColor.value )

HorizontalDraggableScreen(
screenStackIndex = screenStackIndex,
navigationViewModel = navigationViewModel
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
modifier = Modifier
.fillMaxSize()
.background(color = animatedScreenColor.value)
){
//-- Change Color Of Screen to a random Color Button --
Text(
color = Color.White,
text = "Change Screen Color",
modifier = Modifier
.padding(bottom = 20.dp)
.background(Color.Red, RoundedCornerShape(15.dp))
.clickable {
screenColor.value = Color(
(0..255).random(),
(0..255).random(),
(0..255).random(),
)
}
.padding(10.dp)
)

//-- Navigate to Screen2 button --
Row( modifier = Modifier
.clip(RoundedCornerShape(15.dp))
.background(Color.Blue)
.clickable {
navigationViewModel.pushToBackStack(AppScreenTypes.Screen2())
}
.padding(10.dp)
){
Text(text = "Navigate to Screen 2", color = Color.White)
}
}
}
}


@Composable
fun Screen2(
screenStackIndex: Int,
navigationViewModel: NavigationViewModel
) {
HorizontalDraggableScreen(
screenStackIndex = screenStackIndex,
navigationViewModel = navigationViewModel
) {
Surface(shadowElevation = 10.dp){
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
modifier = Modifier
.background(color = Color.White)
.fillMaxSize()

) {

Text(text = "Drag to reveal previous screen")
Spacer(modifier = Modifier.height(10.dp))

//-- Navigate to Previous Screen button --
Row(modifier = Modifier
.clip(RoundedCornerShape(15.dp))
.background(Color.Blue)
.clickable {
navigationViewModel.popBackStack()
}
.padding(10.dp)
) {
Icon(imageVector = Icons.Default.ArrowBack, contentDescription = "Back Arrow", tint = Color.White)
Spacer(modifier = Modifier.width(10.dp))
Text(text = "Navigate to Previous Screen", color = Color.White)
}
}
}
}
}

5. Create an Entry point for all screens (If you’re familar with navigation compose, you can think of this Composable as the navigation graph and host Combined)

@Composable
fun NavigationScreen(
navigationViewModel: NavigationViewModel = hiltViewModel()
) {
val navigationState = navigationViewModel.navigationState.collectAsState()
val navigationBackStack = navigationState.value.navigationBackStack
val screenXOffsetSet = remember { mutableStateOf(false) }

Box(
modifier = Modifier
.onGloballyPositioned { layoutCoordinates ->
//--Set screenXOffset if not set--
if (!screenXOffsetSet.value) {
val rect = layoutCoordinates.boundsInRoot()
navigationViewModel.setScreenXOffset(rect.topRight.x)
screenXOffsetSet.value = true
}
}
){
for((index, screen) in navigationBackStack.withIndex()){
when(screen){
is AppScreenTypes.Screen1 -> {
AnimatedVisibility(visible = index >= navigationBackStack.size - 2) {
Screen1(screenStackIndex = index, navigationViewModel = navigationViewModel)
}
}
is AppScreenTypes.Screen2 -> {
AnimatedVisibility(visible = index >= navigationBackStack.size - 2) {
Screen2(screenStackIndex = index, navigationViewModel = navigationViewModel)
}

}
}
}
}

}

6. MainActivity

@AndroidEntryPoint
class MainActivity : ComponentActivity(

) {

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {

InstaCloneApp3Theme {
// A surface container using the 'background' color from the theme
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background
) {
NavigationScreen()
}
}
}
}

}
Navigation App

Thats It!

Optimizations — Lazy composition can be implemented for the previous screen. The Previous screen state would only be rendered as a top screen is about to be dragged. Stay tuned to my InstagramClone Github repo for updates 😉😉.

--

--