Technologies Natives

Detect Instagram-like gestures with Jetpack Compose

This is the second article of a series about Jetpack Compose.

Posts in this series :

Resources:

What we'll try to achieve

  • On press on the 1st left quarter of the screen : go to previous screen.
  • On press on the right 3 quarters of the screen : go to next screen.
  • On press-and-hold anywhere : pause the progress bar.
  • On progress bar finished : go to next screen.

 

target

Our Jetpack Compose Stories !

Recreating Instagram's stories screen

Let's warm up by using our progress bar in a typical Instagram's like screen.

++pre>++code class="has-line-data" data-line-start="30" data-line-end="77">@Composable
fun InstagramScreen() {
   // We will hardcode those parameter for now.
   val steps = 5;
   val currentStep = 2;
   val isPressed = remember { mutableStateOf(false) }
   val goToPreviousScreen = {}
   val goToNextScreen = {}

   Column(
       horizontalAlignment = Alignment.CenterHorizontally,
       modifier =
       Modifier
           .background(
               Brush.linearGradient( // (1)
                   colors = listOf(GreenLemon, GreenLeaves, BlueSea), // (2)
                   start = Offset.Zero, end = Offset.Infinite
               )
           )
   ) {
       InstagramSlicedProgressBar(steps, currentStep, isPressed.value, goToNextScreen)
       Column(
           horizontalAlignment = Alignment.CenterHorizontally,
           verticalArrangement = Arrangement.Center,
           modifier = Modifier.weight(1f)
       ) {
           Text(
               text = "Hello world !",
               style = Typography.h1,
               color = Color.White
           )
           Spacer(modifier = Modifier.height(8.dp))
           Text(
               text = "Tap or wait to go to the next screen",
               style = Typography.body1,
               color = Color.White
           )
           Spacer(modifier = Modifier.height(8.dp))
           Text(
               text = "?",
               style = Typography.body1,
               color = Color.White
           )
       }
   }
}
++/code>++/pre>

  1. This is the way of creating a linear gradient ! Very useful.
  2. Use a list of colours that you like.
    Mine is :++pre>++code class="has-line-data" data-line-start="82" data-line-end="88"> val GreenLemon = Color(0xFFA9F24D)
    val GreenLeaves = Color(0xFF00C88C)
    val BlueSea = Color(0xFF4895AD)
    val Purple = Color(0xFF9248AD)
    val RedRaspberry = Color(0xFFE2264C)
    ++/code>++/pre>

Adding gestures

Adding gestures to this screen is not very complicated. Jetpack Compose ++code>pointerInput++/code> modifier is very handful in this situation :

++pre>++code class="has-line-data" data-line-start="94" data-line-end="135">@Composable
fun InstagramScreen() {
   // We will hardcode those parameter for now.
   val steps = 5;
   val currentStep = 2;
   val isPressed = remember { mutableStateOf(false) }
   val goToPreviousScreen = {}
   val goToNextScreen = {}

   Column(
       horizontalAlignment = Alignment.CenterHorizontally,
       modifier =
       Modifier
           .background(
               ...
           ).pointerInput(Unit) { // (1)
               val maxWidth = this.size.width // (2)
               detectTapGestures(
                   onPress = { // (3)
                       val pressStartTime = System.currentTimeMillis()
                       isPressed.value = true
                       this.tryAwaitRelease() // (4)
                       val pressEndTime = System.currentTimeMillis()
                       val totalPressTime = pressEndTime - pressStartTime // (5)
                       if (totalPressTime < 200) {
                           val isTapOnRightTwoTiers = (it.x > (maxWidth / 4)) // (6)
                           if (isTapOnRightTwoTiers) {
                               goToNextScreen()
                           } else {
                               goToPreviousScreen()
                           }
                       }
                       isPressed.value = false
                   },
               )
           }
   ) {
       ...
   }
}
++/code>++/pre>

  1. ++code>pointerInput++/code> installs a gesture detector. It is attached to some key. If the key change on recomposition, the previous gesture detector is detached and a new one is created.
  2. Here we use ++code>Unit++/code> because we want to install a permanent gesture detector.
  3. We can retrieve the Composable's width inside a ++code>PointerInputScope++/code> ! Wow !
  4. ++code>detectTapGestures++/code>'s ++code>onPress++/code> attribute is what we need to detect custom on-press behaviours. It expects a suspend function and provide in its scope a suspendable ++code>awaitRelease++/code> and ++code>tryAwaitRelease++/code> functions.
  5. Those functions pause the coroutine execution until the user releases its gesture !
  6. We wait for the user to release its gesture.
  7. ++pre>++code class="has-line-data" data-line-start="149" data-line-end="154">graph LR
    A[Start] -->|Down| B{Wait for release?};
    B -->|Up| C[Continue coroutine execution];
    B ---->|Recomposition| B;
    ++/code>++/pre>

Setting up the navigation

We can easily set up a navigation with the ++code>androidx.navigation:navigation-compose++/code> package.

++pre>++code class="has-line-data" data-line-start="160" data-line-end="162">    implementation("androidx.navigation:navigation-compose:2.4.0-alpha03")
++/code>++/pre>

Here is our entry point :

++pre>++code class="has-line-data" data-line-start="166" data-line-end="186">@Composable
fun Navigation() {
   val navController = rememberNavController()
   NavHost(navController = navController, startDestination = "instagram/{steps}/{currentStep}") { // (1)
       composable( // (2)
           "instagram/{steps}/{currentStep}",
           arguments = listOf(
               navArgument("steps") { type = NavType.IntType; defaultValue = 8 }, // (3)
               navArgument("currentStep") { type = NavType.IntType; defaultValue = 1 }, // (4)
           )
       ) { backStackEntry -> // (5)
           InstagramScreen(
               navController,
               backStackEntry.arguments!!.getInt("steps"),
               backStackEntry.arguments!!.getInt("currentStep"),
           )
       }
   }
}
++/code>++/pre>

  1. Here we declare our router. Its start destination is ++code>instagram/{steps}/{currentStep}++/code>.
  2. We declare a route. There are some navigation params : ++code>steps++/code> and ++code>currentStep++/code>.
  3. ++code>steps++/code> is an ++code>Int++/code>; we can use a default value.
  4. ++code>currentStep++/code> is an ++code>Int++/code>; we can use a default value.
  5. We pass down our navigation params thanks to ++code>backStackEntry++/code> argument.

Remember our hardcoded values ? Let's change those :

++pre>++code class="has-line-data" data-line-start="196" data-line-end="208">@Composable
fun InstagramScreen(navController: NavController, steps: Int, currentStep: Int) {
   val goToNextScreen = {
       if (currentStep + 1 <= steps) navController.navigate("instagram/$steps/${currentStep + 1}")
   }
   val goToPreviousScreen = {
       if (currentStep - 1 > 0) navController.navigate("instagram/$steps/${currentStep - 1}")
   }

   ...
}
++/code>++/pre>

Here you go !

The final result is here :

target

Développeur mobile ?

Rejoins nos équipes