5

I'm trying to create a sky view in my Android app using Jetpack Compose. I want to display it inside a Card with a fixed height. During nigth time, the card background turns dark blue and I'd like to have some blinking stars spread over the sky.

To create the stars blinking animation, I'm using an InfiniteTransition object and a scale property with animateFloat that I apply to several Icons. Those Icons are created inside a BoxWithConstraints, to spread then randomly using a for loop. The full code I'm using is shown below:

@Composable
fun NightSkyCard() {
    Card(
        modifier = Modifier
            .height(200.dp)
            .fillMaxWidth(),
        elevation = 2.dp,
        shape = RoundedCornerShape(20.dp),
        backgroundColor = DarkBlue
    ) {
        val infiniteTransition = rememberInfiniteTransition()
        val scale by infiniteTransition.animateFloat(
            initialValue = 1f,
            targetValue = 0f,
            animationSpec = infiniteRepeatable(
                animation = tween(1000),
                repeatMode = RepeatMode.Reverse
            )
        )
        
        BoxWithConstraints(
            modifier = Modifier.fillMaxSize()
        ) {
            for (n in 0..20) {
                val size = Random.nextInt(3, 5)
                val start = Random.nextInt(0, maxWidth.value.toInt())
                val top = Random.nextInt(10, maxHeight.value.toInt())
                
                Icon(
                    imageVector = Icons.Filled.Circle,
                    contentDescription = null,
                    modifier = Modifier
                        .padding(start = start.dp, top = top.dp)
                        .size(size.dp)
                        .scale(scale),
                    tint = Color.White
                )
            }
            
        }
    }
}

The problem with this code is that the BoxWithConstraints's scope is recomposing continously, so I get a lot of dots appearing and dissapearing from the screen very fast. I'd like the scope to just run once, so that the dots created at first time would blink using the scale property animation. How could I achieve that?

Thracian
  • 43,021
  • 16
  • 133
  • 222

2 Answers2

6

Instead of continuous recomposition you should look for least amount of recompositions to achieve your goal.

Compose has 3 phases. Composition, Layout and Draw, explained in official document. When you use a lambda you defer state read from composition to layout or draw phase.

If you use Modifier.scale() or Modifier.offset() both of three phases above are called. If you use Modifier.graphicsLayer{scale} or Modifier.offset{} you defer state read to layout phase. And the best part, if you use Canvas, which is a Spacer with Modifier.drawBehind{} under the hood, you defer state read to draw phase as in example below and you achieve your goal only with 1 composition instead of recomposing on every frame.

For instance from official document

// Here, assume animateColorBetween() is a function that swaps between
// two colors
val color by animateColorBetween(Color.Cyan, Color.Magenta)

Box(Modifier.fillMaxSize().background(color))

Here the box's background color is switching rapidly between two colors. This state is thus changing very frequently. The composable then reads this state in the background modifier. As a result, the box has to recompose on every frame, since the color is changing on every frame.

To improve this, we can use a lambda-based modifier–in this case, drawBehind. That means the color state is only read during the draw phase. As a result, Compose can skip the composition and layout phases entirely–when the color changes, Compose goes straight to the draw phase.

val color by animateColorBetween(Color.Cyan, Color.Magenta)
Box(
   Modifier
      .fillMaxSize()
      .drawBehind {
         drawRect(color)
      }
)

And how you can achieve your result

@Composable
fun NightSkyCard2() {
    Card(
        modifier = Modifier
            .height(200.dp)
            .fillMaxWidth(),
        elevation = 2.dp,
        shape = RoundedCornerShape(20.dp),
        backgroundColor = Color.Blue
    ) {
        val infiniteTransition = rememberInfiniteTransition()
        val scale by infiniteTransition.animateFloat(
            initialValue = 1f,
            targetValue = 0f,
            animationSpec = infiniteRepeatable(
                animation = tween(1000),
                repeatMode = RepeatMode.Reverse
            )
        )

        val stars = remember { mutableStateListOf<Star>() }


        BoxWithConstraints(
            modifier = Modifier
                .fillMaxSize()
                .background(Color.Blue)
        ) {

            SideEffect {
                println(" Recomposing")
            }
            
            LaunchedEffect(key1 = Unit) {
                repeat(20) {
                    stars.add(
                        Star(
                            Random.nextInt(2, 5).toFloat(),
                            Random.nextInt(0, constraints.maxWidth).toFloat(),
                            Random.nextInt(10, constraints.maxHeight).toFloat()
                        )
                    )
                }
            }
            
            Canvas(modifier = Modifier.fillMaxSize()) {
               if(stars.size == 20){
                   stars.forEach { star ->
                       drawCircle(
                           Color.White,
                           center = Offset(star.xPos, star.yPos),
                           radius = star.radius *(scale)
                       )
                   }
               }
            }
        }
    }
}

@Immutable
data class Star(val radius: Float, val xPos: Float, val yPos: Float)
Thracian
  • 43,021
  • 16
  • 133
  • 222
  • Thanks, Thracian! Your solution works perfectly! Maybe I didn't explain myself correctly, but I was trying to avoid the continuous recomposition. And as a bonus, how could I achieve having each star bliking at a different rate? One possible solution could be to make the `durationMillis` parameter of `tween` property a random float as well, but I won't be able to call the `animateFloat()` function inside the DrawScope. Any suggestion? – Pion Developer Aug 08 '22 at 12:42
  • 1
    Maybe you can add a random negative offset such delay between (-1,0) as param of Star class and add this to scale and don't scale down while value is below 0. This can delay when scale will be bigger than 0. – Thracian Aug 08 '22 at 12:49
  • 1
    Great, it worked! I used a random offset between (-1, 1) because otherwise all stars dissappeared at the same time. Thanks for your help! – Pion Developer Aug 08 '22 at 13:14
  • @Thracian how do you know `use Modifier.scale() or Modifier.offset() both of three phases above are called. If you use Modifier.graphicsLayer{scale} or Modifier.offset{} you defer state read to layout phase` this statment is used in above answer. So my question how do we know which function is using which phase? Any articles or blog ? – Kotlin Learner Dec 17 '22 at 16:05
  • @vivekmodi Modifier.drawX functions invoke `draw` phase only. `Modifier.layout`, or anything that calls `Modifier.graphicsLayer` or `placeWithLayer`(Modifier.offset) invoke `layout` phase only. You can check out my question here. https://stackoverflow.com/questions/72457805/jetpack-compose-deferring-reads-in-phases-for-performance. Also in my tutorial i have some examples – Thracian Dec 17 '22 at 17:27
  • When you click a Modifier you can see what it actually does. Modifier.rotate for instance is Modifier.graphicsLayer with rotate variable – Thracian Dec 17 '22 at 17:28
  • To check which phases are called use Modifier.drawWithContent and Modifier.layout and log lambdas – Thracian Dec 17 '22 at 17:31
  • Sure I'll check them in details. Thanks for your guidance. – Kotlin Learner Dec 17 '22 at 17:56
2

One solution is to wrap your code in a LaunchedEffect so that the animation runs once:

@Composable
fun NightSkyCard() {
    Card(
        modifier = Modifier
            .height(200.dp)
            .fillMaxWidth(),
        elevation = 2.dp,
        shape = RoundedCornerShape(20.dp),
        backgroundColor = DarkBlue
    ) {
        val infiniteTransition = rememberInfiniteTransition()
        val scale by infiniteTransition.animateFloat(
            initialValue = 1f,
            targetValue = 0f,
            animationSpec = infiniteRepeatable(
                animation = tween(1000),
                repeatMode = RepeatMode.Reverse
            )
        )

        BoxWithConstraints(
            modifier = Modifier.fillMaxSize()
        ) {
            for (n in 0..20) {
                var size by remember { mutableStateOf(0) }
                var start by remember { mutableStateOf(0) }
                var top by remember { mutableStateOf(0) }
                
                LaunchedEffect(key1 = Unit) {    
                    size = Random.nextInt(3, 5)
                    start = Random.nextInt(0, maxWidth.value.toInt())
                    top = Random.nextInt(10, maxHeight.value.toInt())
                }
                Icon(
                    imageVector = Icons.Filled.Circle,
                    contentDescription = null,
                    modifier = Modifier
                        .padding(start = start.dp, top = top.dp)
                        .size(size.dp)
                        .scale(scale),
                    tint = Color.White
                )
            }
        }
    }
}

You then get 21 blinking stars.

Code Poet
  • 6,222
  • 2
  • 29
  • 50
  • Thanks for your response! I've tried so, but I can't use composable functions inside a `LaunchedEffect` scope because it's a `CoroutineScope`. That means that I cant' call `Icons` or `animateFloat()` inside the `for` loop using this approach. – Pion Developer Aug 07 '22 at 16:04
  • What's the problem? It's working I tested it. :) – Code Poet Aug 07 '22 at 16:13
  • Oops! When I try to add the `Icon` inside the `for` loop (to create several stars) Android Studio throws an error: "@Composable invocations can only happen from the context of a @Composable function" . Could you post your complete code, please? – Pion Developer Aug 07 '22 at 16:24
  • Ok, I've updated the code. This only gives me one star though. I think you will want more than one! Looks great though! :) – Code Poet Aug 07 '22 at 16:33
  • I'll edit my code so you can have 20 stars. Sorry for the confusion! – Code Poet Aug 07 '22 at 16:55