HourGlass Animation

Posted on Aug 17, 2023 • 15 min read

Meet Prajapati

Sr. Mobile Software Engineer

HourGlass Animation

Creating an HourGlass Animation with Jetpack Compose

In this tutorial, we will explore how to create an HourGlass animation using Jetpack Compose. Jetpack Compose is a modern UI toolkit for building native Android interfaces using a declarative syntax. Our HourGlass animation will combine various Compose elements to simulate the classic Hourglass timer effect.

The Animation

test

Utilizing the Power of Canvas

One of the most fascinating aspects of creating the HourGlass animation is the use of the Canvas Composable. Jetpack Compose provides the Canvas Composable, which allows us to draw custom graphics and shapes directly onto the screen. In our HourGlass animation, we take full advantage of the capabilities offered by Canvas to bring our hourglass to life.

Setting Up the HourGlass Animation

To begin, let’s create a Composable function called HourGlass that takes in a HourGlassViewModel and a HourGlassConfig. The HourGlassViewModel will provide us with the necessary data for our animation, while the HourGlassConfig will define the size and other configuration parameters of our hourglass.

@Composable
fun HourGlass(
   hourGlassViewModel: HourGlassViewModel,
   hourGlassConfig: HourGlassConfig,
) {
   // Collect the current tick value from the ViewModel
   val tick: Float by hourGlassViewModel.getTick.collectAsStateWithLifecycle(0f)

   // Create a Box to constrain the animation within a specific size
   BoxWithConstraints(
       modifier = Modifier.size(hourGlassConfig.size),
   ) {
       val center = Offset(
           maxWidth.toPx() / 2,
           maxHeight.toPx() / 2
       )

       // Draw the hourglass components using Canvas
       Canvas(
           modifier = Modifier,
       ) {
           topGlass(center, hourGlassConfig)
           bottomGlass(center, hourGlassConfig)
           topSand(center, hourGlassConfig, tick)
           bottomSand(center, hourGlassConfig, tick)
           movingSand(tick, center, hourGlassConfig)
           movingSandParticles(tick, center, hourGlassConfig)
       }
   }
}

Let’s briefly explain each step of the animation:

  1. topGlass and bottomGlass: These functions draw the top and bottom parts of the hourglass frame.

  2. topSand and bottomSand: These functions simulate the sand falling from the top to the bottom half of the hourglass based on the current tick value.

  3. movingSand: This function draws the continuous flow of sand within the hourglass, creating the illusion of time passing.

  4. movingSandParticles: Here, we add extra visual flair by creating small sand particles that move along with the falling sand.

The Config Class

data class HourGlassConfig(
   val size: Dp,
   val glassWidth: Dp = 2.dp,
   val glassColor: Color = Color(0xFF966F33),
   val glassGapForSand: Dp = 10.dp,
   val sandColor: Color = Color(0xFFC2B280),
   val sandWidth: Dp = 2.5.dp,
)

The HourGlassConfig data class encapsulates configuration parameters for an HourGlass animation.

It includes properties such as size (hourglass size), glassWidth (frame width), glassColor (frame color), glassGapForSand (a gap between glass halves), sandColor (sand color), and sandWidth (sand particle width). By adjusting these properties, you can easily customize the appearance of the animation to create unique hourglass effects.

The ViewModel

import android.os.CountDownTimer
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.launch

class HourGlassViewModel : ViewModel() {
   // Represents the ongoing CountDownTimer instance
   private var countDownTimer: CountDownTimer? = null

   // A MutableSharedFlow that emits tick values representing animation progress
   private val tick = MutableSharedFlow<Float>()

   // Exposes the tick flow for observing animation progress
   val getTick = tick

   // Starts the countdown timer with the given duration in seconds
   fun startTimer(durationInSecond: Long) {
       // If a timer is already running, do nothing
       if (countDownTimer != null) {
           return
       }

       // Convert duration to milliseconds
       val duration = durationInSecond * 1000L

       // Create and start the CountDownTimer
       countDownTimer = object : CountDownTimer(
           duration,
           1
       ) {
           // This function is called on every tick (every millisecond)
           override fun onTick(millisUntilFinished: Long) {
               // Calculate animation progress and emit it using coroutine
               viewModelScope.launch {
                   tick.emit((duration - millisUntilFinished).toFloat() / duration)
               }
           }

           // This function is called when the timer finishes
           override fun onFinish() {
               // Emit a final progress value of 1.0 (100%)
               viewModelScope.launch {
                   tick.emit(1f)
               }
           }
       }.start()
   }

   // Stops the timer and resets the progress to 0
   fun stopTimer() {
       // Emit a progress value of 0 to reset animation
       viewModelScope.launch {
           tick.emit(0f)
       }

       // Cancel the ongoing timer, if any
       countDownTimer?.cancel()

       // Set the timer instance to null
       countDownTimer = null
   }
}

In this code:

  • We use a CountDownTimer to manage the animation timing, emitting progress ticks.

  • The tick flow is a shared flow that emits progress values.

  • startTimer initializes the timer and emits progress values as time passes.

  • stopTimer cancels the timer and resets the progress to 0.

This ViewModel handles the countdown logic, ensuring that animation progress is accurately tracked and emitted for your HourGlass animation.

Canvas Components For HourGlass

Top Glass

// This function draws the top glass frame of the hourglass.
fun DrawScope.topGlass(
   center: Offset,
   hourGlassConfig: HourGlassConfig,
) {
   with(hourGlassConfig) {
       val length = center.x

       // Create a Path to define the shape of the top glass frame
       drawPath(
           path = Path().apply {
               moveTo(center.x - length, center.y - length)
               lineTo(center.x + length, center.y - length)
               lineTo(center.x + glassGapForSand.value / 2, center.y)
               moveTo(center.x - glassGapForSand.value / 2, center.y)
               lineTo(center.x - length, center.y - length)
           },
           color = glassColor,
           style = Stroke(width = glassWidth.value)
       )
   }
}

The topGlass the function draws the top part of the hourglass frame. It creates a Path object using the drawPath function.

Let me demonstrate the Path,

test

Bottom Glass

// This function draws the bottom glass frame of the hourglass.
fun DrawScope.bottomGlass(
   center: Offset,
   hourGlassConfig: HourGlassConfig,
) {
   // Rotate the canvas by 180 degrees to draw the bottom glass
   rotate(
       degrees = 180f,
       pivot = center
   ) {
       // Call the topGlass function to draw the bottom glass
       topGlass(center, hourGlassConfig)
   }
}

This function uses the rotate function to flip the canvas by 180 degrees around the center point. This is done to draw the bottom glass frame of the hourglass. Within the rotated canvas, it calls the topGlass function to draw the bottom glass frame. This simplifies the code by reusing the same drawing logic for both the top and bottom glass.

test

Let’s Add The Sand

Top Sand

// This function draws the sand in the top half of the hourglass.
fun DrawScope.topSand(
   center: Offset,
   hourGlassConfig: HourGlassConfig,
   tick: Float = 0f,
) {
   with(hourGlassConfig) {
       val length = center.x
       var variableLength = length - glassWidth.value / 2
       variableLength -= variableLength * tick

       // Create a Path to define the shape of the sand in the top half
       drawPath(
           path = Path().apply {
               moveTo(center.x - variableLength, center.y - variableLength)
               lineTo(center.x + variableLength, center.y - variableLength)
               lineTo(center.x, center.y + glassWidth.value)
               moveTo(center.x, center.y)
               lineTo(center.x - variableLength, center.y - variableLength)
           },
           color = sandColor,
       )
   }
}

The topSand function draws the sand in the top half of the hourglass. It calculates the variableLength of the sand based on the animation progress (tick). This variableLength decreases as the animation progresses, giving the illusion of sand moving down from the top half. 

The drawPath function is used to create a triangular shape for the sand using moveTo and lineTo functions. The sand color is set from hourGlassConfig.

test

Bottom Sand

// This function draws the sand in the bottom half of the hourglass.
fun DrawScope.bottomSand(
   center: Offset,
   hourGlassConfig: HourGlassConfig,
   tick: Float,
) {
   with(hourGlassConfig) {
       val clipLength = center.x - glassWidth.value / 2

       // Clip the canvas to draw sand only within the bottom glass
       clipPath(
           path = Path().apply {
               moveTo(center.x + clipLength, center.y + clipLength)
               lineTo(center.x - clipLength, center.y + clipLength)
               lineTo(center.x, center.y - glassWidth.value)
               moveTo(center.x, center.y)
               lineTo(center.x + clipLength, center.y + clipLength)
           },
           clipOp = ClipOp.Intersect
       ) {
           val rectLength = center.x * (1f - tick)
                      // Draw a rectangle to represent the sand in the bottom half
           filledRect(center, center.x, hourGlassConfig.sandColor)
           // Draw a white rectangle to cover the sand above the current level
           filledRect(center, rectLength, Color.White)
       }
   }
}
// This function draws a filled rectangle at a specified position.
fun DrawScope.filledRect(
   center: Offset,
   rectLength: Float,
   color: Color,
) {
   // Create a Path to define the shape of the filled rectangle
   drawPath(
       path = Path().apply {
           moveTo(center.x + rectLength, center.y + rectLength)
           lineTo(center.x - rectLength, center.y + rectLength)
           lineTo(center.x + rectLength, center.y)
           moveTo(center.x + rectLength, center.y)
           lineTo(center.x - rectLength, center.y)
           lineTo(center.x - rectLength, center.y + rectLength)
       },
       brush = SolidColor(color),
   )
}

The bottomSand function draws the sand in the bottom half of the hourglass. It uses the clipPath function to restrict the drawing area to the bottom glass section. Within the clipped area, it first draws a rectangle representing the sand using the filledRect function. Then, it draws a white rectangle above the current sand level to simulate the disappearing sand effect as it moves to the bottom.

test

Moving Sand From Top To Bottom

// This function draws the continuous flow of sand in the hourglass.
fun DrawScope.movingSand(
   tick: Float,
   center: Offset,
   hourGlassConfig: HourGlassConfig,
) {
   if (tick > 0f) {
       // Draw a line representing the falling sand
       drawLine(
           color = Color(0xFFC2B280),
           start = center,
           end = Offset(center.x, center.y + center.x - (hourGlassConfig.glassWidth.value / 2)),
           strokeWidth = hourGlassConfig.sandWidth.toPx()
       )
   }
}

This function draws the continuous flow of sand particles in the hourglass. If the tick value is greater than 0, it draws a vertical line using the drawLine function to represent the falling sand. The line starts from the center of the hourglass and extends down to the current sand level. The sandWidth from hourGlassConfig is used to set the line's width.

test

Adding The Moving Sand Particles Around The Moving Sand

// This function draws moving sand particles in the hourglass.
fun DrawScope.movingSandParticles(
   tick: Float,
   center: Offset,
   hourGlassConfig: HourGlassConfig,
) {
   // Check if time is moving (tick > 0)
   if (tick > 0f) {
       // Draw multiple sand particles using random offsets
       for (i in 0..10) {
           // Generate a random position for the sand particle
           val random = Random.nextInt(center.y.dp.toPx().toInt())

           // Draw the sand particle using the drawPoints function
           drawPoints(
               pointMode = PointMode.Points,
               points = listOf(
                   Offset(center.x - 1.dp.toPx(), min(center.y + random.toFloat(), center.y + center.x) - hourGlassConfig.glassWidth.value * 2),
                   Offset(center.x + 1.dp.toPx(), min(center.y + random.toFloat(), center.y + center.x) - hourGlassConfig.glassWidth.value * 2),
               ),
               color = hourGlassConfig.sandColor,
               strokeWidth = 4f
           )
       }
   }
}
  1. Inside the loop (a repeating action), the code does the following 10 times:

  2. It uses a special tool called Random to pick a random number. This number helps decide where to put each sand particle inside the hourglass.

  3. Then, the code uses the drawPoints function to draw the sand particles. It does this by giving it some instructions:

  4. pointMode = PointMode.Points says that we're drawing individual points, like tiny dots. points = listOf(...) gives a list of positions for the sand particles. Each particle is drawn using two points.

  • These points are calculated based on the center of the hourglass, and they are slightly different for each particle. This makes the particles look like they're moving around randomly.

  • The color of the sand particles is set to the color of sand from the hourGlassConfig.

  • The strokeWidth determines how thick each sand particle should be drawn.

And that’s how this code creates the effect of moving sand particles inside the hourglass. It’s like adding a bunch of tiny dots that move around to show the sand falling and swirling inside the timer.

test

Result

test

Usage

HourGlass(
           hourGlassViewModel,
           hourGlassConfig = HourGlassConfig(
               size = 100.dp,
           )
       )

Pass the ViewModel and the config, and To start the glass,

// Duration is Seconds
hourGlassViewModel.startTimer(3600)

AWS Certified Team

Tech Holding Team is a AWS Certified & validates cloud expertise to help professionals highlight in-demand skills and organizations build effective, innovative teams for cloud initiatives using AWS.

By using this site, you agree to thePrivacy Policy.