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

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,25 +116,22 @@ 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 isAtBottom = (currentScrollY + currentClientHeight) >= (currentScrollHeight - 2)
val isNext = keyEvent.nativeKeyEvent.keyCode == KeyEvent.KEYCODE_VOLUME_DOWN
Timber.d("Dir: $direction, AtBottom: $isAtBottom, Y: $currentScrollY")
if (renderMode == RenderMode.VERTICAL_SCROLL) {
val direction = if (isNext) 1 else -1
val isAtBottom = (currentScrollY + currentClientHeight) >= (currentScrollHeight - 2)
if (direction == -1 && currentScrollY == 0) {
// Top -> Prev Chapter
if (currentChapterIndex > 0) {
onNavigateChapter(-1, ChapterScrollPosition.END)
}
} else if (direction == 1 && isAtBottom) {
// Bottom -> Next Chapter
if (currentChapterIndex < totalChapters - 1) {
onNavigateChapter(1, ChapterScrollPosition.START)
if (direction == -1 && currentScrollY == 0) {
if (currentChapterIndex > 0) onNavigateChapter(-1, ChapterScrollPosition.END)
} else if (direction == 1 && isAtBottom) {
if (currentChapterIndex < totalChapters - 1) onNavigateChapter(1, ChapterScrollPosition.START)
} else {
val scrollAmount = (currentClientHeight * 0.25).toInt() * direction
onScrollBy(scrollAmount)
}
} else {
// Scroll
val scrollAmount = (currentClientHeight * 0.25).toInt() * direction
onScrollBy(scrollAmount)
if (isNext) onNextPage() else onPrevPage()
}
}
true