Last active
October 31, 2024 11:37
-
-
Save fvilarino/a99849319e495f834a55e59ff322bf46 to your computer and use it in GitHub Desktop.
Ticker Final
This file contains 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
private val TickerCycleMillis = 150 | |
private object AlphabetMapper { | |
private val Alphabet = " ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789•".toList() | |
val size: Int = Alphabet.size | |
fun getLetterAt(index: Int): Char = Alphabet[index % size] | |
fun getIndexOf(letter: Char, offset: Int = 0): TickerIndex { | |
var index = Alphabet.indexOf(letter.uppercaseChar()) | |
index = if (index < 0) Alphabet.lastIndex else index | |
val offsetIndex = if (index < offset) { | |
index + (size * (offset / size + 1)) | |
} else { | |
index | |
} | |
return TickerIndex(rawIndex = index, offsetIndex = offsetIndex) | |
} | |
} | |
@JvmInline | |
value class TickerIndex private constructor(private val packedIndex: Int) { | |
val index: Int | |
get() = (packedIndex and 0xFFFF0000.toInt()) shr 16 | |
val offsetIndex: Int | |
get() = packedIndex and 0x0000FFFF | |
companion object { | |
operator fun invoke( | |
rawIndex: Int, | |
offsetIndex: Int, | |
) = TickerIndex( | |
((rawIndex and 0x0000FFFF) shl 16) + (offsetIndex and 0x0000FFFF) | |
) | |
} | |
} | |
@Stable | |
class TickerStateHolder { | |
private val animatable = Animatable(0f) | |
val value: Float | |
get() = animatable.value | |
val index: Int | |
get() = animatable.value.toInt() | |
suspend fun animateTo(target: TickerIndex) { | |
val currentIndex = animatable.value.toInt() | |
val result = animatable.animateTo( | |
targetValue = target.offsetIndex.toFloat(), | |
animationSpec = tween( | |
durationMillis = (target.offsetIndex - currentIndex) * TickerCycleMillis, | |
easing = FastOutSlowInEasing, | |
) | |
) | |
if (result.endReason == AnimationEndReason.Finished) { | |
snapTo(target.index) | |
} | |
} | |
private suspend fun snapTo(index: Int) { | |
animatable.snapTo(index.toFloat()) | |
} | |
} | |
@Composable | |
fun TickerBoard( | |
text: String, | |
numColumns: Int, | |
numRows: Int, | |
modifier: Modifier = Modifier, | |
textColor: Color = Color.White, | |
backgroundColor: Color = Color.Black, | |
fontSize: TextUnit = 96.sp, | |
horizontalArrangement: Arrangement.Horizontal = Arrangement.spacedBy(8.dp), | |
verticalArrangement: Arrangement.Vertical = Arrangement.spacedBy(8.dp), | |
) { | |
val padded = text.padEnd(numColumns * numRows, ' ') | |
Column( | |
modifier = modifier, | |
verticalArrangement = verticalArrangement, | |
) { | |
repeat(numRows) { row -> | |
TickerRow( | |
text = padded.substring(startIndex = row * numColumns), | |
numCells = numColumns, | |
horizontalArrangement = horizontalArrangement, | |
textColor = textColor, | |
backgroundColor = backgroundColor, | |
fontSize = fontSize, | |
) | |
} | |
} | |
} | |
@Composable | |
fun TickerRow( | |
text: String, | |
numCells: Int, | |
modifier: Modifier = Modifier, | |
textColor: Color = Color.White, | |
backgroundColor: Color = Color.Black, | |
fontSize: TextUnit = 96.sp, | |
horizontalArrangement: Arrangement.Horizontal = Arrangement.spacedBy(8.dp), | |
) { | |
Row( | |
modifier = modifier, | |
horizontalArrangement = horizontalArrangement | |
) { | |
repeat(numCells) { index -> | |
Ticker( | |
letter = text.getOrNull(index) ?: ' ', | |
textColor = textColor, | |
backgroundColor = backgroundColor, | |
fontSize = fontSize | |
) | |
} | |
} | |
} | |
@Composable | |
fun rememberTickerState() = remember { | |
TickerStateHolder() | |
} | |
@Composable | |
fun Ticker( | |
letter: Char, | |
modifier: Modifier = Modifier, | |
textColor: Color = Color.White, | |
backgroundColor: Color = Color.Black, | |
fontSize: TextUnit = 96.sp, | |
contentPadding: PaddingValues = PaddingValues(all = 8.dp), | |
state: TickerStateHolder = rememberTickerState(), | |
) { | |
LaunchedEffect(key1 = letter) { | |
val currentIndex = state.index | |
val index = AlphabetMapper.getIndexOf(letter = letter, offset = currentIndex) | |
state.animateTo(index) | |
} | |
val fraction = state.value - state.value.toInt() | |
val rotation = -180f * fraction | |
val currentLetter = AlphabetMapper.getLetterAt(state.index) | |
val nextLetter = AlphabetMapper.getLetterAt(state.index + 1) | |
Box( | |
modifier = modifier | |
) { | |
BackgroundLetter( | |
currentLetter = currentLetter, | |
nextLetter = nextLetter, | |
textColor = textColor, | |
backgroundColor = backgroundColor, | |
fontSize = fontSize, | |
contentPadding = contentPadding, | |
) | |
Box( | |
modifier = Modifier | |
.graphicsLayer { | |
rotationX = rotation | |
cameraDistance = 6f * density | |
transformOrigin = TransformOrigin(.5f, 1f) | |
} | |
) { | |
if (fraction <= .5f) { | |
TopHalf { | |
CenteredText( | |
letter = currentLetter, | |
contentPadding = contentPadding, | |
textColor = textColor, | |
backgroundColor = backgroundColor, | |
fontSize = fontSize, | |
) | |
} | |
} else { | |
BottomHalf( | |
modifier = Modifier.graphicsLayer { | |
rotationX = 180f | |
} | |
) { | |
CenteredText( | |
letter = nextLetter, | |
contentPadding = contentPadding, | |
textColor = textColor, | |
backgroundColor = backgroundColor, | |
fontSize = fontSize, | |
) | |
} | |
} | |
} | |
} | |
} | |
@Composable | |
private fun BackgroundLetter( | |
currentLetter: Char, | |
nextLetter: Char, | |
modifier: Modifier = Modifier, | |
textColor: Color = Color.White, | |
backgroundColor: Color = Color.Black, | |
fontSize: TextUnit = 96.sp, | |
contentPadding: PaddingValues = PaddingValues(all = 8.dp), | |
) { | |
Column( | |
modifier = modifier, | |
) { | |
TopHalf { | |
CenteredText( | |
letter = nextLetter, | |
textColor = textColor, | |
backgroundColor = backgroundColor, | |
fontSize = fontSize, | |
contentPadding = contentPadding | |
) | |
} | |
BottomHalf { | |
CenteredText( | |
letter = currentLetter, | |
textColor = textColor, | |
backgroundColor = backgroundColor, | |
fontSize = fontSize, | |
contentPadding = contentPadding | |
) | |
} | |
} | |
} | |
@Composable | |
private fun TopHalf( | |
modifier: Modifier = Modifier, | |
content: @Composable () -> Unit, | |
) { | |
HalfChild( | |
modifier = modifier, | |
topHalf = true, | |
content = content, | |
) | |
} | |
@Composable | |
private fun BottomHalf( | |
modifier: Modifier = Modifier, | |
content: @Composable () -> Unit, | |
) { | |
HalfChild( | |
modifier = modifier, | |
topHalf = false, | |
content = content, | |
) | |
} | |
@Composable | |
private fun HalfChild( | |
modifier: Modifier = Modifier, | |
topHalf: Boolean = true, | |
content: @Composable () -> Unit, | |
) { | |
Layout( | |
modifier = modifier.clipToBounds(), | |
content = content, | |
) { measurables, constraints -> | |
require(measurables.size == 1) { "This composable expects a single child" } | |
val placeable = measurables.first().measure(constraints) | |
val height = placeable.height / 2 | |
layout( | |
width = placeable.width, | |
height = height, | |
) { | |
placeable.placeRelative( | |
x = 0, | |
y = if (topHalf) 0 else -height, | |
) | |
} | |
} | |
} | |
@Composable | |
private fun CenteredText( | |
letter: Char, | |
modifier: Modifier = Modifier, | |
textColor: Color = Color.White, | |
backgroundColor: Color = Color.Black, | |
fontSize: TextUnit = 96.sp, | |
contentPadding: PaddingValues = PaddingValues(all = 8.dp), | |
) { | |
var ascent by remember { | |
mutableStateOf(0f) | |
} | |
var middle by remember { | |
mutableStateOf(0f) | |
} | |
var baseline by remember { | |
mutableStateOf(0f) | |
} | |
var top by remember { | |
mutableStateOf(0f) | |
} | |
var bottom by remember { | |
mutableStateOf(0f) | |
} | |
val delta: Float by remember { | |
derivedStateOf { | |
((bottom - baseline) - (ascent - top)) / 2f | |
} | |
} | |
val direction = LocalLayoutDirection.current | |
val startPadding = contentPadding.calculateStartPadding(direction) | |
val endPadding = contentPadding.calculateEndPadding(direction) | |
Text( | |
text = letter.toString(), | |
color = textColor, | |
fontFamily = FontFamily.Monospace, | |
fontSize = fontSize, | |
modifier = modifier | |
.background(backgroundColor) | |
.padding(paddingValues = contentPadding) | |
.drawBehind { | |
drawLine( | |
textColor, | |
Offset(x = -startPadding.value * density, y = center.y), | |
Offset( | |
x = size.width + (startPadding + endPadding).value * density, | |
y = center.y | |
), | |
strokeWidth = 2f * density, | |
) | |
} | |
.offset { | |
IntOffset(x = 0, y = delta.roundToInt()) | |
}, | |
onTextLayout = { textLayoutResult -> | |
val layoutInput = textLayoutResult.layoutInput | |
val fontSizePx = with(layoutInput.density) { layoutInput.style.fontSize.toPx() } | |
baseline = textLayoutResult.firstBaseline | |
top = textLayoutResult.getLineTop(0) | |
bottom = textLayoutResult.getLineBottom(0) | |
middle = bottom - top | |
ascent = bottom - fontSizePx | |
} | |
) | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment