Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion app/src/main/java/com/markreader/ui/screens/EditorScreen.kt
Original file line number Diff line number Diff line change
Expand Up @@ -295,14 +295,17 @@ fun EditorScreen(
} else {
val previewTextColor = MaterialTheme.colorScheme.onSurface.toArgb()
val previewIsDark = isSystemInDarkTheme()
// The preview never restores a scroll position.
val previewScrollY = remember { mutableStateOf(0) }
Box(modifier = Modifier.weight(1f).fillMaxWidth()) {
RenderedTextView(
text = previewText,
textColor = previewTextColor,
padding = PaddingValues(0.dp),
savedScrollY = 0,
savedScrollY = previewScrollY,
scrollToOffset = null,
onScrollChanged = { _, _ -> },
onScrollExtentChanged = {},
onScrollConsumed = {},
headings = emptyList(),
onActiveHeadingChanged = {},
Expand Down
151 changes: 107 additions & 44 deletions app/src/main/java/com/markreader/ui/screens/ViewerScreen.kt
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package com.markreader.ui.screens

import android.app.Application
import android.os.Build
import android.os.SystemClock
import androidx.activity.compose.BackHandler
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
Expand Down Expand Up @@ -79,10 +80,12 @@ import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.toArgb
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.State
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
Expand Down Expand Up @@ -115,6 +118,12 @@ import com.markreader.ui.components.segmentShape
import com.markreader.ui.export.ExportManager
import kotlin.math.roundToInt

/**
* How long the chrome's visibility decision is held after it changes, covering the
* show/hide animation and the relayout it causes.
*/
private const val CHROME_SETTLE_MS = 350L

@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ViewerScreen(
Expand All @@ -130,8 +139,13 @@ fun ViewerScreen(
)
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
val scrollToOffset by viewModel.scrollToOffset.collectAsStateWithLifecycle()
val savedScrollY by viewModel.scrollY.collectAsStateWithLifecycle()
val scrollProgress by viewModel.scrollProgress.collectAsStateWithLifecycle()
// Deliberately not read with `by` here. Both change on every scroll frame, so
// reading them in this scope would recompose the whole screen — including the
// viewer's AndroidView update block — 60+ times a second while scrolling.
// They are read inside the leaf composables that actually display them, and
// inside snapshotFlow below, so the reads stay out of this scope.
val savedScrollY = viewModel.scrollY.collectAsStateWithLifecycle()
val scrollProgress = viewModel.scrollProgress.collectAsStateWithLifecycle()
val prefs = uiState.userPreferences
val isSystemDark = isSystemInDarkTheme()

Expand Down Expand Up @@ -176,16 +190,32 @@ fun ViewerScreen(
// Immersive reading: hide the chrome on downward scrolls, bring it back on
// upward scrolls or at the top. Large deltas are programmatic jumps (TOC,
// search match) where the user just used the chrome — keep it visible.
// Showing or hiding the bar changes the content's top inset, which resizes the
// ScrollView and can clamp its position — emitting a scroll delta that points
// the opposite way and immediately flips the decision back. Hold the decision
// briefly after each change so the bar can finish animating instead of
// stuttering against its own layout effect.
LaunchedEffect(Unit) {
var lastY = 0
snapshotFlow { savedScrollY }.collect { y ->
var settleUntil = 0L
snapshotFlow { savedScrollY.value }.collect { y ->
val delta = y - lastY
when {
y <= 0 -> isChromeVisible = true
delta < -8 -> isChromeVisible = true
delta in 9..1200 -> isChromeVisible = false
}
lastY = y
if (y <= 0) {
isChromeVisible = true
settleUntil = 0L
return@collect
}
if (SystemClock.uptimeMillis() < settleUntil) return@collect
val target = when {
delta < -8 -> true
delta in 9..1200 -> false
else -> return@collect
}
if (target != isChromeVisible) {
isChromeVisible = target
settleUntil = SystemClock.uptimeMillis() + CHROME_SETTLE_MS
}
}
}
LaunchedEffect(uiState.isSearchActive) {
Expand Down Expand Up @@ -407,23 +437,11 @@ fun ViewerScreen(
)
}
}
scrollProgress?.let { progress ->
Surface(
shape = RoundedCornerShape(50),
color = chromeColors.tonalContainer.copy(alpha = 0.6f),
contentColor = chromeColors.muted
) {
Text(
text = "${(progress * 100).roundToInt()}%",
style = MaterialTheme.typography.labelSmall,
maxLines = 1,
modifier = Modifier.padding(
horizontal = 8.dp,
vertical = 2.dp
)
)
}
}
ReadingProgressChip(
progress = scrollProgress,
containerColor = chromeColors.tonalContainer,
contentColor = chromeColors.muted
)
}
}
},
Expand Down Expand Up @@ -614,26 +632,11 @@ fun ViewerScreen(
}
}
}
val animatedReadProgress by animateFloatAsState(
targetValue = scrollProgress ?: 0f,
animationSpec = spring(stiffness = Spring.StiffnessLow),
label = "readingProgress"
ReadingProgressBar(
progress = scrollProgress,
trackColor = chromeColors.tonalContainer,
barColor = chromeColors.content
)
if (scrollProgress != null) {
Box(
modifier = Modifier
.fillMaxWidth()
.height(3.dp)
.background(chromeColors.tonalContainer.copy(alpha = 0.5f))
) {
Box(
modifier = Modifier
.fillMaxWidth(animatedReadProgress.coerceIn(0f, 1f))
.fillMaxHeight()
.background(chromeColors.content.copy(alpha = 0.7f))
)
}
}
}
}
) { paddingValues: PaddingValues ->
Expand Down Expand Up @@ -712,6 +715,7 @@ fun ViewerScreen(
savedScrollY = savedScrollY,
scrollToOffset = scrollToOffset,
onScrollChanged = viewModel::onScrollPositionChanged,
onScrollExtentChanged = viewModel::onScrollExtentChanged,
onScrollConsumed = viewModel::onScrollConsumed,
headings = uiState.headings,
onActiveHeadingChanged = viewModel::onActiveHeadingChanged,
Expand Down Expand Up @@ -891,6 +895,65 @@ fun ViewerScreen(
}
}

/**
* Reading-progress percentage chip.
*
* Takes progress as [State] and reads it here rather than in [ViewerScreen], so
* that a scroll frame recomposes only this chip instead of the entire screen.
*/
@Composable
private fun ReadingProgressChip(
progress: State<Float?>,
containerColor: Color,
contentColor: Color
) {
val value = progress.value ?: return
Surface(
shape = RoundedCornerShape(50),
color = containerColor.copy(alpha = 0.6f),
contentColor = contentColor
) {
Text(
text = "${(value * 100).roundToInt()}%",
style = MaterialTheme.typography.labelSmall,
maxLines = 1,
modifier = Modifier.padding(horizontal = 8.dp, vertical = 2.dp)
)
}
}

/**
* Reading-progress bar under the chrome. Reads progress in its own scope for the
* same reason as [ReadingProgressChip].
*/
@Composable
private fun ReadingProgressBar(
progress: State<Float?>,
trackColor: Color,
barColor: Color
) {
val value = progress.value
val animatedReadProgress by animateFloatAsState(
targetValue = value ?: 0f,
animationSpec = spring(stiffness = Spring.StiffnessLow),
label = "readingProgress"
)
if (value == null) return
Box(
modifier = Modifier
.fillMaxWidth()
.height(3.dp)
.background(trackColor.copy(alpha = 0.5f))
) {
Box(
modifier = Modifier
.fillMaxWidth(animatedReadProgress.coerceIn(0f, 1f))
.fillMaxHeight()
.background(barColor.copy(alpha = 0.7f))
)
}
}

@Composable
private fun TocHeadingRow(
heading: HeadingItem,
Expand Down
Loading