Last active
December 9, 2021 10:00
-
-
Save objcode/aabd32724202b5659bc3b32a791b91d9 to your computer and use it in GitHub Desktop.
A quick animation exploration drawing a star field using Compose
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/* | |
* 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