Skip to content

Instantly share code, notes, and snippets.

@objcode
Last active December 9, 2021 10:00
Show Gist options
  • Star 6 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save objcode/aabd32724202b5659bc3b32a791b91d9 to your computer and use it in GitHub Desktop.
Save objcode/aabd32724202b5659bc3b32a791b91d9 to your computer and use it in GitHub Desktop.
A quick animation exploration drawing a star field using Compose
/*
* Copyright 2021 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package stars
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.ExperimentalAnimationApi
import androidx.compose.animation.core.TweenSpec
import androidx.compose.animation.core.animate
import androidx.compose.foundation.*
import androidx.compose.foundation.interaction.InteractionSource
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsPressedAsState
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.composed
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.graphics.drawscope.ContentDrawScope
import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.toSize
import kotlinx.coroutines.delay
import kotlin.math.cos
import kotlin.math.roundToInt
import kotlin.math.sin
import kotlin.random.Random
@Composable
fun StarsBox(
modifier: Modifier = Modifier,
starState: StarState = remember { StarState(3) },
content: @Composable BoxScope.() -> Unit = {}
) {
Box(modifier.drawStars(starState), content = content)
}
fun Modifier.drawStars(
starState: StarState,
clip: Shape = CircleShape,
backgroundColor: Color = Color.Black
) = composed {
var time by remember { mutableStateOf(0L) }
LaunchedEffect(Unit) {
while(true) {
withFrameMillis { millis ->
time = millis
}
}
}
this.background(backgroundColor, clip)
.onSizeChanged { starState.onSizeChange(it.toSize()) }
.drawBehind {
starState.stars.forEach { star ->
with(star) { draw(time) }
}
}
}
@Composable
fun StarButton(
onClick: () -> Unit,
modifier: Modifier = Modifier,
enabled: Boolean = true,
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
elevation: ButtonElevation? = ButtonDefaults.elevation(),
shape: Shape = MaterialTheme.shapes.small,
border: BorderStroke? = null,
colors: ButtonColors = ButtonDefaults.buttonColors(),
contentPadding: PaddingValues = ButtonDefaults.ContentPadding,
content: @Composable RowScope.() -> Unit
) {
val contentColor by colors.contentColor(enabled)
Surface(
shape = shape,
color = colors.backgroundColor(enabled).value,
contentColor = contentColor.copy(alpha = 1f),
border = border,
elevation = elevation?.elevation(enabled, interactionSource)?.value ?: 0.dp,
modifier = modifier.clickable(
onClick = onClick,
enabled = enabled,
role = Role.Button,
interactionSource = interactionSource,
indication = null
)
) {
CompositionLocalProvider(LocalContentAlpha provides contentColor.alpha) {
ProvideTextStyle(
value = MaterialTheme.typography.button
) {
Row(
Modifier
.defaultMinSize(
minWidth = ButtonDefaults.MinWidth,
minHeight = ButtonDefaults.MinHeight
)
.indication(interactionSource, rememberStarIndication())
.padding(contentPadding),
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically,
content = content
)
}
}
}
}
@Composable
private fun rememberStarIndication(): Indication {
return remember { StarIndication() }
}
private class StarIndication: Indication {
@Composable
override fun rememberUpdatedInstance(interactionSource: InteractionSource): IndicationInstance {
val isPressed by interactionSource.collectIsPressedAsState()
val time = remember { mutableStateOf(0L) }
val instance = remember(interactionSource, isPressed) {
StarIndicationInstance(StarState(3), isPressed, time)
}
LaunchedEffect(isPressed, instance) {
while (isPressed) {
withFrameMillis { millis ->
time.value = millis
}
}
}
return instance
}
}
private class StarIndicationInstance(
private val starState: StarState,
private val isPressed: Boolean,
private val time: State<Long>,
private val backgroundColor: Color = Color.Black
): IndicationInstance {
override fun ContentDrawScope.drawIndication() {
starState.onSizeChange(size)
if (isPressed) {
drawRect(backgroundColor)
starState.stars.forEach { star ->
with(star) { draw(time.value) }
}
}
drawContent()
}
}
class StarState(starCount: Int) {
var starCount: Int by mutableStateOf(starCount)
private set
var warp: Float by mutableStateOf(3f)
private set
private var layerSize: Size? by mutableStateOf(null)
private val list = mutableStateOf(listOf<Star>())
internal val stars: List<Star> by list
internal fun onSizeChange(newSize: Size) {
if (!newSize.isEmpty() && newSize != layerSize) {
layerSize = newSize
regenerateStars()
} else if (newSize.isEmpty()){
// we don't have any size, stop doing any work
list.value = emptyList()
}
}
fun changeWarp(newSpeed: Float) {
warp = newSpeed.coerceIn(1f, 100f)
}
fun changeStarCount(newStarCount: Int) {
starCount = newStarCount.coerceAtLeast(0)
regenerateStars()
}
private fun regenerateStars() {
if (list.value.size == starCount) {
return
}
val localSize = layerSize ?: return
val missing = starCount - list.value.size
if (missing > 0) {
val toAdd = mutableListOf<Star>()
for (i in 0 until missing) {
toAdd.add(Star(localSize, this))
}
list.value = list.value + toAdd
} else {
list.value = list.value.dropLast(-1 * missing)
}
}
}
internal class Star(
size: Size,
private val starState: StarState
) {
private val MaxRadius = 4f;
private var initialDrawMillis: Long = 0L
private var unitOffset: Offset = randomUnitOffset()
private var initialRadius: Long = randomRadiusFor(size)
fun DrawScope.draw(currentMillis: Long) {
if (initialDrawMillis == 0L) {
initialDrawMillis = currentMillis
}
val middle = Offset(size.width / 2f, size.height / 2f)
val tick = (currentMillis - initialDrawMillis) * starState.warp
// this is all derived by "does it look right" with a vague idea I wanted it to "accelerate"
val extra: Float = (initialRadius * initialRadius).toFloat() / middle.getDistanceSquared()
val offsetMultiplier = 1f + extra + tick / 100;
val step: Float = (initialRadius / 10f).coerceIn(0.5f, 10f)
val finalRadius = initialRadius + step * tick / 1_000 * offsetMultiplier
val finalOffset = unitOffset.times(finalRadius) + middle
if (finalOffset.isIn(size)) {
drawCircle(
Color.White,
center = finalOffset,
radius = ((size.minDimension / initialRadius / 4)).coerceIn(2f, MaxRadius)
);
} else {
// this star is no more, pick some new polar coords
// Star is mutable here entirely as an optimization because list editing was a perf hit
reset(size)
}
}
override fun toString(): String {
return "Star(unitOffset=$unitOffset, initialRadius=$initialRadius, initialDrawMillis=$initialDrawMillis)"
}
private fun reset(size: Size) {
initialDrawMillis = 0L
initialRadius = randomRadiusFor(size)
unitOffset = randomUnitOffset()
}
private fun randomUnitOffset() = Random.nextDouble(Math.PI * 2).toUnitOffset()
private fun randomRadiusFor(size: Size) =
Random.nextLong(size.maxDimension.toLong() / 2).coerceAtLeast(5L)
}
private fun Double.toUnitOffset(): Offset = Offset(cos(this).toFloat(), sin(this).toFloat())
private fun Offset.isIn(size: Size): Boolean {
return x >= 0 && x <= size.width && y >= 0 && y <= size.height
}
@Preview
@Composable
private fun PreviewStars() = StarsBox(modifier = Modifier.size(100.dp))
@Preview
@Composable
private fun PreviewStarsWithViewpointAcceleration() {
val starState = remember { StarState(3) }
LaunchedEffect(starState) {
while(true) {
animate(
initialValue = 0f,
targetValue = 10f,
animationSpec = TweenSpec(15_000),
block = { value, _ ->
starState.changeWarp(value)
}
)
animate(
initialValue = 10f,
targetValue = 0f,
animationSpec = TweenSpec(15_000),
block = { value, _ ->
starState.changeWarp(value)
}
)
delay(10_000)
}
}
StarsBox(starState = starState, modifier = Modifier.size(200.dp)) {
Text(
"warp ${starState.warp.roundToInt()}",
Modifier.align(Alignment.Center),
style = LocalTextStyle.current.copy(color = Color.White)
)
}
}
@Preview
@Composable
private fun PreviewStarButton() = StarButton(
onClick = {},
modifier = Modifier.height(300.dp)
.fillMaxWidth()
) {
Text("Hold me (to see indicator)")
}
@OptIn(ExperimentalAnimationApi::class)
@Preview
@Composable
private fun TwitterPreview() {
var displayWarpPreview by remember { mutableStateOf(false) }
Column(Modifier.fillMaxSize(), verticalArrangement = Arrangement.Center) {
Row(modifier = Modifier.wrapContentSize()) {
AnimatedVisibility(
visible = displayWarpPreview
) {
val starState = remember { StarState(3) }
LaunchedEffect(starState) {
while (true) {
animate(
initialValue = 0f,
targetValue = 10f,
animationSpec = TweenSpec(15_000),
block = { value, _ ->
starState.changeWarp(value)
}
)
animate(
initialValue = 10f,
targetValue = 0f,
animationSpec = TweenSpec(15_000),
block = { value, _ ->
starState.changeWarp(value)
}
)
delay(10_000)
}
}
Row {
Spacer(Modifier.width(16.dp))
StarsBox(
starState = starState,
modifier = Modifier.size(200.dp)
) {
Text(
"warp ${starState.warp.roundToInt()}",
Modifier.align(Alignment.Center),
style = LocalTextStyle.current.copy(color = Color.White)
)
}
}
}
StarButton(
onClick = { displayWarpPreview = !displayWarpPreview},
modifier = Modifier.height(200.dp)
.fillMaxWidth()
.padding(horizontal = 16.dp)
) {
if (displayWarpPreview) {
Text("Full stop")
} else {
Text("Engage")
}
}
}
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment