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,26 +116,23 @@ 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
if (renderMode == RenderMode.VERTICAL_SCROLL) {
val direction = if (isNext) 1 else -1
val isAtBottom = (currentScrollY + currentClientHeight) >= (currentScrollHeight - 2) val isAtBottom = (currentScrollY + currentClientHeight) >= (currentScrollHeight - 2)
Timber.d("Dir: $direction, AtBottom: $isAtBottom, Y: $currentScrollY")
if (direction == -1 && currentScrollY == 0) { 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) { } 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 { } else {
// Scroll
val scrollAmount = (currentClientHeight * 0.25).toInt() * direction val scrollAmount = (currentClientHeight * 0.25).toInt() * direction
onScrollBy(scrollAmount) onScrollBy(scrollAmount)
} }
} else {
if (isNext) onNextPage() else onPrevPage()
}
} }
true true
} }

View file

@ -84,16 +84,23 @@ import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.composed
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.drawBehind import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Rect import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.graphics.BlendMode import androidx.compose.ui.graphics.BlendMode
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ColorFilter import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.graphics.ColorMatrix import androidx.compose.ui.graphics.ColorMatrix
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.PathEffect import androidx.compose.ui.graphics.PathEffect
import androidx.compose.ui.graphics.drawscope.Stroke 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.graphics.isSpecified
import androidx.compose.ui.input.pointer.PointerEventPass import androidx.compose.ui.input.pointer.PointerEventPass
import androidx.compose.ui.input.pointer.pointerInput 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.unit.toSize
import androidx.compose.ui.window.Popup import androidx.compose.ui.window.Popup
import androidx.compose.ui.window.PopupPositionProvider import androidx.compose.ui.window.PopupPositionProvider
import androidx.compose.ui.zIndex
import androidx.core.net.toUri import androidx.core.net.toUri
import coil.ImageLoader import coil.ImageLoader
import coil.compose.AsyncImage import coil.compose.AsyncImage
@ -478,6 +486,7 @@ fun PaginatedReaderScreen(
book: EpubBook, book: EpubBook,
isDarkTheme: Boolean, isDarkTheme: Boolean,
pagerState: PagerState, pagerState: PagerState,
isPageTurnAnimationEnabled: Boolean,
searchQuery: String, searchQuery: String,
fontSizeMultiplier: Float, fontSizeMultiplier: Float,
lineHeightMultiplier: Float, lineHeightMultiplier: Float,
@ -736,6 +745,7 @@ fun PaginatedReaderScreen(
PaginatedReaderContent( PaginatedReaderContent(
uiState = uiState, uiState = uiState,
pagerState = pagerState, pagerState = pagerState,
isPageTurnAnimationEnabled = isPageTurnAnimationEnabled,
searchQuery = searchQuery, searchQuery = searchQuery,
ttsHighlightInfo = ttsHighlightInfo, ttsHighlightInfo = ttsHighlightInfo,
textStyle = textStyle, textStyle = textStyle,
@ -1292,6 +1302,7 @@ private fun TextWithEmphasis(
internal fun PaginatedReaderContent( internal fun PaginatedReaderContent(
uiState: PaginatedReaderUiState, uiState: PaginatedReaderUiState,
pagerState: PagerState, pagerState: PagerState,
isPageTurnAnimationEnabled: Boolean,
searchQuery: String, searchQuery: String,
ttsHighlightInfo: TtsHighlightInfo?, ttsHighlightInfo: TtsHighlightInfo?,
textStyle: TextStyle, textStyle: TextStyle,
@ -1380,13 +1391,15 @@ internal fun PaginatedReaderContent(
}, onHide = { menuState = null }) }, onHide = { menuState = null })
} }
var pageTurnTouchY by remember { mutableStateOf<Float?>(null) }
if (uiState.isLoading) { if (uiState.isLoading) {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator() CircularProgressIndicator()
} }
} else { } else {
if (uiState.totalPageCount > 0) { if (uiState.totalPageCount > 0) {
val generation = uiState.generation uiState.generation
val realClipboard: Clipboard = LocalClipboard.current val realClipboard: Clipboard = LocalClipboard.current
var isForDictionary by remember { mutableStateOf(false) } var isForDictionary by remember { mutableStateOf(false) }
@ -1430,17 +1443,42 @@ internal fun PaginatedReaderContent(
LocalTextToolbar provides textToolbar, LocalClipboard provides dictionaryClipboard LocalTextToolbar provides textToolbar, LocalClipboard provides dictionaryClipboard
) { ) {
HorizontalPager( 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 -> ) { 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 pageContent by remember { mutableStateOf<Page?>(null) }
var currentChapterPath by remember { mutableStateOf<String?>(null) } var currentChapterPath by remember { mutableStateOf<String?>(null) }
LaunchedEffect(pageIndex, generation) { LaunchedEffect(pageIndex, uiState.generation) {
pageContent = onGetPage(pageIndex) pageContent = onGetPage(pageIndex)
onGetChapterPath(pageIndex)?.let { currentChapterPath = it } onGetChapterPath(pageIndex)?.let { currentChapterPath = it }
} }
// SelectionContainer will now use our custom dictionaryClipboardManager Box(modifier = Modifier.fillMaxSize().then(pageModifier)) {
SelectionContainer(modifier = Modifier.fillMaxSize()) { SelectionContainer(modifier = Modifier.fillMaxSize()) {
Box(modifier = Modifier Box(modifier = Modifier
.fillMaxSize() .fillMaxSize()
@ -2463,6 +2501,7 @@ internal fun PaginatedReaderContent(
} }
} }
} }
}
menuState?.let { state -> menuState?.let { state ->
Popup(popupPositionProvider = remember(state.rect, density) { Popup(popupPositionProvider = remember(state.rect, density) {
SmartPopupPositionProvider(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()
}
}
}