From 7a96feb04971e164f6443bd2dece0abc259118a6 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <99822064+revonateB0T@users.noreply.github.com> Date: Thu, 17 Oct 2024 14:07:49 +0800 Subject: [PATCH] Rolling number POC, convert page index (#1740) * Rolling number POC, convert page index * Refine * Improve * Comments * Refine Comments * Bigger number is from top * Prepare to make content moveable * Make content index reversed * Use moveableContentOf * Increase interval * Fix calculation * Fix calculation again * Extract * Refine * Use zero-sized placeholder * Fix width * Allow RollingNumber have fixed width * Improve comments * Make moveableContent initialized lazily * Reverse direction * Avoid movableContentOf if width fixed * Use Arrangement.spacedBy * Improve animation * Refine * Fix comment * Fix padding --------- Co-authored-by: Dude so hot --- .../hippo/ehviewer/ui/main/RollingNumber.kt | 166 ++++++++++++++++++ .../tachiyomi/ui/reader/PageIndicatorText.kt | 23 ++- 2 files changed, 184 insertions(+), 5 deletions(-) create mode 100644 app/src/main/kotlin/com/hippo/ehviewer/ui/main/RollingNumber.kt diff --git a/app/src/main/kotlin/com/hippo/ehviewer/ui/main/RollingNumber.kt b/app/src/main/kotlin/com/hippo/ehviewer/ui/main/RollingNumber.kt new file mode 100644 index 000000000..8a9e9e08f --- /dev/null +++ b/app/src/main/kotlin/com/hippo/ehviewer/ui/main/RollingNumber.kt @@ -0,0 +1,166 @@ +package com.hippo.ehviewer.ui.main + +import androidx.compose.animation.core.animateIntSize +import androidx.compose.animation.core.animateOffset +import androidx.compose.animation.core.updateTransition +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.LocalTextStyle +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.movableContentOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clipToBounds +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.layout.layout +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.rememberTextMeasurer +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.TextUnit +import androidx.compose.ui.unit.dp +import com.hippo.ehviewer.util.unsafeLazy +import kotlin.math.PI +import kotlin.math.atan2 +import kotlin.math.cos +import kotlin.math.floor +import kotlin.math.max +import kotlin.math.sin + +// We have three space to make animation looks continuous +// 1. Concept space, where we have elements 1 2 3 4 5 6 7 8 9 0 null +// 0, 1, null is connected and the have same norm(distance) to each other +// 2. Intermediate, which is 2d euclidean space +// 0 ~ 9 in Concept space is mapped to a circle rigidly +// null is mapped to a special point(0, - 1.528f * radius) +// keeping null - 0, null - 1, 0 - 1 have same distance +// 3. Number Offset Space +// value is from -1 ~ 10, where -1 and 10 shows nothing +// radian of point in Intermediate space is mapped to here discontinuously + +// This controls animation speed +private const val RADIUS = 100f + +// Gaps between number in radian +private const val GAP = (2 * PI / 10).toFloat() +private const val ZERO_RADIAN = -(PI / 2 - GAP / 2).toFloat() + +// null is mapped to (0, - 1.528f * radius) +private const val NULL_NODE_DISTANCE = 1.528f + +// Input: Concept space: 1 2 3 4 6 6 7 8 9 0 null +// Output: Position in 2d euclidean space partial circle +private fun conceptSpaceToIntermediate(value: Int?): Offset { + if (value != null) { + val theta = ZERO_RADIAN - value * GAP + return Offset(cos(theta), sin(theta)) * RADIUS + } else { + return Offset(0f, -NULL_NODE_DISTANCE) * RADIUS + } +} + +// Output: -1 ~ 10, where -1 and 10 shows nothing +private fun intermediateToNumberOffset(circleOffset: Offset): Float { + val degree = atan2(circleOffset.y, circleOffset.x) + val normalized = normalize(ZERO_RADIAN - degree) + return if (normalized > 9 * GAP) { + val reNormalized = normalized - 9 * GAP - GAP / 2 + if (reNormalized > 0) { + // 0 side + reNormalized / GAP * 2 - 1 + } else { + // 9 side + 10 + reNormalized / GAP * 2 + } + } else { + normalized / GAP + } +} + +// Normalize radian to [0, 2 * PI] +private fun normalize(degree: Float) = (degree - 2 * PI * floor(degree / (2 * PI))).toFloat() + +// Convert 2d euclidean space partial circle to UI Offset +private fun numberOffsetToUIOffset(size: IntSize, number: Float) = IntOffset(0, -(size.height * 2 * number).toInt()) + +@Composable +fun RollingNumber(number: Int, style: TextStyle = LocalTextStyle.current, width: Int? = null) { + val string = remember(number) { "$number" } + val transition = updateTransition(string) + val textMeasurer = rememberTextMeasurer() + val size by transition.animateIntSize { str -> + remember(str) { textMeasurer.measure(str, style).size } + } + val density = LocalDensity.current + val (styleNoSpacing, spacing) = remember(density, style) { + if (style.letterSpacing != TextUnit.Unspecified) { + val spacing = with(density) { style.letterSpacing.toDp() } + style.copy(letterSpacing = TextUnit.Unspecified) to spacing + } else { + style to 0.dp + } + } + Row( + modifier = Modifier.clipToBounds().layout { measurable, constraints -> + val placeable = measurable.measure(constraints) + layout(width = placeable.width, height = size.height) { + placeable.place(0, 0) + } + }.padding(horizontal = spacing), + horizontalArrangement = Arrangement.spacedBy(spacing, Alignment.End), + ) { + val content = remember { + val meta = @Composable { reversed: Int -> + val max = width ?: with(transition) { max(currentState.length, targetState.length) } + val rotate by transition.animateOffset { str -> + val len = str.length + val absent = max - len + val where = max - reversed - 1 + val v = if (where < absent) null else str[where - absent].digitToInt() + conceptSpaceToIntermediate(v) + } + Column( + modifier = Modifier.offset { + val number = intermediateToNumberOffset(rotate) + numberOffsetToUIOffset(size, number) + }, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + repeat(10) { i -> + Text( + text = "$i", + style = styleNoSpacing, + ) + Text( + text = "", + style = styleNoSpacing, + ) + } + } + } + (0 until MAX_NUMBER).map { + unsafeLazy { + if (width != null) { + { meta(it) } + } else { + movableContentOf { meta(it) } + } + } + } + } + val max = width ?: with(transition) { max(currentState.length, targetState.length) } + check(max <= MAX_NUMBER) + repeat(max) { index -> + content[max - index - 1].value() + } + } +} + +private const val MAX_NUMBER = 16 diff --git a/app/src/main/kotlin/eu/kanade/tachiyomi/ui/reader/PageIndicatorText.kt b/app/src/main/kotlin/eu/kanade/tachiyomi/ui/reader/PageIndicatorText.kt index c639a5d8a..169c2c61c 100644 --- a/app/src/main/kotlin/eu/kanade/tachiyomi/ui/reader/PageIndicatorText.kt +++ b/app/src/main/kotlin/eu/kanade/tachiyomi/ui/reader/PageIndicatorText.kt @@ -1,6 +1,8 @@ package eu.kanade.tachiyomi.ui.reader +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -11,6 +13,7 @@ import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.sp +import com.hippo.ehviewer.ui.main.RollingNumber @Composable fun PageIndicatorText( @@ -20,8 +23,6 @@ fun PageIndicatorText( ) { if (currentPage <= 0 || totalPages <= 0) return - val text = "$currentPage / $totalPages" - val style = TextStyle( color = Color(235, 235, 235), fontSize = MaterialTheme.typography.bodySmall.fontSize, @@ -33,8 +34,20 @@ fun PageIndicatorText( drawStyle = Stroke(width = 4f), ) - Box(modifier = modifier, contentAlignment = Alignment.Center) { - Text(text = text, style = strokeStyle) - Text(text = text, style = style) + val total = "$totalPages" + + Row(modifier = modifier, horizontalArrangement = Arrangement.Center) { + Box(modifier = modifier, contentAlignment = Alignment.Center) { + RollingNumber(number = currentPage, style = strokeStyle, width = total.length) + RollingNumber(number = currentPage, style = style, width = total.length) + } + Box(modifier = modifier, contentAlignment = Alignment.Center) { + Text(text = " / ", style = strokeStyle) + Text(text = " / ", style = style) + } + Box(modifier = modifier, contentAlignment = Alignment.Center) { + Text(text = total, style = strokeStyle) + Text(text = total, style = style) + } } }