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 google-services.json
.kotlin/ .kotlin/
.idea/ .idea/
CLA.md
CONTRIBUTING.md
cla.yml

View file

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

View file

@ -136,12 +136,14 @@ fun EpubReaderTopBar(
isTtsActive: Boolean, isTtsActive: Boolean,
tapToNavigateEnabled: Boolean, tapToNavigateEnabled: Boolean,
volumeScrollEnabled: Boolean, volumeScrollEnabled: Boolean,
isPageTurnAnimationEnabled: Boolean,
onNavigateBack: () -> Unit, onNavigateBack: () -> Unit,
onCloseSearch: () -> Unit, onCloseSearch: () -> Unit,
onChangeRenderMode: (RenderMode) -> Unit, onChangeRenderMode: (RenderMode) -> Unit,
onToggleBookmark: () -> Unit, onToggleBookmark: () -> Unit,
onToggleTapToNavigate: (Boolean) -> Unit, onToggleTapToNavigate: (Boolean) -> Unit,
onToggleVolumeScroll: (Boolean) -> Unit, onToggleVolumeScroll: (Boolean) -> Unit,
onTogglePageTurnAnimation: (Boolean) -> Unit,
onStartAutoScroll: () -> Unit, onStartAutoScroll: () -> Unit,
searchFocusRequester: androidx.compose.ui.focus.FocusRequester, searchFocusRequester: androidx.compose.ui.focus.FocusRequester,
modifier: Modifier = Modifier modifier: Modifier = Modifier
@ -232,8 +234,13 @@ fun EpubReaderTopBar(
) )
HorizontalDivider() HorizontalDivider()
DropdownMenuItem( DropdownMenuItem(
text = { Text("Volume Button Scrolling") }, text = {
enabled = currentRenderMode == RenderMode.VERTICAL_SCROLL, Text(
if (currentRenderMode == RenderMode.VERTICAL_SCROLL) "Volume Button Scrolling"
else "Volume Button Page Turn"
)
},
enabled = true,
onClick = { onClick = {
onToggleVolumeScroll(!volumeScrollEnabled) onToggleVolumeScroll(!volumeScrollEnabled)
showMoreMenu = false showMoreMenu = false
@ -241,6 +248,18 @@ fun EpubReaderTopBar(
trailingIcon = { if (volumeScrollEnabled) Icon(Icons.Default.Check, contentDescription = "Enabled") } trailingIcon = { if (volumeScrollEnabled) Icon(Icons.Default.Check, contentDescription = "Enabled") }
) )
HorizontalDivider() 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( DropdownMenuItem(
text = { Text("Auto Scroll") }, text = { Text("Auto Scroll") },
enabled = !isTtsActive && currentRenderMode == RenderMode.VERTICAL_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.AnimatedContent
import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInVertically 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_USE_SLIDER_KEY = "auto_scroll_use_slider"
private const val AUTO_SCROLL_MIN_SPEED_KEY = "auto_scroll_min_speed" 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 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) { private fun saveAutoScrollMinSpeed(context: Context, speed: Float) {
val prefs = context.getSharedPreferences("reader_prefs", Context.MODE_PRIVATE) val prefs = context.getSharedPreferences("reader_prefs", Context.MODE_PRIVATE)
@ -309,6 +321,10 @@ fun EpubReaderHost(
mutableStateOf(loadTapToNavigateSetting(context)) mutableStateOf(loadTapToNavigateSetting(context))
} }
var isPageTurnAnimationEnabled by remember {
mutableStateOf(loadPageTurnAnimationSetting(context))
}
val locatorConverter = remember(context) { val locatorConverter = remember(context) {
LocatorConverter( LocatorConverter(
bookCacheDao = BookCacheDatabase.getDatabase(context).bookCacheDao(), 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) { BackHandler(enabled = true) {
if (isPageSliderVisible) { if (isPageSliderVisible) {
isPageSliderVisible = false isPageSliderVisible = false
@ -1467,9 +1491,31 @@ fun EpubReaderHost(
scope.launch { scope.launch {
initialScrollTargetForChapter = target initialScrollTargetForChapter = target
if (target == ChapterScrollPosition.START) currentScrollYPosition = 0 if (target == ChapterScrollPosition.START) currentScrollYPosition = 0
currentChapterIndex += offset 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, textAlign = currentTextAlign,
activeHighlightPalette = currentHighlightPalette, activeHighlightPalette = currentHighlightPalette,
onUpdatePalette = onUpdateHighlightPalette, onUpdatePalette = onUpdateHighlightPalette,
isPageTurnAnimationEnabled = isPageTurnAnimationEnabled,
ttsHighlightInfo = TtsHighlightInfo( ttsHighlightInfo = TtsHighlightInfo(
text = ttsState.currentText ?: "", text = ttsState.currentText ?: "",
cfi = ttsState.sourceCfi ?: "", cfi = ttsState.sourceCfi ?: "",
@ -2182,9 +2229,15 @@ fun EpubReaderHost(
}, },
onTap = { tapOffset -> onTap = { tapOffset ->
Timber.d("PaginatedReaderScreen onTap called with offset: $tapOffset") Timber.d("PaginatedReaderScreen onTap called with offset: $tapOffset")
if (volumeScrollEnabled) {
containerFocusRequester.requestFocus()
}
if (tapOffset == null || !tapToNavigateEnabled) { if (tapOffset == null || !tapToNavigateEnabled) {
Timber.d("Condition met: Toggling bars. tapOffset is null: ${tapOffset == null}, tapToNavigateEnabled: $tapToNavigateEnabled")
focusManager.clearFocus() focusManager.clearFocus()
if (volumeScrollEnabled) containerFocusRequester.requestFocus()
if (showBars || showFormatAdjustmentBars) { if (showBars || showFormatAdjustmentBars) {
showBars = false showBars = false
showFormatAdjustmentBars = false showFormatAdjustmentBars = false
@ -2192,37 +2245,34 @@ fun EpubReaderHost(
showBars = true showBars = true
} }
} else { } 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 val oneQuarterWidthPx = constraints.maxWidth / 4f
when { when {
tapOffset.x < oneQuarterWidthPx -> { tapOffset.x < oneQuarterWidthPx -> {
// Left 25%: go to previous page
Timber.d("Tapped left zone. Navigating to previous page.")
scope.launch { scope.launch {
val targetPage = (paginatedPagerState.currentPage - 1).coerceAtLeast(0) val targetPage = (paginatedPagerState.currentPage - 1).coerceAtLeast(0)
if (targetPage != paginatedPagerState.currentPage) { if (targetPage != paginatedPagerState.currentPage) {
paginatedPagerState.scrollToPage(targetPage) if (isPageTurnAnimationEnabled) {
paginatedPagerState.animateScrollToPage(targetPage, animationSpec = tween(700))
} else paginatedPagerState.scrollToPage(targetPage)
} }
} }
} }
tapOffset.x > (constraints.maxWidth - oneQuarterWidthPx) -> { tapOffset.x > (constraints.maxWidth - oneQuarterWidthPx) -> {
// Right 25%: go to next page
Timber.d("Tapped right zone. Navigating to next page.")
scope.launch { scope.launch {
val pageCount = paginatedPagerState.pageCount val pageCount = paginatedPagerState.pageCount
if (pageCount > 0) { if (pageCount > 0) {
val targetPage = (paginatedPagerState.currentPage + 1).coerceAtMost(pageCount - 1) val targetPage = (paginatedPagerState.currentPage + 1).coerceAtMost(pageCount - 1)
if (targetPage != paginatedPagerState.currentPage) { if (targetPage != paginatedPagerState.currentPage) {
paginatedPagerState.scrollToPage(targetPage) if (isPageTurnAnimationEnabled) {
paginatedPagerState.animateScrollToPage(targetPage, animationSpec = tween(700))
} else paginatedPagerState.scrollToPage(targetPage)
} }
} }
} }
} }
else -> { else -> {
// Middle 50%: toggle bars
Timber.d("Tapped middle zone. Toggling bars.")
focusManager.clearFocus() focusManager.clearFocus()
if (volumeScrollEnabled) containerFocusRequester.requestFocus()
if (showBars || showFormatAdjustmentBars) { if (showBars || showFormatAdjustmentBars) {
showBars = false showBars = false
showFormatAdjustmentBars = false showFormatAdjustmentBars = false
@ -2635,6 +2685,7 @@ fun EpubReaderHost(
isTtsActive = isTtsSessionActive, isTtsActive = isTtsSessionActive,
tapToNavigateEnabled = tapToNavigateEnabled, tapToNavigateEnabled = tapToNavigateEnabled,
volumeScrollEnabled = volumeScrollEnabled, volumeScrollEnabled = volumeScrollEnabled,
isPageTurnAnimationEnabled = isPageTurnAnimationEnabled,
onNavigateBack = { triggerSaveAndExit() }, onNavigateBack = { triggerSaveAndExit() },
onCloseSearch = { onCloseSearch = {
searchState.isSearchActive = false searchState.isSearchActive = false
@ -2675,6 +2726,10 @@ fun EpubReaderHost(
tapToNavigateEnabled = enabled tapToNavigateEnabled = enabled
saveTapToNavigateSetting(context, enabled) saveTapToNavigateSetting(context, enabled)
}, },
onTogglePageTurnAnimation = { enabled ->
isPageTurnAnimationEnabled = enabled
savePageTurnAnimationSetting(context, enabled)
},
onToggleVolumeScroll = { enabled -> onToggleVolumeScroll = { enabled ->
volumeScrollEnabled = enabled volumeScrollEnabled = enabled
saveVolumeScrollSetting(context, enabled) saveVolumeScrollSetting(context, enabled)

View file

@ -100,10 +100,11 @@ fun Modifier.volumeScrollHandler(
currentChapterIndex: Int, currentChapterIndex: Int,
totalChapters: Int, totalChapters: Int,
onScrollBy: (Int) -> Unit, onScrollBy: (Int) -> Unit,
onNavigateChapter: (offset: Int, scrollTarget: ChapterScrollPosition) -> Unit onNavigateChapter: (offset: Int, scrollTarget: ChapterScrollPosition) -> Unit,
onNextPage: () -> Unit = {},
onPrevPage: () -> Unit = {}
): Modifier = this.onPreviewKeyEvent { keyEvent -> ): Modifier = this.onPreviewKeyEvent { keyEvent ->
val shouldHandle = volumeScrollEnabled && val shouldHandle = volumeScrollEnabled &&
renderMode == RenderMode.VERTICAL_SCROLL &&
!isTtsActive && !isTtsActive &&
!isMusicActive !isMusicActive
@ -115,25 +116,22 @@ fun Modifier.volumeScrollHandler(
if (!isVolumeKey) return@onPreviewKeyEvent false if (!isVolumeKey) return@onPreviewKeyEvent false
if (keyEvent.type == KeyEventType.KeyDown) { 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
val isAtBottom = (currentScrollY + currentClientHeight) >= (currentScrollHeight - 2)
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) { if (direction == -1 && currentScrollY == 0) {
// Top -> Prev Chapter if (currentChapterIndex > 0) onNavigateChapter(-1, ChapterScrollPosition.END)
if (currentChapterIndex > 0) { } else if (direction == 1 && isAtBottom) {
onNavigateChapter(-1, ChapterScrollPosition.END) if (currentChapterIndex < totalChapters - 1) onNavigateChapter(1, ChapterScrollPosition.START)
} } else {
} else if (direction == 1 && isAtBottom) { val scrollAmount = (currentClientHeight * 0.25).toInt() * direction
// Bottom -> Next Chapter onScrollBy(scrollAmount)
if (currentChapterIndex < totalChapters - 1) {
onNavigateChapter(1, ChapterScrollPosition.START)
} }
} else { } else {
// Scroll if (isNext) onNextPage() else onPrevPage()
val scrollAmount = (currentClientHeight * 0.25).toInt() * direction
onScrollBy(scrollAmount)
} }
} }
true true