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 <djohn@fbi.gov>
This commit is contained in:
renovate[bot]andDude so hot authored and GitHub committed 2024-10-17 06:07:49 +00:00
1 parent 5df1d099a1
commit 7a96feb049
2 files changed
+184 -5

No files matched your search

@@ -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
@@ -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)
}
}
}