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:
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user