[Java] 纯文本查看 复制代码
package com.etye.reader.reader
import kotlin.math.abs
import kotlin.math.hypot
import kotlin.math.sin
internal data class CurlPoint(val x: Float, val y: Float)
internal data class CurlFrame(
val touch: CurlPoint,
val corner: CurlPoint,
val start1: CurlPoint,
val control1: CurlPoint,
val end1: CurlPoint,
val vertex1: CurlPoint,
val start2: CurlPoint,
val control2: CurlPoint,
val end2: CurlPoint,
val vertex2: CurlPoint,
)
/**
* Bezier page curl used by simulated page turns.
* The touch point is where the finger holds the sheet; the corner is the
* page corner being lifted (right/left, and top or bottom).
*/
internal object CurlGeometry {
fun touch(
progress: Float,
forward: Boolean,
width: Float,
height: Float,
fromTop: Boolean = false,
): CurlPoint {
val p = progress.coerceIn(0f, 1f)
val lift = sin(p * Math.PI).toFloat() * height * 0.14f
val travel = p * width * 1.65f
val x = if (forward) width - 2f - travel else 2f + travel
val y = if (fromTop) 2f + lift + p * height * 0.06f else height - 2f - lift - p * height * 0.06f
return CurlPoint(x, y)
}
fun corner(forward: Boolean, width: Float, height: Float, fromTop: Boolean = false): CurlPoint {
return CurlPoint(if (forward) width else 0f, if (fromTop) 0f else height)
}
fun frame(touch: CurlPoint, corner: CurlPoint, width: Float, height: Float): CurlFrame? {
var ax = touch.x
var ay = touch.y
if (abs(corner.x - ax) < 1f) ax = corner.x + if (corner.x > width / 2f) -1f else 1f
if (abs(corner.y - ay) < 1f) {
ay = if (corner.y <= height / 2f) corner.y + 1f else corner.y - 1f
}
val adjusted = limitTouch(CurlPoint(ax, ay), corner, width, height)
return points(adjusted, corner)
}
private fun limitTouch(touch: CurlPoint, corner: CurlPoint, width: Float, height: Float): CurlPoint {
if (touch.x <= 0f || touch.x >= width) return touch
val first = points(touch, corner) ?: return touch
if (first.start1.x in 0f..width) return touch
val reflected = if (first.start1.x < 0f) width - first.start1.x else first.start1.x
if (reflected < 1f) return touch
val horizontal = abs(corner.x - touch.x)
if (horizontal < 1f) return touch
val nextX = abs(corner.x - width * horizontal / reflected)
val nextY = abs(corner.y - abs(corner.x - nextX) * abs(corner.y - touch.y) / horizontal)
val maxY = (height - 1f).coerceAtLeast(1f)
return CurlPoint(nextX, nextY.coerceIn(1f, maxY))
}
private fun points(touch: CurlPoint, corner: CurlPoint): CurlFrame? {
val middleX = (touch.x + corner.x) / 2f
val middleY = (touch.y + corner.y) / 2f
val dx = corner.x - middleX
val dy = corner.y - middleY
if (abs(dx) < 0.5f || abs(dy) < 0.5f) return null
val control1 = CurlPoint(middleX - dy * dy / dx, corner.y)
val control2 = CurlPoint(corner.x, middleY - dx * dx / dy)
val start1 = CurlPoint(control1.x - (corner.x - control1.x) / 2f, corner.y)
val start2 = CurlPoint(corner.x, control2.y - (corner.y - control2.y) / 2f)
val end1 = cross(touch, control1, start1, start2)
val end2 = cross(touch, control2, start1, start2)
if (!end1.finite() || !end2.finite()) return null
val vertex1 = CurlPoint(
(start1.x + 2f * control1.x + end1.x) / 4f,
(start1.y + 2f * control1.y + end1.y) / 4f,
)
val vertex2 = CurlPoint(
(start2.x + 2f * control2.x + end2.x) / 4f,
(start2.y + 2f * control2.y + end2.y) / 4f,
)
if (!vertex1.finite() || !vertex2.finite()) return null
return CurlFrame(touch, corner, start1, control1, end1, vertex1, start2, control2, end2, vertex2)
}
private fun cross(a: CurlPoint, b: CurlPoint, c: CurlPoint, d: CurlPoint): CurlPoint {
val denom = (d.y - c.y) * (b.x - a.x) - (d.x - c.x) * (b.y - a.y)
if (abs(denom) < 0.001f) return b
val ua = ((d.x - c.x) * (a.y - c.y) - (d.y - c.y) * (a.x - c.x)) / denom
return CurlPoint(a.x + ua * (b.x - a.x), a.y + ua * (b.y - a.y))
}
private fun CurlPoint.finite(): Boolean = x.isFinite() && y.isFinite()
fun peelDistance(frame: CurlFrame): Float {
return hypot(frame.touch.x - frame.corner.x, frame.touch.y - frame.corner.y)
}
}
//////// FILE: app/src/main/java/com/etye/reader/reader/SlideSettle.kt ////////
package com.etye.reader.reader
import kotlin.math.abs
import kotlin.math.roundToInt
/**
* Decides whether releasing a slide should finish the turn.
*
* [progress] is how far the finger has pulled the page, as a fraction of the page.
* [velocityPxPerSec] follows the finger: negative moves toward the next page.
* [towardNext] is the side that is open when the finger lifts.
* A short pull still finishes when the flick is fast. A pull past the midpoint
* finishes even if the finger starts back. A clear pull back below the midpoint rebounds.
*/
internal fun slideShouldFinish(
progress: Float,
velocityPxPerSec: Float,
towardNext: Boolean,
flingPxPerSec: Float = 1100f,
): Boolean {
if (progress >= 0.5f) return true
val speed = abs(velocityPxPerSec)
val fling = flingPxPerSec.coerceAtLeast(1f)
val opposite = if (towardNext) velocityPxPerSec > 0f else velocityPxPerSec < 0f
val sameWay = if (towardNext) velocityPxPerSec < 0f else velocityPxPerSec > 0f
if (opposite && speed >= fling * 0.65f) return false
if (progress >= 0.16f) return true
if (sameWay && speed >= fling && progress > 0.04f) return true
return false
}
/**
* Whole-pixel position of the page that is sliding away.
* The incoming page is placed exactly one span away, so the two edges meet
* and never share a pixel. During the settle, the final pixel snaps off
* instead of sitting on screen as a fractional sliver.
*/
internal fun slideDrawOffset(offset: Float, span: Int, settling: Boolean): Int {
val distance = span.coerceAtLeast(1)
if (settling && abs(offset) > 1f && abs(distance - abs(offset)) <= 1f) {
return if (offset < 0f) -distance else distance
}
return offset.roundToInt().coerceIn(-distance, distance)
}
//////// FILE: app/src/main/java/com/etye/reader/reader/Paginator.kt ////////
package com.etye.reader.reader
import android.graphics.Paint
import android.os.Build
import android.text.Layout
import android.text.StaticLayout
import android.text.TextPaint
import java.util.Locale
internal fun readerLayout(
text: CharSequence,
start: Int,
end: Int,
paint: TextPaint,
widthPx: Int,
lineSpacing: Float,
): StaticLayout {
val safeStart = start.coerceIn(0, text.length)
val safeEnd = end.coerceIn(safeStart, text.length)
val builder = StaticLayout.Builder.obtain(text, safeStart, safeEnd, paint, widthPx.coerceAtLeast(1))
.setAlignment(Layout.Alignment.ALIGN_NORMAL)
.setIncludePad(false)
.setLineSpacing(0f, lineSpacing)
.setBreakStrategy(Layout.BREAK_STRATEGY_SIMPLE)
.setHyphenationFrequency(Layout.HYPHENATION_FREQUENCY_NONE)
if (Build.VERSION.SDK_INT >= 28) {
builder.setUseLineSpacingFromFallbacks(false)
}
return builder.build()
}
/** Height the last line's box must stay inside, leaving a gap above the footer. */
internal fun pageUsableHeight(heightPx: Int, fontPx: Float): Int {
val guard = (fontPx * 0.2f).toInt().coerceAtLeast(1)
return (heightPx - guard).coerceAtLeast(1)
}
/** Bottom of the last line whose box sits fully inside [maxHeight]. */
internal fun fittedLineBottom(layout: StaticLayout, maxHeight: Int): Int {
if (maxHeight <= 0 || layout.lineCount == 0) return 0
var fitted = 0
for (line in 0 until layout.lineCount) {
val bottom = layout.getLineBottom(line)
if (bottom <= maxHeight) fitted = bottom else break
}
return if (fitted > 0) fitted else layout.getLineBottom(0).coerceAtMost(maxHeight)
}
object Paginator {
/**
* Pages are cut with the same StaticLayout the page views draw, so the
* last line's box ends above the footer instead of being clipped through
* the glyphs. Each layout is only about one page long.
*/
fun paginate(
text: String,
widthPx: Int,
heightPx: Int,
fontPx: Float,
lineSpacing: Float,
): List<Int> {
if (text.isEmpty() || widthPx <= 0 || heightPx <= 0) return listOf(0)
val paint = TextPaint(Paint.ANTI_ALIAS_FLAG).apply {
textSize = fontPx
isElegantTextHeight = false
textLocale = Locale.SIMPLIFIED_CHINESE
}
val usableHeight = pageUsableHeight(heightPx, fontPx)
val probe = readerLayout("字\n字", 0, 3, paint, widthPx, lineSpacing)
val lineHeight = if (probe.lineCount >= 2) {
(probe.getLineBottom(1) - probe.getLineBottom(0)).coerceAtLeast(1)
} else {
probe.getLineBottom(0).coerceAtLeast(1)
}
val linesGuess = (usableHeight / lineHeight).coerceAtLeast(1)
val charsPerLine = (widthPx / fontPx.coerceAtLeast(1f)).toInt().coerceAtLeast(8)
var span = (linesGuess * charsPerLine + charsPerLine).coerceIn(80, 2000)
val starts = ArrayList<Int>(text.length / (linesGuess * charsPerLine).coerceAtLeast(32) + 1)
var index = 0
var guardCount = 0
while (index < text.length && guardCount++ < text.length + 2) {
starts += index
var pageEnd = minOf(text.length, index + 1)
while (true) {
val windowEnd = minOf(text.length, index + span)
val layout = readerLayout(text, index, windowEnd, paint, widthPx, lineSpacing)
if (layout.lineCount == 0) break
val entireFits = layout.getLineBottom(layout.lineCount - 1) <= usableHeight
if (entireFits && windowEnd < text.length) {
val grown = (span * 2).coerceAtMost((text.length - index).coerceAtLeast(span + 1))
if (grown <= span) {
pageEnd = windowEnd
break
}
span = grown
continue
}
var lastFit = -1
for (line in 0 until layout.lineCount) {
if (layout.getLineBottom(line) <= usableHeight) lastFit = line else break
}
pageEnd = if (lastFit >= 0) {
absoluteLineEnd(layout, lastFit, index)
} else {
val forced = absoluteLineEnd(layout, 0, index)
if (forced > index) forced else minOf(text.length, index + 1)
}
break
}
if (pageEnd <= index) pageEnd = minOf(text.length, index + 1)
index = pageEnd
}
return starts.ifEmpty { listOf(0) }
}
private fun absoluteLineEnd(layout: StaticLayout, line: Int, rangeStart: Int): Int {
val end = layout.getLineEnd(line)
return if (rangeStart > 0 && layout.getLineStart(0) == 0) rangeStart + end else end
}
}
//////// FILE: app/src/main/java/com/etye/reader/reader/PageCurlView.kt ////////
package com.etye.reader.reader
import android.animation.Animator
import android.animation.AnimatorListenerAdapter
import android.animation.ValueAnimator
import android.content.Context
import android.graphics.Bitmap
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.ColorMatrix
import android.graphics.ColorMatrixColorFilter
import android.graphics.LinearGradient
import android.graphics.Matrix
import android.graphics.Paint
import android.graphics.Path
import android.graphics.Shader
import android.text.StaticLayout
import android.text.TextPaint
import android.util.AttributeSet
import android.view.View
import android.view.animation.PathInterpolator
import java.util.Locale
import kotlin.math.hypot
/**
* Full-page curl. The bitmap is the whole reading sheet, margins included,
* and the corner follows the finger. The next page shows through the opening,
* and the lifted flap is a mirrored copy of this page. Releasing past the
* threshold finishes the turn, otherwise the sheet springs back.
*/
class PageCurlView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
) : View(context, attrs) {
var isTurning: Boolean = false
private set
private var pageText: String = ""
private var pendingText: String = ""
private var textColor: Int = 0xFF3A3228.toInt()
private var pageColor: Int = 0xFFF6F1E3.toInt()
private var fontPx: Float = 48f
private var spacing: Float = 1.75f
private var insetLeft: Int = 0
private var insetTop: Int = 0
private var insetRight: Int = 0
private var insetBottom: Int = 0
private var forward: Boolean = true
private var fromTop: Boolean = false
private var scripted: Boolean = false
private var dragging: Boolean = false
private var commitOnEnd: Boolean = true
private var progress: Float = 0f
private var touchX: Float = 0f
private var touchY: Float = 0f
private var turnToken: Int = 0
private var currentBitmap: Bitmap? = null
private var nextBitmap: Bitmap? = null
private var turnCallback: (() -> Unit)? = null
private var animator: ValueAnimator? = null
private val bitmapPaint = Paint(Paint.ANTI_ALIAS_FLAG or Paint.FILTER_BITMAP_FLAG)
private val backPaint = Paint(Paint.ANTI_ALIAS_FLAG or Paint.FILTER_BITMAP_FLAG).apply {
colorFilter = ColorMatrixColorFilter(
ColorMatrix(
floatArrayOf(
0.55f, 0.04f, 0.02f, 0f, 38f,
0.02f, 0.50f, 0.02f, 0f, 32f,
0.01f, 0.03f, 0.42f, 0f, 24f,
0f, 0f, 0f, 1f, 0f,
),
),
)
}
private val fillPaint = Paint(Paint.ANTI_ALIAS_FLAG)
private val shadePaint = Paint(Paint.ANTI_ALIAS_FLAG)
private val peelPath = Path()
private val backPath = Path()
private val mirrorMatrix = Matrix()
private val matrixValues = FloatArray(9)
fun show(text: String, textColor: Int, pageColor: Int, fontPx: Float, spacing: Float) {
val changed = text != pageText || textColor != this.textColor || pageColor != this.pageColor ||
fontPx != this.fontPx || spacing != this.spacing
this.textColor = textColor
this.pageColor = pageColor
this.fontPx = fontPx
this.spacing = spacing
if (isTurning) return
if (!changed && text == pageText) return
pageText = text
redraw()
}
/** Paper margins inside the sheet. They curl with the page instead of framing it. */
fun contentInset(left: Int, top: Int, right: Int, bottom: Int) {
if (left == insetLeft && top == insetTop && right == insetRight && bottom == insetBottom) return
insetLeft = left.coerceAtLeast(0)
insetTop = top.coerceAtLeast(0)
insetRight = right.coerceAtLeast(0)
insetBottom = bottom.coerceAtLeast(0)
if (!isTurning) redraw()
}
/** Compose on some Harmony builds drops a child invalidate, so the parent must be marked too. */
private fun redraw() {
invalidate()
val host = parent
if (host is View) host.invalidate()
}
fun turn(
toText: String,
forward: Boolean,
alignEnd: Boolean = false,
fromTop: Boolean = false,
onEnd: () -> Unit,
): Boolean {
if (!arm(toText, forward, alignEnd, onEnd)) return !isTurning
scripted = true
dragging = false
commitOnEnd = true
this.fromTop = fromTop
progress = 0f
animate(0f, 1f, 560L) { value ->
progress = value
redraw()
}
return true
}
/** Finger-down after the drag is clearly horizontal. [x] and [y] are the virtual touch point. */
fun grab(
toText: String,
forward: Boolean,
alignEnd: Boolean,
x: Float,
y: Float,
onCommit: () -> Unit,
): Boolean {
if (!arm(toText, forward, alignEnd, onCommit)) return !isTurning
scripted = false
dragging = true
commitOnEnd = false
this.fromTop = y < height * 0.5f
touchX = x
touchY = y
redraw()
return true
}
fun follow(x: Float, y: Float) {
if (!dragging) return
touchX = x
touchY = y
val heightF = height.toFloat()
fromTop = when {
y < heightF * 0.42f -> true
y > heightF * 0.58f -> false
else -> fromTop
}
redraw()
}
fun release() {
if (!dragging) return
dragging = false
val widthF = width.toFloat().coerceAtLeast(1f)
val heightF = height.toFloat().coerceAtLeast(1f)
val traveled = if (forward) (widthF - touchX) / widthF else touchX / widthF
val finish = traveled >= 0.25f
commitOnEnd = finish
val startX = touchX
val startY = touchY
val end = if (finish) exitPoint(widthF, heightF) else restPoint(widthF, heightF)
animate(0f, 1f, if (finish) 380L else 260L) { t ->
touchX = startX + (end.x - startX) * t
touchY = startY + (end.y - startY) * t
redraw()
}
}
override fun onDetachedFromWindow() {
turnToken += 1
animator?.cancel()
animator = null
dragging = false
isTurning = false
turnCallback = null
recycleBitmaps()
super.onDetachedFromWindow()
}
override fun onDraw(canvas: Canvas) {
val current = currentBitmap
val next = nextBitmap
if (!isTurning || current == null || next == null) {
canvas.drawColor(pageColor)
drawText(canvas, pageText)
return
}
val widthF = width.toFloat()
val heightF = height.toFloat()
val touch = if (scripted) {
CurlGeometry.touch(progress, forward, widthF, heightF, fromTop)
} else {
CurlPoint(touchX, touchY)
}
val corner = CurlGeometry.corner(forward, widthF, heightF, fromTop)
val frame = CurlGeometry.frame(touch, corner, widthF, heightF)
val opened = if (scripted) progress > 0.92f else horizontalProgress(touch, widthF) > 0.92f
if (frame == null || CurlGeometry.peelDistance(frame) < 12f) {
canvas.drawColor(pageColor)
canvas.drawBitmap(if (opened) next else current, 0f, 0f, bitmapPaint)
return
}
buildPeel(frame)
buildBack(frame)
val density = resources.displayMetrics.density
// clipPath stays on the GPU. clipOutPath falls back to software on many
// Harmony GPUs, which drops the curl and makes the turn look disabled.
canvas.drawBitmap(current, 0f, 0f, bitmapPaint)
canvas.save()
canvas.clipPath(peelPath)
canvas.drawBitmap(next, 0f, 0f, bitmapPaint)
drawCastShadow(canvas, frame.vertex1, frame.vertex2, frame.corner, 78, 36f * density)
canvas.restore()
drawMirroredBack(canvas, frame, current)
drawCastShadow(canvas, frame.vertex1, frame.vertex2, frame.touch, 64, 20f * density)
drawRidge(canvas, frame)
}
private fun arm(toText: String, forward: Boolean, alignEnd: Boolean, onEnd: () -> Unit): Boolean {
if (isTurning) return false
if (width <= 1 || height <= 1) {
pageText = toText
redraw()
onEnd()
return false
}
val from = render(pageText, alignEnd = false)
val to = render(toText, alignEnd = alignEnd)
if (from == null || to == null) {
from?.recycle()
to?.recycle()
pageText = toText
redraw()
onEnd()
return false
}
isTurning = true
this.forward = forward
pendingText = toText
currentBitmap = from
nextBitmap = to
turnCallback = onEnd
return true
}
private fun animate(from: Float, to: Float, duration: Long, onFrame: (Float) -> Unit) {
val token = turnToken
animator?.cancel()
animator = ValueAnimator.ofFloat(from, to).apply {
this.duration = duration
interpolator = PathInterpolator(0.22f, 0.08f, 0.18f, 1f)
addUpdateListener { value -> onFrame(value.animatedValue as Float) }
addListener(object : AnimatorListenerAdapter() {
private var cancelled = false
override fun onAnimationCancel(animation: Animator) {
cancelled = true
}
override fun onAnimationEnd(animation: Animator) {
if (cancelled || token != turnToken || !isTurning) return
if (commitOnEnd) completeTurn() else revertTurn()
}
})
start()
}
}
private fun exitPoint(widthF: Float, heightF: Float): CurlPoint {
val x = if (forward) -widthF * 0.35f else widthF * 1.35f
val y = if (fromTop) heightF * 0.38f else heightF * 0.62f
return CurlPoint(x, y)
}
private fun restPoint(widthF: Float, heightF: Float): CurlPoint {
val x = if (forward) widthF - 1.5f else 1.5f
val y = if (fromTop) 1.5f else heightF - 1.5f
return CurlPoint(x, y)
}
private fun horizontalProgress(touch: CurlPoint, widthF: Float): Float {
if (widthF <= 1f) return 0f
return if (forward) (widthF - touch.x) / widthF else touch.x / widthF
}
private fun buildPeel(frame: CurlFrame) {
peelPath.reset()
peelPath.moveTo(frame.start1.x, frame.start1.y)
peelPath.quadTo(frame.control1.x, frame.control1.y, frame.end1.x, frame.end1.y)
peelPath.lineTo(frame.touch.x, frame.touch.y)
peelPath.lineTo(frame.end2.x, frame.end2.y)
peelPath.quadTo(frame.control2.x, frame.control2.y, frame.start2.x, frame.start2.y)
peelPath.lineTo(frame.corner.x, frame.corner.y)
peelPath.close()
}
private fun buildBack(frame: CurlFrame) {
backPath.reset()
backPath.moveTo(frame.vertex2.x, frame.vertex2.y)
backPath.lineTo(frame.vertex1.x, frame.vertex1.y)
backPath.lineTo(frame.end1.x, frame.end1.y)
backPath.lineTo(frame.touch.x, frame.touch.y)
backPath.lineTo(frame.end2.x, frame.end2.y)
backPath.close()
}
private fun drawMirroredBack(canvas: Canvas, frame: CurlFrame, bitmap: Bitmap) {
val dis = hypot(frame.corner.x - frame.control1.x, frame.control2.y - frame.corner.y)
if (dis < 1f) return
val f8 = (frame.corner.x - frame.control1.x) / dis
val f9 = (frame.control2.y - frame.corner.y) / dis
matrixValues[0] = 1f - 2f * f9 * f9
matrixValues[1] = 2f * f8 * f9
matrixValues[2] = 0f
matrixValues[3] = matrixValues[1]
matrixValues[4] = 1f - 2f * f8 * f8
matrixValues[5] = 0f
matrixValues[6] = 0f
matrixValues[7] = 0f
matrixValues[8] = 1f
mirrorMatrix.setValues(matrixValues)
mirrorMatrix.preTranslate(-frame.control1.x, -frame.control1.y)
mirrorMatrix.postTranslate(frame.control1.x, frame.control1.y)
canvas.save()
canvas.clipPath(backPath)
canvas.drawBitmap(bitmap, mirrorMatrix, backPaint)
val crestX = (frame.vertex1.x + frame.vertex2.x) / 2f
val crestY = (frame.vertex1.y + frame.vertex2.y) / 2f
fillPaint.shader = LinearGradient(
crestX,
crestY,
frame.touch.x,
frame.touch.y,
Color.argb(28, 255, 248, 236),
Color.argb(96, 42, 30, 18),
Shader.TileMode.CLAMP,
)
canvas.drawPath(backPath, fillPaint)
fillPaint.shader = null
canvas.restore()
}
private fun drawCastShadow(
canvas: Canvas,
from: CurlPoint,
to: CurlPoint,
toward: CurlPoint,
alpha: Int,
reach: Float,
) {
val dx = to.x - from.x
val dy = to.y - from.y
val length = hypot(dx, dy)
if (length < 2f || reach < 1f) return
var px = -dy / length
var py = dx / length
val midX = (from.x + to.x) / 2f
val midY = (from.y + to.y) / 2f
if (px * (toward.x - midX) + py * (toward.y - midY) < 0f) {
px = -px
py = -py
}
val path = Path()
path.moveTo(from.x, from.y)
path.lineTo(to.x, to.y)
path.lineTo(to.x + px * reach, to.y + py * reach)
path.lineTo(from.x + px * reach, from.y + py * reach)
path.close()
shadePaint.shader = LinearGradient(
midX,
midY,
midX + px * reach,
midY + py * reach,
Color.argb(alpha, 32, 24, 16),
Color.TRANSPARENT,
Shader.TileMode.CLAMP,
)
canvas.drawPath(path, shadePaint)
shadePaint.shader = null
}
private fun drawRidge(canvas: Canvas, frame: CurlFrame) {
shadePaint.shader = null
shadePaint.color = Color.argb(110, 255, 250, 242)
shadePaint.strokeWidth = 1.5f * resources.displayMetrics.density
shadePaint.style = Paint.Style.STROKE
canvas.drawLine(frame.vertex1.x, frame.vertex1.y, frame.vertex2.x, frame.vertex2.y, shadePaint)
shadePaint.style = Paint.Style.FILL
}
private fun completeTurn() {
val callback = turnCallback
turnCallback = null
isTurning = false
dragging = false
pageText = pendingText
recycleBitmaps()
animator = null
redraw()
callback?.invoke()
}
private fun revertTurn() {
turnCallback = null
isTurning = false
dragging = false
recycleBitmaps()
animator = null
redraw()
}
private fun recycleBitmaps() {
currentBitmap?.let { if (!it.isRecycled) it.recycle() }
nextBitmap?.let { if (!it.isRecycled) it.recycle() }
currentBitmap = null
nextBitmap = null
}
private fun render(text: String, alignEnd: Boolean): Bitmap? {
return try {
val bitmap = PageSheet.bitmap(width, height)
val canvas = Canvas(bitmap)
canvas.drawColor(pageColor)
drawPageContent(canvas, text, alignEnd)
bitmap
} catch (_: OutOfMemoryError) {
null
}
}
private fun drawText(canvas: Canvas, text: String) {
if (text.isEmpty() || width <= 1) return
drawPageContent(canvas, text, alignEnd = false)
}
private fun drawPageContent(canvas: Canvas, text: String, alignEnd: Boolean) {
val textWidth = (width - insetLeft - insetRight).coerceAtLeast(1)
val textHeight = (height - insetTop - insetBottom).coerceAtLeast(1)
val layout = layoutOf(PageSheet.textWindow(text, alignEnd), textWidth)
val limit = pageUsableHeight(textHeight, fontPx)
canvas.save()
canvas.translate(insetLeft.toFloat(), insetTop.toFloat())
if (alignEnd) {
// Clip the box first, then slide the tail up inside it, so the last
// line ends on the box edge instead of being sliced into the footer.
canvas.clipRect(0f, 0f, textWidth.toFloat(), limit.toFloat())
val shift = (limit - layout.height).coerceAtMost(0).toFloat()
if (shift != 0f) canvas.translate(0f, shift)
} else {
canvas.clipRect(0f, 0f, textWidth.toFloat(), fittedLineBottom(layout, limit).toFloat())
}
layout.draw(canvas)
canvas.restore()
}
private fun layoutOf(text: String, widthPx: Int): StaticLayout {
val paint = TextPaint(Paint.ANTI_ALIAS_FLAG).apply {
color = textColor
textSize = fontPx
isElegantTextHeight = false
textLocale = Locale.SIMPLIFIED_CHINESE
}
return readerLayout(text, 0, text.length, paint, widthPx, spacing)
}
}
//////// FILE: app/src/main/java/com/etye/reader/reader/PageSlideView.kt ////////
package com.etye.reader.reader
import android.animation.Animator
import android.animation.AnimatorListenerAdapter
import android.animation.ValueAnimator
import android.content.Context
import android.graphics.Bitmap
import android.graphics.Canvas
import android.graphics.Paint
import android.text.StaticLayout
import android.text.TextPaint
import android.util.AttributeSet
import android.view.View
import android.view.animation.DecelerateInterpolator
import java.util.Locale
import kotlin.math.abs
/**
* Plain pager slide. The bitmap is the whole reading page, including the
* paper margins around the text, and that sheet moves with the finger.
* No cover and no edge shadow. Either neighbor can be pulled, including
* reversing past the start. A missing side rubber-bands and always returns.
*/
class PageSlideView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
) : View(context, attrs) {
enum class Axis { HORIZONTAL, VERTICAL }
var axis: Axis = Axis.HORIZONTAL
var isTurning: Boolean = false
private set
private var pageText: String = ""
private var pendingNext: String = ""
private var pendingPrev: String = ""
private var textColor: Int = 0xFF3A3228.toInt()
private var pageColor: Int = 0xFFF6F1E3.toInt()
private var fontPx: Float = 48f
private var spacing: Float = 1.75f
private var insetLeft: Int = 0
private var insetTop: Int = 0
private var insetRight: Int = 0
private var insetBottom: Int = 0
private var dragging: Boolean = false
private var commitOnEnd: Boolean = true
private var settledNext: Boolean = true
private var offset: Float = 0f
private var rawOffset: Float = 0f
private var hasNext: Boolean = false
private var hasPrev: Boolean = false
private var sheetDirty: Boolean = true
private var preparedDirty: Boolean = false
private var restyleAfterTurn: Boolean = false
private var preparedNext: String? = null
private var preparedPrev: String? = null
private var preparedPrevAlign: Boolean = false
private var paintPosted: Boolean = false
private var holdPaint: Boolean = false
private var turnToken: Int = 0
private var missForward: Boolean? = null
private var sheet: Bitmap? = null
private var ahead: Bitmap? = null
private var behind: Bitmap? = null
private var onNext: (() -> Unit)? = null
private var onPrev: (() -> Unit)? = null
private var onMiss: ((Boolean) -> Unit)? = null
private val retiring = ArrayList<Bitmap>(2)
private var animator: ValueAnimator? = null
private val bitmapPaint = Paint().apply { isFilterBitmap = false }
private val decelerate = DecelerateInterpolator(1.8f)
fun show(text: String, textColor: Int, pageColor: Int, fontPx: Float, spacing: Float) {
val changed = text != pageText || textColor != this.textColor || pageColor != this.pageColor ||
fontPx != this.fontPx || spacing != this.spacing
this.textColor = textColor
this.pageColor = pageColor
this.fontPx = fontPx
this.spacing = spacing
if (isTurning) {
if (changed) restyleAfterTurn = true
return
}
if (!changed && !restyleAfterTurn) return
restyleAfterTurn = false
pageText = text
sheetDirty = true
preparedDirty = true
schedulePaint()
redraw()
}
/**
* Paper margins inside the page bitmap. The view itself is the full
* reading area, so these insets slide with the text instead of staying
* put as a frame around a smaller sheet.
*/
fun contentInset(left: Int, top: Int, right: Int, bottom: Int) {
if (left == insetLeft && top == insetTop && right == insetRight && bottom == insetBottom) return
insetLeft = left.coerceAtLeast(0)
insetTop = top.coerceAtLeast(0)
insetRight = right.coerceAtLeast(0)
insetBottom = bottom.coerceAtLeast(0)
if (isTurning) {
restyleAfterTurn = true
return
}
sheetDirty = true
preparedDirty = true
schedulePaint()
redraw()
}
/** Compose on some Harmony builds drops a child invalidate, so the parent must be marked too. */
private fun redraw() {
invalidate()
val host = parent
if (host is View) host.invalidate()
}
/**
* Renders the neighbor pages ahead of the gesture so the first move
* does not allocate bitmaps on the pointer frame.
*/
fun prepare(nextText: String?, prevText: String?, prevAlignEnd: Boolean) {
if (nextText == preparedNext && prevText == preparedPrev && prevAlignEnd == preparedPrevAlign && !preparedDirty) {
return
}
preparedNext = nextText
preparedPrev = prevText
preparedPrevAlign = prevAlignEnd
preparedDirty = true
if (!isTurning) schedulePaint()
}
fun turn(toText: String, forward: Boolean, alignEnd: Boolean = false, onEnd: () -> Unit): Boolean {
if (isTurning) return false
if (width <= 1 || height <= 1) {
pageText = toText
sheetDirty = true
redraw()
onEnd()
return false
}
ensureSheet()
val dest = paintInto(if (forward) ahead else behind, toText, alignEnd)
if (sheet == null || dest == null) {
pageText = toText
sheetDirty = true
redraw()
onEnd()
return false
}
if (forward) {
ahead = dest
hasNext = true
pendingNext = toText
onNext = onEnd
onPrev = null
} else {
behind = dest
hasPrev = true
pendingPrev = toText
onPrev = onEnd
onNext = null
}
isTurning = true
dragging = false
commitOnEnd = true
settledNext = forward
missForward = null
rawOffset = 0f
offset = 0f
animate(0f, if (forward) -span() else span(), 280L)
return true
}
/**
* Starts a finger slide that can go to the next page, the previous page,
* or rubber-band when that side does not exist. [offsetPx] is the drag
* since the finger went down, so the page catches the finger immediately.
*/
fun grabSides(
nextText: String?,
prevText: String?,
prevAlignEnd: Boolean,
offsetPx: Float,
onNext: (() -> Unit)?,
onPrev: (() -> Unit)?,
): Boolean {
if (isTurning) return false
if (width <= 1 || height <= 1) return false
ensureSheet()
if (sheet == null) return false
if (preparedNext != nextText || (ahead == null && nextText != null)) {
preparedNext = nextText
ahead = nextText?.let { paintInto(ahead, it, false) }
}
if (preparedPrev != prevText || preparedPrevAlign != prevAlignEnd || (behind == null && prevText != null)) {
preparedPrev = prevText
preparedPrevAlign = prevAlignEnd
behind = prevText?.let { paintInto(behind, it, prevAlignEnd) }
}
preparedDirty = false
hasNext = nextText != null && ahead != null
hasPrev = prevText != null && behind != null
pendingNext = nextText.orEmpty()
pendingPrev = prevText.orEmpty()
this.onNext = onNext
this.onPrev = onPrev
isTurning = true
dragging = true
commitOnEnd = false
missForward = null
rawOffset = offsetPx
offset = presented(offsetPx)
redraw()
return true
}
fun follow(offsetPx: Float) {
if (!dragging) return
rawOffset = offsetPx
offset = presented(offsetPx)
redraw()
}
fun release(velocityPxPerSec: Float = 0f, onMiss: ((forward: Boolean) -> Unit)? = null) {
if (!dragging) return
dragging = false
val distance = span()
val towardNext = when {
rawOffset < -1f -> true
rawOffset > 1f -> false
else -> velocityPxPerSec < 0f
}
val available = if (towardNext) hasNext else hasPrev
val progress = abs(rawOffset) / distance
val fling = (400f * resources.displayMetrics.density).coerceAtLeast(900f)
val finish = available && slideShouldFinish(progress, velocityPxPerSec, towardNext, fling)
commitOnEnd = finish
settledNext = towardNext
this.onMiss = onMiss
missForward = if (!finish && !available && abs(rawOffset) >= missPx()) towardNext else null
val target = when {
finish && towardNext -> -distance
finish -> distance
else -> 0f
}
animate(offset, target, settleDuration(offset, target, abs(velocityPxPerSec), finish))
}
override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
super.onSizeChanged(w, h, oldw, oldh)
if (w == oldw && h == oldh) return
if (isTurning) {
turnToken += 1
animator?.cancel()
animator = null
isTurning = false
dragging = false
onNext = null
onPrev = null
onMiss = null
offset = 0f
rawOffset = 0f
}
sheetDirty = true
preparedDirty = true
schedulePaint()
}
override fun onDetachedFromWindow() {
turnToken += 1
animator?.cancel()
animator = null
dragging = false
isTurning = false
onNext = null
onPrev = null
onMiss = null
paintPosted = false
holdPaint = false
retiring.forEach { bitmap ->
if (bitmap !== sheet && bitmap !== ahead && bitmap !== behind) recycle(bitmap)
}
retiring.clear()
recycle(sheet)
recycle(ahead)
recycle(behind)
sheet = null
ahead = null
behind = null
super.onDetachedFromWindow()
}
override fun onDraw(canvas: Canvas) {
// A filtered, fractional blit blends the departing page's edge with the
// transparent pixels outside the bitmap. That fringe sits on top of the
// incoming page and reads as a ghost, especially while the settle eases
// through the last pixel. Draw opaque bitmaps on whole pixels instead.
val savedFilter = canvas.drawFilter
canvas.drawFilter = null
canvas.drawColor(pageColor)
if (!isTurning) {
val cached = sheet
if (!sheetDirty && cached != null && !cached.isRecycled) {
canvas.drawBitmap(cached, 0f, 0f, bitmapPaint)
} else {
drawText(canvas, pageText)
}
canvas.drawFilter = savedFilter
return
}
val horizontal = axis == Axis.HORIZONTAL
val distance = if (horizontal) width else height
val current = slideDrawOffset(offset, distance, settling = !dragging)
if (current == 0) {
drawSheet(canvas, sheet, 0, 0)
canvas.drawFilter = savedFilter
return
}
val neighbor = if (current < 0) ahead else behind
val neighborAt = if (current < 0) current + distance else current - distance
val movingVisible = if (current < 0) current + distance > 0 else current < distance
canvas.save()
canvas.clipRect(0, 0, width, height)
if (horizontal) {
drawSheet(canvas, neighbor, neighborAt, 0)
if (movingVisible) drawSheet(canvas, sheet, current, 0)
} else {
drawSheet(canvas, neighbor, 0, neighborAt)
if (movingVisible) drawSheet(canvas, sheet, 0, current)
}
canvas.restore()
canvas.drawFilter = savedFilter
}
private fun drawSheet(canvas: Canvas, bitmap: Bitmap?, x: Int, y: Int) {
if (bitmap == null || bitmap.isRecycled) return
canvas.drawBitmap(bitmap, x.toFloat(), y.toFloat(), bitmapPaint)
}
private fun span(): Float {
val value = if (axis == Axis.HORIZONTAL) width.toFloat() else height.toFloat()
return value.coerceAtLeast(1f)
}
private fun missPx(): Float = 48f * resources.displayMetrics.density
private fun presented(raw: Float): Float {
val distance = span()
val limited = raw.coerceIn(-distance, distance)
if (limited == 0f) return 0f
val available = if (limited < 0f) hasNext else hasPrev
return if (available) limited else limited * 0.22f
}
private fun settleDuration(from: Float, to: Float, speed: Float, finishing: Boolean): Long {
val remaining = abs(to - from).coerceAtLeast(1f)
val velocity = speed.coerceAtLeast(if (finishing) 1400f else 1800f)
val millis = (remaining / velocity * 1000f).toLong()
return if (finishing) millis.coerceIn(160L, 320L) else millis.coerceIn(140L, 200L)
}
private fun schedulePaint() {
if (paintPosted) return
paintPosted = true
post {
paintPosted = false
if (!isAttachedToWindow) return@post
// The frame still on screen references these bitmaps. Rewriting or
// recycling them in the same turn as the settle corrupts that frame
// and leaves a ghost of the old page. Wait one pass.
if (holdPaint || isTurning) {
if (holdPaint && !isTurning) holdPaint = false
if (sheetDirty || preparedDirty || retiring.isNotEmpty()) schedulePaint()
return@post
}
if (width <= 1 || height <= 1) return@post
if (retiring.isNotEmpty()) {
val doomed = retiring.toList()
retiring.clear()
doomed.forEach { recycle(it) }
}
if (sheetDirty) {
sheet = paintInto(sheet, pageText, false)
sheetDirty = sheet == null
}
if (preparedDirty) {
val nextText = preparedNext
if (nextText == null) {
recycle(ahead)
ahead = null
} else {
ahead = paintInto(ahead, nextText, false)
}
val prevText = preparedPrev
if (prevText == null) {
recycle(behind)
behind = null
} else {
behind = paintInto(behind, prevText, preparedPrevAlign)
}
preparedDirty = (nextText != null && ahead == null) || (prevText != null && behind == null)
hasNext = ahead != null
hasPrev = behind != null
}
redraw()
}
}
private fun ensureSheet() {
if (!sheetDirty && sheet != null && sheet?.isRecycled == false && sheet?.width == width && sheet?.height == height) {
return
}
sheet = paintInto(sheet, pageText, false)
sheetDirty = sheet == null
}
private fun animate(from: Float, to: Float, duration: Long) {
val token = turnToken
animator?.cancel()
animator = ValueAnimator.ofFloat(from, to).apply {
this.duration = duration
interpolator = decelerate
addUpdateListener { value ->
offset = value.animatedValue as Float
redraw()
}
addListener(object : AnimatorListenerAdapter() {
private var cancelled = false
override fun onAnimationCancel(animation: Animator) {
cancelled = true
}
override fun onAnimationEnd(animation: Animator) {
if (cancelled || token != turnToken || !isTurning) return
if (commitOnEnd) completeTurn() else revertTurn()
}
})
start()
}
}
private fun completeTurn() {
val callback = if (settledNext) onNext else onPrev
val revealed = if (settledNext) ahead else behind
val unused = if (settledNext) behind else ahead
val leftText = pageText
val text = if (settledNext) pendingNext else pendingPrev
onNext = null
onPrev = null
onMiss = null
missForward = null
pageText = text
val old = sheet
if (revealed != null && !revealed.isRecycled) {
sheet = revealed
sheetDirty = false
} else {
sheetDirty = true
}
// Keep the page that just left. It is still referenced by the frame on
// screen, and it is the neighbor on the way back, so it must not be recycled.
if (settledNext) {
ahead = null
preparedNext = null
if (old != null && old !== sheet && !old.isRecycled) {
behind = old
preparedPrev = leftText
preparedPrevAlign = false
hasPrev = true
}
} else {
behind = null
preparedPrev = null
if (old != null && old !== sheet && !old.isRecycled) {
ahead = old
preparedNext = leftText
hasNext = true
}
}
if (unused != null && unused !== sheet && unused !== ahead && unused !== behind && !unused.isRecycled) {
retiring.add(unused)
}
holdPaint = true
isTurning = false
dragging = false
offset = 0f
rawOffset = 0f
animator = null
redraw()
callback?.invoke()
schedulePaint()
}
private fun revertTurn() {
val miss = missForward
val missCallback = onMiss
missForward = null
onMiss = null
onNext = null
onPrev = null
isTurning = false
dragging = false
offset = 0f
rawOffset = 0f
animator = null
redraw()
if (miss != null) missCallback?.invoke(miss)
}
private fun recycle(bitmap: Bitmap?) {
if (bitmap != null && !bitmap.isRecycled) bitmap.recycle()
}
private fun paintInto(existing: Bitmap?, text: String, alignEnd: Boolean): Bitmap? {
return try {
val bitmap = if (
existing != null &&
!existing.isRecycled &&
existing.width == width &&
existing.height == height
) {
existing
} else {
recycle(existing)
PageSheet.bitmap(width, height)
}
val canvas = Canvas(bitmap)
canvas.drawColor(pageColor)
drawPageContent(canvas, text, alignEnd)
bitmap
} catch (_: OutOfMemoryError) {
null
}
}
private fun drawText(canvas: Canvas, text: String) {
if (text.isEmpty() || width <= 1) return
drawPageContent(canvas, text, alignEnd = false)
}
private fun drawPageContent(canvas: Canvas, text: String, alignEnd: Boolean) {
val textWidth = (width - insetLeft - insetRight).coerceAtLeast(1)
val textHeight = (height - insetTop - insetBottom).coerceAtLeast(1)
val layout = layoutOf(PageSheet.textWindow(text, alignEnd), textWidth)
val limit = pageUsableHeight(textHeight, fontPx)
canvas.save()
canvas.translate(insetLeft.toFloat(), insetTop.toFloat())
if (alignEnd) {
// Clip the box first, then slide the tail up inside it, so the last
// line ends on the box edge instead of being sliced into the footer.
canvas.clipRect(0f, 0f, textWidth.toFloat(), limit.toFloat())
val shift = (limit - layout.height).coerceAtMost(0).toFloat()
if (shift != 0f) canvas.translate(0f, shift)
} else {
canvas.clipRect(0f, 0f, textWidth.toFloat(), fittedLineBottom(layout, limit).toFloat())
}
layout.draw(canvas)
canvas.restore()
}
private fun layoutOf(text: String, widthPx: Int): StaticLayout {
val paint = TextPaint(Paint.ANTI_ALIAS_FLAG).apply {
color = textColor
textSize = fontPx
isElegantTextHeight = false
textLocale = Locale.SIMPLIFIED_CHINESE
}
return readerLayout(text, 0, text.length, paint, widthPx, spacing)
}
}
//////// FILE: app/src/main/java/com/etye/reader/ui/reader/ReaderViewModel.kt ////////
package com.etye.reader.ui.reader
import android.app.Application
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import com.etye.reader.ReaderApp
import com.etye.reader.data.BookRecord
import com.etye.reader.data.ReadMode
import com.etye.reader.data.ReaderSettings
import com.etye.reader.data.ThemeMode
import com.etye.reader.parse.ChapterCatalog
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
data class ReadingPosition(val chapterIndex: Int, val charOffset: Int)
sealed interface ReaderLoad {
data object Loading : ReaderLoad
data class Ready(val book: BookRecord, val catalog: ChapterCatalog) : ReaderLoad
data class Failed(val message: String) : ReaderLoad
}
class ReaderViewModel(app: Application, private val bookId: String) : AndroidViewModel(app) {
private val repository = (app as ReaderApp).bookRepository
private val settingsRepository = (app as ReaderApp).settingsRepository
val settings: StateFlow<ReaderSettings> = settingsRepository.settings
private val _load = MutableStateFlow<ReaderLoad>(ReaderLoad.Loading)
val load: StateFlow<ReaderLoad> = _load.asStateFlow()
private val _position = MutableStateFlow(ReadingPosition(0, 0))
val position: StateFlow<ReadingPosition> = _position.asStateFlow()
private val _jump = MutableStateFlow(0)
val jump: StateFlow<Int> = _jump.asStateFlow()
private val _warm = MutableStateFlow(false)
val warm: StateFlow<Boolean> = _warm.asStateFlow()
init {
viewModelScope.launch {
val record = repository.find(bookId) ?: repository.awaitRecord(bookId)
if (record == null) {
_load.value = ReaderLoad.Failed("找不到这本书,可能已经从书架删除")
return@launch
}
_position.value = ReadingPosition(record.chapterIndex, record.charOffset)
try {
val catalog = withContext(Dispatchers.IO) { repository.openCatalog(record) }
if (catalog.size == 0) {
_load.value = ReaderLoad.Failed("这本书没有可阅读的正文")
return@launch
}
val chapter = record.chapterIndex.coerceIn(0, catalog.size - 1)
val scroll = settingsRepository.settings.value.mode == ReadMode.SCROLL
withContext(Dispatchers.Default) {
if (scroll) catalog.warmCooperative() else catalog.ensureAround(chapter)
}
val max = catalog.chapter(chapter).displayText.length
val offset = record.charOffset.coerceIn(0, max)
_position.value = ReadingPosition(chapter, offset)
_load.value = ReaderLoad.Ready(record, catalog)
_warm.value = catalog.lengthsReady
if (!scroll) {
launch(Dispatchers.Default) {
delay(400)
catalog.warmCooperative()
_warm.value = true
val current = _position.value
val progress = catalog.bookProgress(current.chapterIndex, current.charOffset)
repository.updateProgress(bookId, current.chapterIndex, current.charOffset, progress)
}
}
launch(Dispatchers.IO) { repository.touch(bookId) }
} catch (error: Exception) {
_load.value = ReaderLoad.Failed(error.message ?: "无法打开这本书")
}
}
}
fun commit(chapterIndex: Int, charOffset: Int) {
val ready = _load.value as? ReaderLoad.Ready ?: return
val catalog = ready.catalog
if (catalog.size == 0) return
val chapter = chapterIndex.coerceIn(0, catalog.size - 1)
val max = catalog.chapter(chapter).displayText.length
val offset = charOffset.coerceIn(0, max)
val current = _position.value
if (current.chapterIndex == chapter && current.charOffset == offset) return
_position.value = ReadingPosition(chapter, offset)
val progress = catalog.bookProgress(chapter, offset)
repository.updateProgress(bookId, chapter, offset, progress)
}
fun jumpTo(chapterIndex: Int, charOffset: Int) {
commit(chapterIndex, charOffset)
_jump.value = _jump.value + 1
}
fun setFont(sp: Float) {
settingsRepository.update { it.copy(fontSp = sp.coerceIn(14f, 28f)) }
}
fun setSpacing(value: Float) {
settingsRepository.update { it.copy(lineSpacing = value.coerceIn(1.3f, 2.2f)) }
}
fun setTheme(mode: ThemeMode) {
settingsRepository.update { it.copy(theme = mode) }
}
fun setReadMode(mode: ReadMode) {
settingsRepository.update { it.copy(mode = mode) }
}
fun setFullscreen(enabled: Boolean) {
settingsRepository.update { it.copy(fullscreen = enabled) }
}
fun flush() {
repository.flush()
}
override fun onCleared() {
repository.flush()
}
companion object {
fun factory(app: Application, bookId: String): ViewModelProvider.Factory {
return object : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>): T {
return ReaderViewModel(app, bookId) as T
}
}
}
}
}
//////// FILE: app/src/main/java/com/etye/reader/ui/reader/ReaderScreen.kt ////////
package com.etye.reader.ui.reader
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.os.BatteryManager
import android.os.Build
import android.text.format.DateFormat
import android.view.KeyEvent
import android.view.WindowManager
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.awaitEachGesture
import androidx.compose.foundation.gestures.awaitFirstDown
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.WindowInsetsSides
import androidx.compose.foundation.layout.displayCutout
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.only
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.layout.union
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowLeft
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Menu
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.ListItem
import androidx.compose.material3.ListItemDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.SegmentedButton
import androidx.compose.material3.SegmentedButtonDefaults
import androidx.compose.material3.SingleChoiceSegmentedButtonRow
import androidx.compose.material3.Slider
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.input.pointer.PointerEventPass
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.core.view.WindowCompat
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.WindowInsetsControllerCompat
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import com.etye.reader.MainActivity
import com.etye.reader.ReaderApp
import com.etye.reader.data.ReadMode
import com.etye.reader.data.ReaderSettings
import com.etye.reader.data.ThemeMode
import com.etye.reader.parse.BlockKind
import com.etye.reader.parse.ChapterCatalog
import com.etye.reader.parse.ChapterContent
import com.etye.reader.parse.pageIndexForOffset
import com.etye.reader.reader.PageCurlView
import com.etye.reader.reader.PageSlideView
import com.etye.reader.reader.Paginator
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import kotlin.math.abs
import kotlin.math.roundToInt
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.drop
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
private data class FlatBlock(
val chapterIndex: Int,
val kind: BlockKind,
val text: String,
val start: Int,
)
private enum class DragBegin { STARTED, EDGE, IGNORED }
private data class PageDest(
val text: String,
val alignEnd: Boolean,
val commit: () -> Unit,
)
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ReaderScreen(bookId: String, onBack: () -> Unit) {
val app = LocalContext.current.applicationContext as ReaderApp
val viewModel: ReaderViewModel = viewModel(
key = bookId,
factory = remember(bookId) { ReaderViewModel.factory(app, bookId) },
)
val load by viewModel.load.collectAsStateWithLifecycle()
val settings by viewModel.settings.collectAsStateWithLifecycle()
val position by viewModel.position.collectAsStateWithLifecycle()
var chrome by remember { mutableStateOf(false) }
var showSettings by remember { mutableStateOf(false) }
var showChapters by remember { mutableStateOf(false) }
var showProgress by remember { mutableStateOf(false) }
var edgeHint by remember { mutableStateOf<String?>(null) }
val activity = LocalContext.current as MainActivity
DisposableEffect(Unit) {
activity.window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
onDispose {
activity.window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
viewModel.flush()
}
}
LaunchedEffect(edgeHint) {
if (edgeHint != null) {
delay(1200)
edgeHint = null
}
}
val background = MaterialTheme.colorScheme.background
val onBackground = MaterialTheme.colorScheme.onBackground
when (val state = load) {
ReaderLoad.Loading -> CenterMessage(text = "正在打开…", loading = true, onBack = onBack)
is ReaderLoad.Failed -> CenterMessage(text = state.message, loading = false, onBack = onBack)
is ReaderLoad.Ready -> {
val catalog = state.catalog
val warm by viewModel.warm.collectAsStateWithLifecycle()
val chapterIndex = position.chapterIndex.coerceIn(0, (catalog.size - 1).coerceAtLeast(0))
val chapter = catalog.chapter(chapterIndex)
val previousChapter = if (chapterIndex > 0) catalog.chapter(chapterIndex - 1) else null
val nextChapter = if (chapterIndex + 1 < catalog.size) catalog.chapter(chapterIndex + 1) else null
val progress = if (warm) {
catalog.bookProgress(chapterIndex, position.charOffset)
} else {
catalog.bookProgress(chapterIndex, position.charOffset)
}
val chapterPercent = chapterPercent(position.charOffset, chapter.displayText.length)
val view = LocalView.current
val pageArgb = background.toArgb()
val lightStatus = settings.theme != ThemeMode.NIGHT
val safeTop = if (settings.fullscreen) {
WindowInsets.displayCutout.only(WindowInsetsSides.Top + WindowInsetsSides.Horizontal)
} else {
WindowInsets.statusBars.union(WindowInsets.displayCutout)
.only(WindowInsetsSides.Top + WindowInsetsSides.Horizontal)
}
DisposableEffect(Unit) {
val window = activity.window
val previousCutout = if (Build.VERSION.SDK_INT >= 28) {
window.attributes.layoutInDisplayCutoutMode
} else {
0
}
if (Build.VERSION.SDK_INT >= 28) {
val params = window.attributes
params.layoutInDisplayCutoutMode = if (Build.VERSION.SDK_INT >= 30) {
WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_ALWAYS
} else {
WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES
}
window.attributes = params
}
onDispose {
if (Build.VERSION.SDK_INT >= 28) {
val params = window.attributes
params.layoutInDisplayCutoutMode = previousCutout
window.attributes = params
}
}
}
DisposableEffect(settings.fullscreen, pageArgb, lightStatus) {
val window = activity.window
val controller = WindowCompat.getInsetsController(window, view)
applyReaderBars(window, controller, settings.fullscreen, pageArgb, lightStatus)
onDispose { restoreReaderBars(window, controller, lightStatus) }
}
fun jumpChapter(delta: Int) {
val target = chapterIndex + delta
if (target !in 0 until catalog.size) {
edgeHint = if (delta > 0) "已经是最后一章" else "已经是第一章"
return
}
viewModel.jumpTo(target, 0)
}
Box(Modifier.fillMaxSize().background(background)) {
Column(
Modifier
.fillMaxSize()
.windowInsetsPadding(safeTop)
.navigationBarsPadding(),
) {
Box(Modifier.weight(1f).fillMaxWidth()) {
if (settings.mode == ReadMode.SCROLL) {
ScrollPane(
catalog = catalog,
position = position,
jump = viewModel.jump.collectAsStateWithLifecycle().value,
settings = settings,
onCommit = viewModel::commit,
onToggleChrome = { chrome = !chrome },
)
} else {
PagePane(
mode = settings.mode,
chapter = chapter,
previousChapter = previousChapter,
nextChapter = nextChapter,
chapterIndex = chapterIndex,
chapterCount = catalog.size,
offset = position.charOffset,
settings = settings,
textColor = onBackground,
pageColor = background,
onCommit = viewModel::commit,
onJump = viewModel::jumpTo,
onToggleChrome = { chrome = !chrome },
onEdge = { edgeHint = it },
)
}
edgeHint?.let { hint ->
Surface(
modifier = Modifier.align(Alignment.Center),
shape = RoundedCornerShape(20.dp),
color = MaterialTheme.colorScheme.inverseSurface,
) {
Text(
hint,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
color = MaterialTheme.colorScheme.inverseOnSurface,
)
}
}
}
ReaderFooter(chapterPercent = chapterPercent, chapterTitle = chapter.title)
}
AnimatedVisibility(
visible = chrome,
modifier = Modifier.align(Alignment.TopCenter),
enter = fadeIn() + slideInVertically { -it },
exit = fadeOut() + slideOutVertically { -it },
) {
Surface(
modifier = Modifier.fillMaxWidth(),
color = MaterialTheme.colorScheme.surface.copy(alpha = 0.97f),
shadowElevation = 3.dp,
) {
Row(
Modifier.windowInsetsPadding(safeTop).height(52.dp).padding(end = 12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "返回")
}
Column(Modifier.weight(1f)) {
Text(
chapter.title,
style = MaterialTheme.typography.titleMedium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Text(
state.book.title,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
}
}
AnimatedVisibility(
visible = chrome,
modifier = Modifier.align(Alignment.BottomCenter),
enter = fadeIn() + slideInVertically { it },
exit = fadeOut() + slideOutVertically { it },
) {
Surface(
modifier = Modifier.fillMaxWidth(),
color = MaterialTheme.colorScheme.surface.copy(alpha = 0.97f),
shadowElevation = 6.dp,
) {
Row(
Modifier.navigationBarsPadding().height(64.dp).padding(horizontal = 4.dp),
horizontalArrangement = Arrangement.SpaceEvenly,
verticalAlignment = Alignment.CenterVertically,
) {
ReaderBarButton("上一章", Icons.AutoMirrored.Filled.KeyboardArrowLeft) { jumpChapter(-1) }
ReaderBarButton("目录", Icons.Filled.Menu) { showChapters = true }
ProgressBarButton(progress) { showProgress = true }
ReaderBarButton("设置", Icons.Filled.Settings) { showSettings = true }
ReaderBarButton("下一章", Icons.AutoMirrored.Filled.KeyboardArrowRight) { jumpChapter(1) }
}
}
}
}
if (showSettings) {
SettingsSheet(settings = settings, viewModel = viewModel, onDismiss = { showSettings = false })
}
if (showChapters) {
ChapterSheet(
titles = catalog.titles,
current = chapterIndex,
onPick = { index ->
viewModel.jumpTo(index, 0)
showChapters = false
},
onDismiss = { showChapters = false },
)
}
if (showProgress) {
ProgressSheet(
progress = progress,
chapterTitle = chapter.title,
onSeek = { fraction ->
val (index, offset) = catalog.targetFor(fraction)
viewModel.jumpTo(index, offset)
},
onDismiss = { showProgress = false },
)
}
}
}
}
@Composable
private fun ReaderBarButton(label: String, icon: ImageVector, onClick: () -> Unit) {
Column(
modifier = Modifier
.widthIn(min = 56.dp)
.clip(RoundedCornerShape(8.dp))
.clickable(onClick = onClick)
.padding(horizontal = 4.dp, vertical = 4.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Icon(icon, contentDescription = label, modifier = Modifier.size(22.dp))
Text(label, style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
@Composable
private fun ProgressBarButton(progress: Float, onClick: () -> Unit) {
Column(
modifier = Modifier
.widthIn(min = 56.dp)
.clip(RoundedCornerShape(8.dp))
.clickable(onClick = onClick)
.padding(horizontal = 4.dp, vertical = 4.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(
"${(progress * 100).roundToInt()}%",
style = MaterialTheme.typography.labelLarge,
textAlign = TextAlign.Center,
)
Text("进度", style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
@Composable
private fun CenterMessage(text: String, loading: Boolean, onBack: () -> Unit) {
Column(
Modifier.fillMaxSize().statusBarsPadding().padding(24.dp),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
) {
if (loading) {
CircularProgressIndicator()
Spacer(Modifier.height(16.dp))
}
Text(text, style = MaterialTheme.typography.bodyLarge)
Spacer(Modifier.height(16.dp))
TextButton(onClick = onBack) { Text("返回书架") }
}
}
private class CurlSlot {
var view: PageCurlView? = null
}
private class SlideSlot {
var view: PageSlideView? = null
}
@Composable
private fun PagePane(
mode: ReadMode,
chapter: ChapterContent,
previousChapter: ChapterContent?,
nextChapter: ChapterContent?,
chapterIndex: Int,
chapterCount: Int,
offset: Int,
settings: ReaderSettings,
textColor: Color,
pageColor: Color,
onCommit: (Int, Int) -> Unit,
onJump: (Int, Int) -> Unit,
onToggleChrome: () -> Unit,
onEdge: (String) -> Unit,
) {
val density = LocalDensity.current
val fontPx = with(density) { settings.fontSp.sp.toPx() }
val pagePadX = with(density) { 24.dp.roundToPx() }
val pagePadTop = with(density) { 12.dp.roundToPx() }
var size by remember { mutableStateOf(IntSize.Zero) }
val sliding = mode == ReadMode.HORIZONTAL || mode == ReadMode.VERTICAL
// The view is the whole sheet above the footer. Text stays in the inner box,
// so the margins travel with the page instead of sitting outside it.
val contentWidth = (size.width - pagePadX * 2).coerceAtLeast(0)
val contentHeight = (size.height - pagePadTop).coerceAtLeast(0)
// Reset only when the chapter text changes. Size and font must not blank the page,
// or the page-number row appearing would shrink the viewport and paginate forever.
var pages by remember(chapter.displayText) { mutableStateOf<List<Int>?>(null) }
LaunchedEffect(chapter.displayText, contentWidth, contentHeight, fontPx, settings.lineSpacing) {
val width = contentWidth
val height = contentHeight
if (width <= 0 || height <= 0) return@LaunchedEffect
val text = chapter.displayText
val spacing = settings.lineSpacing
val measuredFont = fontPx
pages = withContext(Dispatchers.Default) {
Paginator.paginate(text, width, height, measuredFont, spacing)
}
}
val pageStarts = pages
val pageIndex = if (pageStarts == null) 0 else pageIndexForOffset(pageStarts, offset)
val pageText = if (pageStarts == null) {
""
} else {
val start = pageStarts[pageIndex]
val end = if (pageIndex == pageStarts.lastIndex) chapter.displayText.length else pageStarts[pageIndex + 1]
chapter.displayText.substring(start.coerceIn(0, chapter.displayText.length), end.coerceIn(0, chapter.displayText.length))
}
val curl = remember { CurlSlot() }
val slide = remember { SlideSlot() }
val textArgb = textColor.toArgb()
val pageArgb = pageColor.toArgb()
fun turning(): Boolean {
return if (sliding) slide.view?.isTurning == true else curl.view?.isTurning == true
}
fun slice(text: String, start: Int, end: Int): String {
val safeStart = start.coerceIn(0, text.length)
val safeEnd = end.coerceIn(safeStart, text.length)
return text.substring(safeStart, safeEnd)
}
fun turn(toText: String, forward: Boolean, alignEnd: Boolean, fromTop: Boolean, commit: () -> Unit) {
if (sliding) {
val view = slide.view
if (view == null) commit() else view.turn(toText, forward, alignEnd, commit)
} else {
val view = curl.view
if (view == null) commit() else view.turn(toText, forward, alignEnd, fromTop, commit)
}
}
fun goNext(fromTop: Boolean = false) {
val starts = pageStarts ?: return
if (turning()) return
val index = pageIndexForOffset(starts, offset)
if (index < starts.lastIndex) {
val destStart = starts[index + 1]
val destEnd = if (index + 1 == starts.lastIndex) chapter.displayText.length else starts[index + 2]
turn(slice(chapter.displayText, destStart, destEnd), forward = true, alignEnd = false, fromTop = fromTop) {
onCommit(chapterIndex, destStart)
}
} else if (chapterIndex < chapterCount - 1) {
val preview = nextChapter?.displayText.orEmpty()
turn(preview, forward = true, alignEnd = false, fromTop = fromTop) {
onJump(chapterIndex + 1, 0)
}
} else {
onCommit(chapterIndex, chapter.displayText.length)
onEdge("已经是最后一页")
}
}
fun goPrev(fromTop: Boolean = false) {
val starts = pageStarts ?: return
if (turning()) return
val index = pageIndexForOffset(starts, offset)
if (index > 0) {
val destStart = starts[index - 1]
val destEnd = starts[index]
turn(slice(chapter.displayText, destStart, destEnd), forward = false, alignEnd = false, fromTop = fromTop) {
onCommit(chapterIndex, destStart)
}
} else if (chapterIndex > 0) {
val preview = previousChapter?.displayText.orEmpty()
turn(preview, forward = false, alignEnd = true, fromTop = fromTop) {
onJump(chapterIndex - 1, Int.MAX_VALUE)
}
} else {
onEdge("已经是第一页")
}
}
fun nextDest(): PageDest? {
val starts = pageStarts ?: return null
val index = pageIndexForOffset(starts, offset)
if (index < starts.lastIndex) {
val destStart = starts[index + 1]
val destEnd = if (index + 1 == starts.lastIndex) chapter.displayText.length else starts[index + 2]
return PageDest(slice(chapter.displayText, destStart, destEnd), false) { onCommit(chapterIndex, destStart) }
}
if (chapterIndex < chapterCount - 1) {
return PageDest(nextChapter?.displayText.orEmpty(), false) { onJump(chapterIndex + 1, 0) }
}
return null
}
fun prevDest(): PageDest? {
val starts = pageStarts ?: return null
val index = pageIndexForOffset(starts, offset)
if (index > 0) {
val destStart = starts[index - 1]
return PageDest(slice(chapter.displayText, destStart, starts[index]), false) { onCommit(chapterIndex, destStart) }
}
if (chapterIndex > 0) {
return PageDest(previousChapter?.displayText.orEmpty(), true) { onJump(chapterIndex - 1, Int.MAX_VALUE) }
}
return null
}
fun beginSlide(primary: Float): DragBegin {
if (pageStarts == null || turning()) return DragBegin.IGNORED
val next = nextDest()
val prev = prevDest()
val started = slide.view?.grabSides(
nextText = next?.text,
prevText = prev?.text,
prevAlignEnd = prev?.alignEnd == true,
offsetPx = primary,
onNext = next?.commit,
onPrev = prev?.commit,
) == true
return if (started) DragBegin.STARTED else DragBegin.IGNORED
}
fun beginDrag(forward: Boolean, x: Float, y: Float): DragBegin {
val starts = pageStarts ?: return DragBegin.IGNORED
if (turning()) return DragBegin.IGNORED
val index = pageIndexForOffset(starts, offset)
if (forward) {
if (index < starts.lastIndex) {
val destStart = starts[index + 1]
val destEnd = if (index + 1 == starts.lastIndex) chapter.displayText.length else starts[index + 2]
val text = slice(chapter.displayText, destStart, destEnd)
val started = curl.view?.grab(text, true, false, x, y) { onCommit(chapterIndex, destStart) } == true
return if (started) DragBegin.STARTED else DragBegin.IGNORED
}
if (chapterIndex < chapterCount - 1) {
val text = nextChapter?.displayText.orEmpty()
val started = curl.view?.grab(text, true, false, x, y) { onJump(chapterIndex + 1, 0) } == true
return if (started) DragBegin.STARTED else DragBegin.IGNORED
}
return DragBegin.EDGE
}
if (index > 0) {
val destStart = starts[index - 1]
val text = slice(chapter.displayText, destStart, starts[index])
val started = curl.view?.grab(text, false, false, x, y) { onCommit(chapterIndex, destStart) } == true
return if (started) DragBegin.STARTED else DragBegin.IGNORED
}
if (chapterIndex > 0) {
val text = previousChapter?.displayText.orEmpty()
val started = curl.view?.grab(text, false, true, x, y) { onJump(chapterIndex - 1, Int.MAX_VALUE) } == true
return if (started) DragBegin.STARTED else DragBegin.IGNORED
}
return DragBegin.EDGE
}
val activity = LocalContext.current as MainActivity
DisposableEffect(pageStarts, offset, chapterIndex) {
activity.volumeHandler = { keyCode ->
if (keyCode == KeyEvent.KEYCODE_VOLUME_DOWN) goNext() else goPrev()
true
}
onDispose { activity.volumeHandler = null }
}
Box(
Modifier
.fillMaxSize()
.onSizeChanged { next ->
val widthDelta = abs(next.width - size.width)
val heightDelta = abs(next.height - size.height)
if (size == IntSize.Zero || widthDelta > 1 || heightDelta > 1) size = next
}
.pointerInput(pageStarts, offset, chapterIndex, mode) {
awaitEachGesture {
val down = awaitFirstDown(requireUnconsumed = false)
val start = down.position
var last = start
var totalX = 0f
var totalY = 0f
var velocity = 0f
var samplePos = start
var sampleTime = down.uptimeMillis
var dragged = false
var forwardDrag = false
var dragBegin = DragBegin.IGNORED
val slop = viewConfiguration.touchSlop
val widthPx = size.width.toFloat().coerceAtLeast(1f)
val heightPx = size.height.toFloat().coerceAtLeast(1f)
val vertical = mode == ReadMode.VERTICAL
while (true) {
val event = awaitPointerEvent()
val change = event.changes.firstOrNull { it.id == down.id } ?: break
val dt = change.uptimeMillis - sampleTime
val delta = if (vertical) change.position.y - samplePos.y else change.position.x - samplePos.x
if (change.pressed && dt in 1..48) {
val sample = delta / dt.toFloat() * 1000f
velocity = velocity * 0.6f + sample * 0.4f
}
if (change.pressed) {
samplePos = change.position
sampleTime = change.uptimeMillis
}
last = change.position
totalX = last.x - start.x
totalY = last.y - start.y
val primary = if (vertical) totalY else totalX
val cross = if (vertical) totalX else totalY
if (!dragged && abs(primary) > slop && abs(primary) > abs(cross)) {
dragged = true
forwardDrag = primary < 0f
if (sliding) {
dragBegin = beginSlide(primary)
} else {
dragBegin = beginDrag(
forwardDrag,
last.x.coerceIn(0f, widthPx),
last.y.coerceIn(0f, heightPx),
)
}
} else if (dragged && dragBegin == DragBegin.STARTED) {
if (sliding) {
slide.view?.follow(if (vertical) totalY else totalX)
} else {
curl.view?.follow(
last.x.coerceIn(0f, widthPx),
last.y.coerceIn(0f, heightPx),
)
}
}
if (dragged) change.consume()
if (!change.pressed) break
}
when {
dragged && dragBegin == DragBegin.STARTED -> {
if (sliding) {
slide.view?.release(velocity) { forward ->
onEdge(if (forward) "已经是最后一页" else "已经是第一页")
}
} else {
curl.view?.release()
}
}
dragged && dragBegin == DragBegin.EDGE -> {
onEdge(if (forwardDrag) "已经是最后一页" else "已经是第一页")
}
!dragged && abs(totalX) < slop * 2 && abs(totalY) < slop * 2 -> {
if (vertical) {
when {
start.y < heightPx * 0.28f -> goPrev()
start.y > heightPx * 0.72f -> goNext()
else -> onToggleChrome()
}
} else {
val fromTop = start.y < size.height * 0.45f
when {
start.x < widthPx * 0.28f -> goPrev(fromTop)
start.x > widthPx * 0.72f -> goNext(fromTop)
else -> onToggleChrome()
}
}
}
}
}
},
) {
if (pageStarts == null) {
CircularProgressIndicator(modifier = Modifier.align(Alignment.Center))
} else if (sliding) {
AndroidView(
modifier = Modifier.fillMaxSize(),
factory = { context ->
PageSlideView(context).also { slide.view = it }
},
update = { view ->
slide.view = view
view.axis = if (mode == ReadMode.VERTICAL) PageSlideView.Axis.VERTICAL else PageSlideView.Axis.HORIZONTAL
view.contentInset(pagePadX, pagePadTop, pagePadX, 0)
view.show(pageText, textArgb, pageArgb, fontPx, settings.lineSpacing)
val next = nextDest()
val prev = prevDest()
view.prepare(next?.text, prev?.text, prev?.alignEnd == true)
},
)
} else {
AndroidView(
modifier = Modifier.fillMaxSize(),
factory = { context ->
PageCurlView(context).also { curl.view = it }
},
update = { view ->
curl.view = view
view.contentInset(pagePadX, pagePadTop, pagePadX, 0)
view.show(pageText, textArgb, pageArgb, fontPx, settings.lineSpacing)
},
)
}
}
}
@Composable
private fun ScrollPane(
catalog: ChapterCatalog,
position: ReadingPosition,
jump: Int,
settings: ReaderSettings,
onCommit: (Int, Int) -> Unit,
onToggleChrome: () -> Unit,
) {
var blocks by remember(catalog) { mutableStateOf<List<FlatBlock>?>(null) }
LaunchedEffect(catalog) {
blocks = withContext(Dispatchers.Default) {
if (!catalog.lengthsReady) catalog.warmCooperative()
List(catalog.size) { index -> catalog.chapter(index) }.flatMapIndexed { index, chapter ->
chapter.blocks.map { block -> FlatBlock(index, block.kind, block.text, block.start) }
}
}
}
val readyBlocks = blocks
val listState = rememberLazyListState()
var restored by remember(readyBlocks) { mutableStateOf(false) }
LaunchedEffect(jump, readyBlocks) {
val loaded = readyBlocks ?: return@LaunchedEffect
if (loaded.isEmpty()) return@LaunchedEffect
val index = blockIndex(loaded, position.chapterIndex, position.charOffset)
listState.scrollToItem(index.coerceIn(0, loaded.lastIndex))
restored = true
}
LaunchedEffect(restored, readyBlocks) {
val loaded = readyBlocks ?: return@LaunchedEffect
if (!restored) return@LaunchedEffect
snapshotFlow { listState.firstVisibleItemIndex }
.distinctUntilChanged()
.drop(1)
.collect { index ->
val block = loaded.getOrNull(index) ?: return@collect
onCommit(block.chapterIndex, block.start)
}
}
val scope = rememberCoroutineScope()
val activity = LocalContext.current as MainActivity
DisposableEffect(readyBlocks) {
val loaded = readyBlocks
if (loaded == null) {
onDispose { }
} else {
activity.volumeHandler = { keyCode ->
val forward = keyCode == KeyEvent.KEYCODE_VOLUME_DOWN
val target = (listState.firstVisibleItemIndex + if (forward) 8 else -8)
.coerceIn(0, (loaded.size - 1).coerceAtLeast(0))
scope.launch { listState.animateScrollToItem(target) }
true
}
onDispose { activity.volumeHandler = null }
}
}
if (readyBlocks == null) {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator()
}
return
}
Box(
Modifier
.fillMaxSize()
.pointerInput(Unit) {
awaitEachGesture {
val down = awaitFirstDown(requireUnconsumed = false, pass = PointerEventPass.Initial)
val pointerId = down.id
val start = down.position
val slop = viewConfiguration.touchSlop
val longPress = viewConfiguration.longPressTimeoutMillis
var moved = false
while (true) {
val event = awaitPointerEvent(PointerEventPass.Final)
val change = event.changes.firstOrNull { it.id == pointerId } ?: break
val dx = change.position.x - start.x
val dy = change.position.y - start.y
if (abs(dx) > slop || abs(dy) > slop) moved = true
if (!change.pressed) {
val brief = change.uptimeMillis - down.uptimeMillis < longPress
val width = size.width.toFloat()
if (!moved && brief && start.x > width * 0.28f && start.x < width * 0.72f) {
onToggleChrome()
}
break
}
}
}
},
) {
SelectionContainer {
LazyColumn(
state = listState,
modifier = Modifier.fillMaxSize(),
contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 24.dp, vertical = 16.dp),
) {
itemsIndexed(readyBlocks, key = { index, block -> "$index-${block.chapterIndex}-${block.start}" }) { _, block ->
if (block.kind == BlockKind.TITLE) {
Text(
block.text,
modifier = Modifier.padding(top = 8.dp, bottom = 14.dp),
color = MaterialTheme.colorScheme.onBackground,
fontSize = (settings.fontSp + 2f).sp,
fontWeight = FontWeight.SemiBold,
lineHeight = ((settings.fontSp + 2f) * settings.lineSpacing).sp,
)
} else {
Text(
block.text,
modifier = Modifier.padding(bottom = 16.dp),
color = MaterialTheme.colorScheme.onBackground,
fontSize = settings.fontSp.sp,
lineHeight = (settings.fontSp * settings.lineSpacing).sp,
)
}
}
}
}
}
}
private fun blockIndex(blocks: List<FlatBlock>, chapterIndex: Int, offset: Int): Int {
if (blocks.isEmpty()) return 0
val target = if (offset == Int.MAX_VALUE) Int.MAX_VALUE else offset.coerceAtLeast(0)
var best = 0
for (index in blocks.indices) {
val block = blocks[index]
if (block.chapterIndex < chapterIndex) {
best = index
} else if (block.chapterIndex == chapterIndex && block.start <= target) {
best = index
} else if (block.chapterIndex > chapterIndex) {
break
}
}
return best
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun SettingsSheet(settings: ReaderSettings, viewModel: ReaderViewModel, onDismiss: () -> Unit) {
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
var dragFont by remember(settings.fontSp) { mutableFloatStateOf(settings.fontSp) }
var dragSpacing by remember(settings.lineSpacing) { mutableFloatStateOf(settings.lineSpacing) }
ModalBottomSheet(onDismissRequest = onDismiss, sheetState = sheetState, containerColor = MaterialTheme.colorScheme.surface) {
Column(Modifier.fillMaxWidth().navigationBarsPadding().padding(horizontal = 20.dp).padding(bottom = 28.dp)) {
Text("阅读设置", style = MaterialTheme.typography.titleLarge)
Spacer(Modifier.height(16.dp))
Text("字号 ${dragFont.roundToInt()}", style = MaterialTheme.typography.titleSmall)
Slider(
value = dragFont,
onValueChange = { dragFont = it },
onValueChangeFinished = { viewModel.setFont(dragFont.roundToInt().toFloat()) },
valueRange = 14f..28f,
steps = 13,
)
Text("行距 ${"%.1f".format(dragSpacing)}", style = MaterialTheme.typography.titleSmall)
Slider(
value = dragSpacing,
onValueChange = { dragSpacing = it },
onValueChangeFinished = { viewModel.setSpacing((dragSpacing * 10f).roundToInt() / 10f) },
valueRange = 1.3f..2.2f,
steps = 8,
)
Spacer(Modifier.height(8.dp))
Text("翻页", style = MaterialTheme.typography.titleSmall)
Spacer(Modifier.height(8.dp))
ModeGrid(
labels = listOf("仿真", "左右", "上下", "滚屏"),
selected = when (settings.mode) {
ReadMode.CURL -> 0
ReadMode.HORIZONTAL -> 1
ReadMode.VERTICAL -> 2
ReadMode.SCROLL -> 3
},
onSelect = { index ->
viewModel.setReadMode(when (index) {
1 -> ReadMode.HORIZONTAL
2 -> ReadMode.VERTICAL
3 -> ReadMode.SCROLL
else -> ReadMode.CURL
})
},
)
Spacer(Modifier.height(16.dp))
Text("屏幕", style = MaterialTheme.typography.titleSmall)
Spacer(Modifier.height(8.dp))
ChoiceRow(
labels = listOf("全屏", "显示状态栏"),
selected = if (settings.fullscreen) 0 else 1,
onSelect = { viewModel.setFullscreen(it == 0) },
)
Spacer(Modifier.height(16.dp))
Text("背景", style = MaterialTheme.typography.titleSmall)
Spacer(Modifier.height(8.dp))
ChoiceRow(
labels = listOf("白天", "护眼", "夜间"),
selected = when (settings.theme) {
ThemeMode.DAY -> 0
ThemeMode.EYE -> 1
ThemeMode.NIGHT -> 2
},
onSelect = { index ->
viewModel.setTheme(when (index) {
1 -> ThemeMode.EYE
2 -> ThemeMode.NIGHT
else -> ThemeMode.DAY
})
},
)
Spacer(Modifier.height(18.dp))
Text(
"页读 1.4.7 · 书籍只保存在这台设备上",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
@Composable
private fun ModeGrid(labels: List<String>, selected: Int, onSelect: (Int) -> Unit) {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
labels.chunked(2).forEachIndexed { row, pair ->
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth()) {
pair.forEachIndexed { column, label ->
val index = row * 2 + column
val on = index == selected
Surface(
modifier = Modifier.weight(1f).height(40.dp).clip(RoundedCornerShape(8.dp)).clickable { onSelect(index) },
color = if (on) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceVariant,
) {
Row(
Modifier.fillMaxSize(),
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically,
) {
if (on) {
Icon(
Icons.Filled.Check,
contentDescription = "已选择",
modifier = Modifier.padding(end = 4.dp).size(16.dp),
tint = MaterialTheme.colorScheme.primary,
)
}
Text(
label,
color = if (on) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onSurface,
fontWeight = if (on) FontWeight.SemiBold else FontWeight.Normal,
)
}
}
}
}
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun ChoiceRow(labels: List<String>, selected: Int, onSelect: (Int) -> Unit) {
SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) {
labels.forEachIndexed { index, label ->
SegmentedButton(
selected = selected == index,
onClick = { onSelect(index) },
shape = SegmentedButtonDefaults.itemShape(index, labels.size),
) { Text(label) }
}
}
}
@Suppress("DEPRECATION")
private fun applyReaderBars(
window: android.view.Window,
controller: WindowInsetsControllerCompat,
fullscreen: Boolean,
pageColor: Int,
lightStatus: Boolean,
) {
controller.isAppearanceLightStatusBars = lightStatus
if (fullscreen) {
window.addFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN)
window.statusBarColor = android.graphics.Color.TRANSPARENT
controller.systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
controller.hide(WindowInsetsCompat.Type.statusBars())
} else {
window.clearFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN)
controller.systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_DEFAULT
controller.show(WindowInsetsCompat.Type.statusBars())
window.statusBarColor = pageColor
}
}
@Suppress("DEPRECATION")
private fun restoreReaderBars(
window: android.view.Window,
controller: WindowInsetsControllerCompat,
lightStatus: Boolean,
) {
window.clearFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN)
controller.systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_DEFAULT
controller.show(WindowInsetsCompat.Type.statusBars())
window.statusBarColor = android.graphics.Color.TRANSPARENT
controller.isAppearanceLightStatusBars = lightStatus
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun ChapterSheet(
titles: List<String>,
current: Int,
onPick: (Int) -> Unit,
onDismiss: () -> Unit,
) {
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
val listState = rememberLazyListState()
LaunchedEffect(current) {
if (current > 0) listState.scrollToItem((current - 1).coerceAtLeast(0))
}
ModalBottomSheet(onDismissRequest = onDismiss, sheetState = sheetState, containerColor = MaterialTheme.colorScheme.surface) {
Column(Modifier.fillMaxWidth().navigationBarsPadding()) {
Text("目录", modifier = Modifier.padding(horizontal = 20.dp, vertical = 4.dp), style = MaterialTheme.typography.titleLarge)
Text(
"共 ${titles.size} 章",
modifier = Modifier.padding(horizontal = 20.dp).padding(bottom = 8.dp),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
LazyColumn(state = listState, modifier = Modifier.fillMaxWidth().heightIn(max = 520.dp)) {
itemsIndexed(titles, key = { index, _ -> index }) { index, title ->
val selected = index == current
ListItem(
headlineContent = {
Text(
title,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
fontWeight = if (selected) FontWeight.SemiBold else FontWeight.Normal,
color = if (selected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface,
)
},
supportingContent = { Text(if (selected) "正在阅读" else "第 ${index + 1} 章") },
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
modifier = Modifier.clickable { onPick(index) },
)
}
}
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun ProgressSheet(
progress: Float,
chapterTitle: String,
onSeek: (Float) -> Unit,
onDismiss: () -> Unit,
) {
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
var drag by remember(progress) { mutableFloatStateOf(progress) }
ModalBottomSheet(onDismissRequest = onDismiss, sheetState = sheetState, containerColor = MaterialTheme.colorScheme.surface) {
Column(Modifier.fillMaxWidth().navigationBarsPadding().padding(horizontal = 20.dp).padding(bottom = 28.dp)) {
Text("阅读进度", style = MaterialTheme.typography.titleLarge)
Spacer(Modifier.height(8.dp))
Text(
chapterTitle,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Spacer(Modifier.height(12.dp))
Text("${(drag * 100).roundToInt()}%", style = MaterialTheme.typography.titleMedium)
Slider(
value = drag,
onValueChange = { drag = it },
onValueChangeFinished = { onSeek(drag) },
)
LinearProgressIndicator(
progress = { drag },
modifier = Modifier.fillMaxWidth().height(3.dp).clip(RoundedCornerShape(2.dp)),
drawStopIndicator = {},
)
}
}
}
private val FooterHeight = 28.dp
@Composable
private fun ReaderFooter(chapterPercent: Int, chapterTitle: String) {
val context = LocalContext.current
var timeText by remember { mutableStateOf(formatReaderTime(context)) }
var battery by remember { mutableIntStateOf(-1) }
LaunchedEffect(context) {
while (true) {
timeText = formatReaderTime(context)
val wait = 60_000L - (System.currentTimeMillis() % 60_000L)
delay(wait.coerceAtLeast(1_000L))
}
}
DisposableEffect(context) {
val receiver = object : BroadcastReceiver() {
override fun onReceive(ctx: Context?, intent: Intent?) {
battery = batteryPercent(intent)
}
}
val filter = IntentFilter(Intent.ACTION_BATTERY_CHANGED)
val sticky = if (android.os.Build.VERSION.SDK_INT >= 33) {
context.registerReceiver(receiver, filter, Context.RECEIVER_NOT_EXPORTED)
} else {
@Suppress("UnspecifiedRegisterReceiverFlag")
context.registerReceiver(receiver, filter)
}
battery = batteryPercent(sticky)
onDispose {
runCatching { context.unregisterReceiver(receiver) }
}
}
val ink = MaterialTheme.colorScheme.onSurfaceVariant
Row(
Modifier.fillMaxWidth().height(FooterHeight).padding(horizontal = 12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
"本章 $chapterPercent%",
modifier = Modifier.weight(0.28f),
color = ink,
fontSize = 11.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Text(
chapterTitle,
modifier = Modifier.weight(0.40f),
color = ink,
fontSize = 11.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
textAlign = TextAlign.Center,
)
Text(
if (battery >= 0) "$timeText $battery%" else timeText,
modifier = Modifier.weight(0.32f),
color = ink,
fontSize = 11.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
textAlign = TextAlign.End,
)
}
}
private fun chapterPercent(offset: Int, length: Int): Int {
if (length <= 0) return 0
return ((offset.coerceIn(0, length) * 100f) / length).roundToInt().coerceIn(0, 100)
}
private fun formatReaderTime(context: Context): String {
val pattern = if (DateFormat.is24HourFormat(context)) "HH:mm" else "h:mm"
return SimpleDateFormat(pattern, Locale.getDefault()).format(Date())
}
private fun batteryPercent(intent: Intent?): Int {
if (intent == null) return -1
val level = intent.getIntExtra(BatteryManager.EXTRA_LEVEL, -1)
val scale = intent.getIntExtra(BatteryManager.EXTRA_SCALE, -1)
if (level < 0 || scale <= 0) return -1
return ((level * 100f) / scale).roundToInt().coerceIn(0, 100)
}