Epub page turn animation (#23)

* Added realistic page turn animation option in epub pagination mode

* Added volume key navigation support for paginated render mode and added page turn animation to "tap to change page" and "volume button to change page"
This commit is contained in:
Aryan 2026-03-03 14:55:50 +05:30 committed by GitHub
parent 7d26e10c07
commit 1ce353ad44
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 1203 additions and 926 deletions

3
.gitignore vendored
View file

@ -18,3 +18,6 @@ local.properties
google-services.json
.kotlin/
.idea/
CLA.md
CONTRIBUTING.md
cla.yml

View file

@ -25,8 +25,8 @@ android {
applicationId = "com.aryan.reader"
minSdk = 26
targetSdk = 35
versionCode = 34
versionName = "1.0.33"
versionCode = 35
versionName = "1.0.34"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
externalNativeBuild {

View file

@ -136,12 +136,14 @@ fun EpubReaderTopBar(
isTtsActive: Boolean,
tapToNavigateEnabled: Boolean,
volumeScrollEnabled: Boolean,
isPageTurnAnimationEnabled: Boolean,
onNavigateBack: () -> Unit,
onCloseSearch: () -> Unit,
onChangeRenderMode: (RenderMode) -> Unit,
onToggleBookmark: () -> Unit,
onToggleTapToNavigate: (Boolean) -> Unit,
onToggleVolumeScroll: (Boolean) -> Unit,
onTogglePageTurnAnimation: (Boolean) -> Unit,
onStartAutoScroll: () -> Unit,
searchFocusRequester: androidx.compose.ui.focus.FocusRequester,
modifier: Modifier = Modifier
@ -232,8 +234,13 @@ fun EpubReaderTopBar(
)
HorizontalDivider()
DropdownMenuItem(
text = { Text("Volume Button Scrolling") },
enabled = currentRenderMode == RenderMode.VERTICAL_SCROLL,
text = {
Text(
if (currentRenderMode == RenderMode.VERTICAL_SCROLL) "Volume Button Scrolling"
else "Volume Button Page Turn"
)
},
enabled = true,
onClick = {
onToggleVolumeScroll(!volumeScrollEnabled)
showMoreMenu = false
@ -241,6 +248,18 @@ fun EpubReaderTopBar(
trailingIcon = { if (volumeScrollEnabled) Icon(Icons.Default.Check, contentDescription = "Enabled") }
)
HorizontalDivider()
DropdownMenuItem(
text = { Text("Realistic Page Turns") },
enabled = currentRenderMode == RenderMode.PAGINATED,
onClick = {
onTogglePageTurnAnimation(!isPageTurnAnimationEnabled)
showMoreMenu = false
},
trailingIcon = { if (isPageTurnAnimationEnabled) Icon(Icons.Default.Check, contentDescription = "Enabled") }
)
HorizontalDivider()
DropdownMenuItem(
text = { Text("Auto Scroll") },
enabled = !isTtsActive && currentRenderMode == RenderMode.VERTICAL_SCROLL,

View file

@ -39,6 +39,7 @@ import androidx.annotation.RequiresApi
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInVertically
@ -175,6 +176,17 @@ private const val AUTO_SCROLL_LOCKED_KEY = "auto_scroll_locked"
private const val AUTO_SCROLL_USE_SLIDER_KEY = "auto_scroll_use_slider"
private const val AUTO_SCROLL_MIN_SPEED_KEY = "auto_scroll_min_speed"
private const val AUTO_SCROLL_MAX_SPEED_KEY = "auto_scroll_max_speed"
private const val PAGE_TURN_ANIMATION_KEY = "page_turn_animation_enabled"
private fun savePageTurnAnimationSetting(context: Context, isEnabled: Boolean) {
val prefs = context.getSharedPreferences("reader_prefs", Context.MODE_PRIVATE)
prefs.edit { putBoolean(PAGE_TURN_ANIMATION_KEY, isEnabled) }
}
private fun loadPageTurnAnimationSetting(context: Context): Boolean {
val prefs = context.getSharedPreferences("reader_prefs", Context.MODE_PRIVATE)
return prefs.getBoolean(PAGE_TURN_ANIMATION_KEY, false)
}
private fun saveAutoScrollMinSpeed(context: Context, speed: Float) {
val prefs = context.getSharedPreferences("reader_prefs", Context.MODE_PRIVATE)
@ -309,6 +321,10 @@ fun EpubReaderHost(
mutableStateOf(loadTapToNavigateSetting(context))
}
var isPageTurnAnimationEnabled by remember {
mutableStateOf(loadPageTurnAnimationSetting(context))
}
val locatorConverter = remember(context) {
LocatorConverter(
bookCacheDao = BookCacheDatabase.getDatabase(context).bookCacheDao(),
@ -1109,6 +1125,14 @@ fun EpubReaderHost(
)
}
LaunchedEffect(paginatedPagerState.currentPage, currentRenderMode) {
if (currentRenderMode == RenderMode.PAGINATED && volumeScrollEnabled) {
delay(200)
containerFocusRequester.requestFocus()
Timber.d("Paginated: Page changed to ${paginatedPagerState.currentPage}, re-requesting focus for volume keys.")
}
}
BackHandler(enabled = true) {
if (isPageSliderVisible) {
isPageSliderVisible = false
@ -1467,9 +1491,31 @@ fun EpubReaderHost(
scope.launch {
initialScrollTargetForChapter = target
if (target == ChapterScrollPosition.START) currentScrollYPosition = 0
currentChapterIndex += offset
}
},
onNextPage = {
scope.launch {
val pageCount = paginatedPagerState.pageCount
if (pageCount > 0) {
val targetPage = (paginatedPagerState.currentPage + 1).coerceAtMost(pageCount - 1)
if (targetPage != paginatedPagerState.currentPage) {
if (isPageTurnAnimationEnabled) {
paginatedPagerState.animateScrollToPage(targetPage, animationSpec = tween(700))
} else paginatedPagerState.scrollToPage(targetPage)
}
}
}
},
onPrevPage = {
scope.launch {
val targetPage = (paginatedPagerState.currentPage - 1).coerceAtLeast(0)
if (targetPage != paginatedPagerState.currentPage) {
if (isPageTurnAnimationEnabled) {
paginatedPagerState.animateScrollToPage(targetPage, animationSpec = tween(700))
} else paginatedPagerState.scrollToPage(targetPage)
}
}
}
)
) {
@ -2170,6 +2216,7 @@ fun EpubReaderHost(
textAlign = currentTextAlign,
activeHighlightPalette = currentHighlightPalette,
onUpdatePalette = onUpdateHighlightPalette,
isPageTurnAnimationEnabled = isPageTurnAnimationEnabled,
ttsHighlightInfo = TtsHighlightInfo(
text = ttsState.currentText ?: "",
cfi = ttsState.sourceCfi ?: "",
@ -2182,9 +2229,15 @@ fun EpubReaderHost(
},
onTap = { tapOffset ->
Timber.d("PaginatedReaderScreen onTap called with offset: $tapOffset")
if (volumeScrollEnabled) {
containerFocusRequester.requestFocus()
}
if (tapOffset == null || !tapToNavigateEnabled) {
Timber.d("Condition met: Toggling bars. tapOffset is null: ${tapOffset == null}, tapToNavigateEnabled: $tapToNavigateEnabled")
focusManager.clearFocus()
if (volumeScrollEnabled) containerFocusRequester.requestFocus()
if (showBars || showFormatAdjustmentBars) {
showBars = false
showFormatAdjustmentBars = false
@ -2192,37 +2245,34 @@ fun EpubReaderHost(
showBars = true
}
} else {
// Handle zoned navigation since tapOffset is not null and the feature is enabled.
Timber.d("Condition met: Handling zoned navigation.")
val oneQuarterWidthPx = constraints.maxWidth / 4f
when {
tapOffset.x < oneQuarterWidthPx -> {
// Left 25%: go to previous page
Timber.d("Tapped left zone. Navigating to previous page.")
scope.launch {
val targetPage = (paginatedPagerState.currentPage - 1).coerceAtLeast(0)
if (targetPage != paginatedPagerState.currentPage) {
paginatedPagerState.scrollToPage(targetPage)
if (isPageTurnAnimationEnabled) {
paginatedPagerState.animateScrollToPage(targetPage, animationSpec = tween(700))
} else paginatedPagerState.scrollToPage(targetPage)
}
}
}
tapOffset.x > (constraints.maxWidth - oneQuarterWidthPx) -> {
// Right 25%: go to next page
Timber.d("Tapped right zone. Navigating to next page.")
scope.launch {
val pageCount = paginatedPagerState.pageCount
if (pageCount > 0) {
val targetPage = (paginatedPagerState.currentPage + 1).coerceAtMost(pageCount - 1)
if (targetPage != paginatedPagerState.currentPage) {
paginatedPagerState.scrollToPage(targetPage)
if (isPageTurnAnimationEnabled) {
paginatedPagerState.animateScrollToPage(targetPage, animationSpec = tween(700))
} else paginatedPagerState.scrollToPage(targetPage)
}
}
}
}
else -> {
// Middle 50%: toggle bars
Timber.d("Tapped middle zone. Toggling bars.")
focusManager.clearFocus()
if (volumeScrollEnabled) containerFocusRequester.requestFocus()
if (showBars || showFormatAdjustmentBars) {
showBars = false
showFormatAdjustmentBars = false
@ -2635,6 +2685,7 @@ fun EpubReaderHost(
isTtsActive = isTtsSessionActive,
tapToNavigateEnabled = tapToNavigateEnabled,
volumeScrollEnabled = volumeScrollEnabled,
isPageTurnAnimationEnabled = isPageTurnAnimationEnabled,
onNavigateBack = { triggerSaveAndExit() },
onCloseSearch = {
searchState.isSearchActive = false
@ -2675,6 +2726,10 @@ fun EpubReaderHost(
tapToNavigateEnabled = enabled
saveTapToNavigateSetting(context, enabled)
},
onTogglePageTurnAnimation = { enabled ->
isPageTurnAnimationEnabled = enabled
savePageTurnAnimationSetting(context, enabled)
},
onToggleVolumeScroll = { enabled ->
volumeScrollEnabled = enabled
saveVolumeScrollSetting(context, enabled)

View file

@ -100,10 +100,11 @@ fun Modifier.volumeScrollHandler(
currentChapterIndex: Int,
totalChapters: Int,
onScrollBy: (Int) -> Unit,
onNavigateChapter: (offset: Int, scrollTarget: ChapterScrollPosition) -> Unit
onNavigateChapter: (offset: Int, scrollTarget: ChapterScrollPosition) -> Unit,
onNextPage: () -> Unit = {},
onPrevPage: () -> Unit = {}
): Modifier = this.onPreviewKeyEvent { keyEvent ->
val shouldHandle = volumeScrollEnabled &&
renderMode == RenderMode.VERTICAL_SCROLL &&
!isTtsActive &&
!isMusicActive
@ -115,26 +116,23 @@ fun Modifier.volumeScrollHandler(
if (!isVolumeKey) return@onPreviewKeyEvent false
if (keyEvent.type == KeyEventType.KeyDown) {
val direction = if (keyEvent.nativeKeyEvent.keyCode == KeyEvent.KEYCODE_VOLUME_DOWN) 1 else -1
val isNext = keyEvent.nativeKeyEvent.keyCode == KeyEvent.KEYCODE_VOLUME_DOWN
if (renderMode == RenderMode.VERTICAL_SCROLL) {
val direction = if (isNext) 1 else -1
val isAtBottom = (currentScrollY + currentClientHeight) >= (currentScrollHeight - 2)
Timber.d("Dir: $direction, AtBottom: $isAtBottom, Y: $currentScrollY")
if (direction == -1 && currentScrollY == 0) {
// Top -> Prev Chapter
if (currentChapterIndex > 0) {
onNavigateChapter(-1, ChapterScrollPosition.END)
}
if (currentChapterIndex > 0) onNavigateChapter(-1, ChapterScrollPosition.END)
} else if (direction == 1 && isAtBottom) {
// Bottom -> Next Chapter
if (currentChapterIndex < totalChapters - 1) {
onNavigateChapter(1, ChapterScrollPosition.START)
}
if (currentChapterIndex < totalChapters - 1) onNavigateChapter(1, ChapterScrollPosition.START)
} else {
// Scroll
val scrollAmount = (currentClientHeight * 0.25).toInt() * direction
onScrollBy(scrollAmount)
}
} else {
if (isNext) onNextPage() else onPrevPage()
}
}
true
}

View file

@ -84,16 +84,23 @@ import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.composed
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.graphics.BlendMode
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.graphics.ColorMatrix
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.PathEffect
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.graphics.drawscope.clipPath
import androidx.compose.ui.graphics.drawscope.clipRect
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.graphics.isSpecified
import androidx.compose.ui.input.pointer.PointerEventPass
import androidx.compose.ui.input.pointer.pointerInput
@ -138,6 +145,7 @@ import androidx.compose.ui.unit.sp
import androidx.compose.ui.unit.toSize
import androidx.compose.ui.window.Popup
import androidx.compose.ui.window.PopupPositionProvider
import androidx.compose.ui.zIndex
import androidx.core.net.toUri
import coil.ImageLoader
import coil.compose.AsyncImage
@ -478,6 +486,7 @@ fun PaginatedReaderScreen(
book: EpubBook,
isDarkTheme: Boolean,
pagerState: PagerState,
isPageTurnAnimationEnabled: Boolean,
searchQuery: String,
fontSizeMultiplier: Float,
lineHeightMultiplier: Float,
@ -736,6 +745,7 @@ fun PaginatedReaderScreen(
PaginatedReaderContent(
uiState = uiState,
pagerState = pagerState,
isPageTurnAnimationEnabled = isPageTurnAnimationEnabled,
searchQuery = searchQuery,
ttsHighlightInfo = ttsHighlightInfo,
textStyle = textStyle,
@ -1292,6 +1302,7 @@ private fun TextWithEmphasis(
internal fun PaginatedReaderContent(
uiState: PaginatedReaderUiState,
pagerState: PagerState,
isPageTurnAnimationEnabled: Boolean,
searchQuery: String,
ttsHighlightInfo: TtsHighlightInfo?,
textStyle: TextStyle,
@ -1380,13 +1391,15 @@ internal fun PaginatedReaderContent(
}, onHide = { menuState = null })
}
var pageTurnTouchY by remember { mutableStateOf<Float?>(null) }
if (uiState.isLoading) {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator()
}
} else {
if (uiState.totalPageCount > 0) {
val generation = uiState.generation
uiState.generation
val realClipboard: Clipboard = LocalClipboard.current
var isForDictionary by remember { mutableStateOf(false) }
@ -1430,17 +1443,42 @@ internal fun PaginatedReaderContent(
LocalTextToolbar provides textToolbar, LocalClipboard provides dictionaryClipboard
) {
HorizontalPager(
state = pagerState, modifier = Modifier.fillMaxSize()
state = pagerState,
modifier = Modifier
.fillMaxSize()
.pointerInput(Unit) {
awaitPointerEventScope {
while (true) {
val event = awaitPointerEvent(PointerEventPass.Initial)
val down = event.changes.firstOrNull { it.pressed }
if (down != null) {
pageTurnTouchY = down.position.y
}
}
}
},
beyondViewportPageCount = 1
) { pageIndex ->
val pageOffset = (pageIndex - pagerState.currentPage) - pagerState.currentPageOffsetFraction
val zIndex = -pageOffset
val pageModifier = if (isPageTurnAnimationEnabled) {
Modifier
.zIndex(zIndex)
.realisticBookPage(pagerState, pageIndex, isDarkTheme, pageTurnTouchY) // UPDATED
} else {
Modifier
}
var pageContent by remember { mutableStateOf<Page?>(null) }
var currentChapterPath by remember { mutableStateOf<String?>(null) }
LaunchedEffect(pageIndex, generation) {
LaunchedEffect(pageIndex, uiState.generation) {
pageContent = onGetPage(pageIndex)
onGetChapterPath(pageIndex)?.let { currentChapterPath = it }
}
// SelectionContainer will now use our custom dictionaryClipboardManager
Box(modifier = Modifier.fillMaxSize().then(pageModifier)) {
SelectionContainer(modifier = Modifier.fillMaxSize()) {
Box(modifier = Modifier
.fillMaxSize()
@ -2463,6 +2501,7 @@ internal fun PaginatedReaderContent(
}
}
}
}
menuState?.let { state ->
Popup(popupPositionProvider = remember(state.rect, density) {
SmartPopupPositionProvider(state.rect, density)
@ -3282,3 +3321,166 @@ private fun RenderFlexChildBlock(
}
}
}
@OptIn(ExperimentalFoundationApi::class)
private fun Modifier.realisticBookPage(
pagerState: PagerState,
pageIndex: Int,
isDarkTheme: Boolean,
touchY: Float?
): Modifier = composed {
val frontPath = remember { Path() }
val backPath = remember { Path() }
val reflectedScreenPath = remember { Path() }
this
.graphicsLayer {
val pageOffset = (pageIndex - pagerState.currentPage) - pagerState.currentPageOffsetFraction
if (pageOffset <= 1f && pageOffset > -1f) {
translationX = -pageOffset * size.width
}
if (pageOffset != 0f) {
shadowElevation = 10f
shape = androidx.compose.ui.graphics.RectangleShape
clip = false
}
}
.drawWithContent {
val pageOffset = (pageIndex - pagerState.currentPage) - pagerState.currentPageOffsetFraction
val paperColor = if (isDarkTheme) Color(0xFF121212) else Color(0xFFFFFFFF)
if (abs(pageOffset) < 0.001f) {
drawRect(color = paperColor)
drawContent()
}
else if (pageOffset < 0f && pageOffset > -1f) {
val progress = -pageOffset
val w = size.width
val h = size.height
val startY = touchY ?: h
val centerDist = ((startY - h / 2f) / (h / 2f)).coerceIn(-1f, 1f)
val cornerY = if (centerDist >= 0) h else 0f
val dragX = w - w * 2.2f * progress
val dragY = cornerY - h * 0.5f * progress * centerDist
val midX = (w + dragX) / 2f
val midY = (cornerY + dragY) / 2f
val dx = w - dragX
val dy = cornerY - dragY
val nLen = kotlin.math.sqrt(dx * dx + dy * dy)
if (nLen > 0f) {
val nx = dx / nLen
val ny = dy / nLen
val huge = w * 3f
val vx = -ny
val p1X = midX + vx * huge
val p1Y = midY + nx * huge
val p2X = midX - vx * huge
val p2Y = midY - nx * huge
frontPath.rewind()
frontPath.moveTo(p1X, p1Y)
frontPath.lineTo(p2X, p2Y)
frontPath.lineTo(p2X - nx * huge, p2Y - ny * huge)
frontPath.lineTo(p1X - nx * huge, p1Y - ny * huge)
frontPath.close()
clipPath(frontPath) {
drawRect(color = paperColor)
this@drawWithContent.drawContent()
}
val shadowWidth = (40.dp.toPx() * (1f - progress)).coerceAtLeast(10.dp.toPx())
backPath.rewind()
backPath.moveTo(p1X, p1Y)
backPath.lineTo(p2X, p2Y)
backPath.lineTo(p2X + nx * huge, p2Y + ny * huge)
backPath.lineTo(p1X + nx * huge, p1Y + ny * huge)
backPath.close()
val dropShadowBrush = Brush.linearGradient(
colors = listOf(Color.Black.copy(alpha = 0.4f), Color.Transparent),
start = Offset(midX, midY),
end = Offset(midX + nx * shadowWidth, midY + ny * shadowWidth)
)
clipRect(0f, 0f, w, h) {
drawPath(backPath, dropShadowBrush)
}
fun reflect(px: Float, py: Float): Offset {
val vX = px - midX
val vY = py - midY
val dist = vX * nx + vY * ny
return Offset(px - 2 * dist * nx, py - 2 * dist * ny)
}
val rTL = reflect(0f, 0f)
val rTR = reflect(w, 0f)
val rBR = reflect(w, h)
val rBL = reflect(0f, h)
reflectedScreenPath.rewind()
reflectedScreenPath.moveTo(rTL.x, rTL.y)
reflectedScreenPath.lineTo(rTR.x, rTR.y)
reflectedScreenPath.lineTo(rBR.x, rBR.y)
reflectedScreenPath.lineTo(rBL.x, rBL.y)
reflectedScreenPath.close()
clipRect(0f, 0f, w, h) {
clipPath(frontPath) {
val flapColor = if (isDarkTheme) Color(0xFF2A2A2A) else Color(0xFFF0F0F0)
drawPath(reflectedScreenPath, color = flapColor)
val innerShadowWidth = shadowWidth * 0.7f
val innerShadowBrush = Brush.linearGradient(
colors = listOf(
Color.Black.copy(alpha = 0.25f),
Color.Black.copy(alpha = 0.05f),
Color.Transparent
),
start = Offset(midX, midY),
end = Offset(midX - nx * innerShadowWidth, midY - ny * innerShadowWidth)
)
drawPath(reflectedScreenPath, innerShadowBrush)
drawPath(
path = reflectedScreenPath,
color = if (isDarkTheme) Color.White.copy(alpha = 0.15f) else Color.Black.copy(alpha = 0.15f),
style = Stroke(width = 1.dp.toPx())
)
}
drawLine(
color = if (isDarkTheme) Color.White.copy(alpha = 0.1f) else Color.Black.copy(alpha = 0.1f),
start = Offset(p1X, p1Y),
end = Offset(p2X, p2Y),
strokeWidth = 1.dp.toPx()
)
}
} else {
drawRect(color = paperColor)
drawContent()
}
}
else if (pageOffset > 0f && pageOffset <= 1f) {
drawRect(color = paperColor)
drawContent()
val dimAlpha = (0.25f * pageOffset).coerceIn(0f, 0.4f)
drawRect(color = Color.Black.copy(alpha = dimAlpha))
}
else {
drawRect(color = paperColor)
drawContent()
}
}
}