[New Reader] Implement navigation overlay (#1445)

* [New Reader] Implement navigation overlay

* Fix typo

* Refine

* Reuse navigation regions
This commit is contained in:
FooIbar authored and GitHub committed 2024-07-26 23:45:41 +08:00
1 parent fbe35a0fa0
commit 8ce16bc918
5 files changed
+241 -141

No files matched your search

@@ -70,14 +70,6 @@ inline fun <R, T> PrefDelegate<T>.collectAsState(crossinline transform: @Disallo
return flow.collectAsState(transform(init))
}
@Stable
@Composable
inline fun <R, T> PrefDelegate<T>.collectAsState(key1: Any?, crossinline transform: @DisallowComposableCalls (T) -> R): State<R> {
val flow = remember(key1) { valueFlow().map { transform(it) } }
val init = getValue()
return flow.collectAsState(transform(init))
}
@Stable
@Composable
fun <T> PrefDelegate<T>.collectAsState(): State<T> {
@@ -217,6 +209,8 @@ object Settings : DataStorePreferences(null) {
val imageScaleType = intPref("pref_image_scale_type_key", 1)
val landscapeZoom = boolPref("landscape_zoom", false)
val zoomStart = intPref("pref_zoom_start_key", 1)
val showNavigationOverlayNewUser = boolPref("reader_navigation_overlay_new_user", true)
val showNavigationOverlayOnStart = boolPref("reader_navigation_overlay_on_start", false)
init {
if ("CN" == Locale.getDefault().country) {
@@ -1,6 +1,5 @@
package com.hippo.ehviewer.ui.reader
import android.graphics.PointF
import androidx.compose.foundation.gestures.animateScrollBy
import androidx.compose.foundation.gestures.scrollBy
import androidx.compose.foundation.layout.Arrangement
@@ -44,8 +43,6 @@ import eu.kanade.tachiyomi.source.model.Page
import eu.kanade.tachiyomi.ui.reader.model.ReaderPage
import eu.kanade.tachiyomi.ui.reader.setting.ReadingModeType
import eu.kanade.tachiyomi.ui.reader.setting.ReadingModeType.CONTINUOUS_VERTICAL
import eu.kanade.tachiyomi.ui.reader.setting.ReadingModeType.DEFAULT
import eu.kanade.tachiyomi.ui.reader.setting.ReadingModeType.LEFT_TO_RIGHT
import eu.kanade.tachiyomi.ui.reader.setting.ReadingModeType.RIGHT_TO_LEFT
import eu.kanade.tachiyomi.ui.reader.setting.ReadingModeType.VERTICAL
import eu.kanade.tachiyomi.ui.reader.setting.ReadingModeType.WEBTOON
@@ -75,143 +72,154 @@ fun GalleryPager(
) {
val scope = rememberCoroutineScope()
val items = pageLoader.pages
when (type) {
DEFAULT, LEFT_TO_RIGHT, RIGHT_TO_LEFT, VERTICAL -> {
val isVertical = type == VERTICAL
val scaleType by Settings.imageScaleType.collectAsState()
val landscapeZoom by Settings.landscapeZoom.collectAsState()
val alignment by Settings.zoomStart.collectAsState(type) {
Alignment.fromPreferences(it, type)
}
val navigatorState = Settings.readerPagerNav.collectAsState(isVertical) {
ViewerNavigation.fromPreference(it, isVertical)
}
val invertMode by Settings.readerPagerNavInverted.collectAsState {
TappingInvertMode.entries[it]
}
LaunchedEffect(navigatorState.value, invertMode) {
navigatorState.value.invertMode = invertMode
}
val layoutSize by remember(pagerState) {
derivedStateOf {
pagerState.layoutInfo.viewportSize.toSize()
}
}
if (isVertical) {
VerticalPager(
state = pagerState,
modifier = modifier,
contentPadding = contentPadding,
key = { it },
) { index ->
val page = items[index]
PageContainer(
page = page,
pageLoader = pageLoader,
isRtl = false,
scaleType = scaleType,
landscapeZoom = landscapeZoom,
alignment = alignment,
layoutSize = layoutSize,
navigatorState = navigatorState,
pagerState = pagerState,
onSelectPage = onSelectPage,
onMenuRegionClick = onMenuRegionClick,
scope = scope,
)
}
} else {
val isRtl = type == RIGHT_TO_LEFT
val isRtlLayout = LocalLayoutDirection.current == LayoutDirection.Rtl
HorizontalPager(
state = pagerState,
modifier = modifier,
contentPadding = contentPadding,
reverseLayout = isRtl xor isRtlLayout,
key = { it },
) { index ->
val page = items[index]
PageContainer(
page = page,
pageLoader = pageLoader,
isRtl = isRtl,
scaleType = scaleType,
landscapeZoom = landscapeZoom,
alignment = alignment,
layoutSize = layoutSize,
navigatorState = navigatorState,
pagerState = pagerState,
onSelectPage = onSelectPage,
onMenuRegionClick = onMenuRegionClick,
scope = scope,
)
}
val isPagerType = !ReadingModeType.isWebtoon(type)
val pagerNavigation by Settings.readerPagerNav.collectAsState()
val pagerInvertMode by Settings.readerPagerNavInverted.collectAsState {
TappingInvertMode.entries[it]
}
val webtoonNavigation by Settings.readerWebtoonNav.collectAsState()
val webtoonInvertMode by Settings.readerWebtoonNavInverted.collectAsState {
TappingInvertMode.entries[it]
}
val navigationType = if (isPagerType) {
pagerNavigation
} else {
webtoonNavigation
}
val navigation = remember(navigationType, type) {
ViewerNavigation.fromPreference(navigationType, ReadingModeType.isVertical(type))
}
navigation.invertMode = if (isPagerType) {
pagerInvertMode
} else {
webtoonInvertMode
}
val regions = remember(navigation, navigation.invertMode) { navigation.regions }
val navigatorState = rememberUpdatedState(regions)
if (isPagerType) {
val isVertical = type == VERTICAL
val scaleType by Settings.imageScaleType.collectAsState()
val landscapeZoom by Settings.landscapeZoom.collectAsState()
val zoomStart by Settings.zoomStart.collectAsState()
val alignment = Alignment.fromPreferences(zoomStart, type)
val layoutSize by remember(pagerState) {
derivedStateOf {
pagerState.layoutInfo.viewportSize.toSize()
}
}
WEBTOON, CONTINUOUS_VERTICAL -> {
val zoomableState = rememberZoomableState(zoomSpec = ZoomSpec)
val navigator by Settings.readerWebtoonNav.collectAsState {
ViewerNavigation.fromPreference(it, true)
if (isVertical) {
VerticalPager(
state = pagerState,
modifier = modifier,
contentPadding = contentPadding,
key = { it },
) { index ->
val page = items[index]
PageContainer(
page = page,
pageLoader = pageLoader,
isRtl = false,
scaleType = scaleType,
landscapeZoom = landscapeZoom,
alignment = alignment,
layoutSize = layoutSize,
navigatorState = navigatorState,
pagerState = pagerState,
onSelectPage = onSelectPage,
onMenuRegionClick = onMenuRegionClick,
scope = scope,
)
}
val invertMode by Settings.readerWebtoonNavInverted.collectAsState {
TappingInvertMode.entries[it]
} else {
val isRtl = type == RIGHT_TO_LEFT
val isRtlLayout = LocalLayoutDirection.current == LayoutDirection.Rtl
HorizontalPager(
state = pagerState,
modifier = modifier,
contentPadding = contentPadding,
reverseLayout = isRtl xor isRtlLayout,
key = { it },
) { index ->
val page = items[index]
PageContainer(
page = page,
pageLoader = pageLoader,
isRtl = isRtl,
scaleType = scaleType,
landscapeZoom = landscapeZoom,
alignment = alignment,
layoutSize = layoutSize,
navigatorState = navigatorState,
pagerState = pagerState,
onSelectPage = onSelectPage,
onMenuRegionClick = onMenuRegionClick,
scope = scope,
)
}
LaunchedEffect(navigator, invertMode) {
navigator.invertMode = invertMode
}
val density = LocalDensity.current
val paddingPercent by Settings.webtoonSidePadding.collectAsState()
val sidePadding by remember(density) {
snapshotFlow {
with(density) {
(lazyListState.layoutInfo.viewportSize.width * paddingPercent / 100f).toDp()
}
}
} else {
val zoomableState = rememberZoomableState(zoomSpec = ZoomSpec)
val density = LocalDensity.current
val paddingPercent by Settings.webtoonSidePadding.collectAsState()
val sidePadding by remember(density) {
snapshotFlow {
with(density) {
(lazyListState.layoutInfo.viewportSize.width * paddingPercent / 100f).toDp()
}
}.collectAsState(0.dp)
LazyColumn(
modifier = modifier.zoomable(
state = zoomableState,
onClick = {
scope.launch {
with(lazyListState) {
val size = lazyListState.layoutInfo.viewportSize
val pos = PointF(it.x / size.width, it.y / size.height)
val distance = size.height * 0.75f
when (navigator.getAction(pos)) {
NavigationRegion.MENU -> onMenuRegionClick()
NavigationRegion.NEXT, NavigationRegion.RIGHT -> performScrollBy(distance)
NavigationRegion.PREV, NavigationRegion.LEFT -> performScrollBy(-distance)
}
}
}.collectAsState(0.dp)
LazyColumn(
modifier = modifier.zoomable(
state = zoomableState,
onClick = {
scope.launch {
with(lazyListState) {
val size = lazyListState.layoutInfo.viewportSize
val x = it.x / size.width
val y = it.y / size.height
val distance = size.height * 0.75f
when (navigatorState.value.getAction(x, y)) {
NavigationRegion.MENU -> onMenuRegionClick()
NavigationRegion.NEXT, NavigationRegion.RIGHT -> performScrollBy(distance)
NavigationRegion.PREV, NavigationRegion.LEFT -> performScrollBy(-distance)
}
}
},
onLongClick = { ofs ->
val info = lazyListState.layoutInfo.visibleItemsInfo.find { info ->
info.offset <= ofs.y && info.offset + info.size > ofs.y
}
if (info == null && type == CONTINUOUS_VERTICAL) {
// Maybe user long-click on gaps? ¯\_(ツ)_/¯
return@zoomable
}
info ?: error("Internal error finding long click item!!! offset:$ofs")
onSelectPage(items[info.index])
},
onDoubleClick = DoubleTapZoom,
),
state = lazyListState,
contentPadding = contentPadding + PaddingValues(horizontal = sidePadding),
verticalArrangement = Arrangement.spacedBy(if (type != WEBTOON) 15.dp else 0.dp),
) {
items(items, key = { it.index }) { page ->
PagerItem(
page = page,
pageLoader = pageLoader,
contentScale = ContentScale.FillWidth,
)
}
}
},
onLongClick = { ofs ->
val info = lazyListState.layoutInfo.visibleItemsInfo.find { info ->
info.offset <= ofs.y && info.offset + info.size > ofs.y
}
if (info == null && type == CONTINUOUS_VERTICAL) {
// Maybe user long-click on gaps? ¯\_(ツ)_/¯
return@zoomable
}
info ?: error("Internal error finding long click item!!! offset:$ofs")
onSelectPage(items[info.index])
},
onDoubleClick = DoubleTapZoom,
),
state = lazyListState,
contentPadding = contentPadding + PaddingValues(horizontal = sidePadding),
verticalArrangement = Arrangement.spacedBy(if (type != WEBTOON) 15.dp else 0.dp),
) {
items(items, key = { it.index }) { page ->
PagerItem(
page = page,
pageLoader = pageLoader,
contentScale = ContentScale.FillWidth,
)
}
}
}
val showOnStart = remember {
Settings.showNavigationOverlayNewUser.value || Settings.showNavigationOverlayOnStart.value
}
NavigationOverlay(
regions,
showOnStart,
onDismiss = { Settings.showNavigationOverlayNewUser.value = false },
)
}
@Composable
@@ -223,7 +231,7 @@ private fun PageContainer(
landscapeZoom: Boolean,
alignment: Alignment.Horizontal,
layoutSize: Size,
navigatorState: State<ViewerNavigation>,
navigatorState: State<NavigationRegions>,
pagerState: PagerState,
onSelectPage: (ReaderPage) -> Unit,
onMenuRegionClick: () -> Unit,
@@ -284,9 +292,10 @@ private fun PageContainer(
scope.launch {
with(pagerState) {
with(zoomableState) {
val pos = PointF(it.x / layoutSize.width, it.y / layoutSize.height)
val x = it.x / layoutSize.width
val y = it.y / layoutSize.height
val distance = layoutSize.width
when (navigatorState.value.getAction(pos)) {
when (navigatorState.value.getAction(x, y)) {
NavigationRegion.MENU -> onMenuRegionClick()
NavigationRegion.NEXT -> {
val canPan = if (isRtl) panRight(distance) else panLeft(distance)
@@ -325,6 +334,9 @@ private fun PageContainer(
}
}
private fun NavigationRegions.getAction(x: Float, y: Float) =
find { it.rectF.contains(x, y) }?.type ?: NavigationRegion.MENU
suspend fun ZoomableState.panLeft(distance: Float): Boolean {
val canPan = Settings.navigateToPan.value && transformedContentBounds.right > distance
if (canPan) {
@@ -0,0 +1,88 @@
package com.hippo.ehviewer.ui.reader
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.size
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.res.colorResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle
import eu.kanade.tachiyomi.ui.reader.viewer.ViewerNavigation
@Composable
fun NavigationOverlay(
regions: NavigationRegions,
showOnStart: Boolean,
onDismiss: () -> Unit,
modifier: Modifier = Modifier,
) {
var firstLaunch by remember { mutableStateOf(true) }
var visible by remember { mutableStateOf(showOnStart) }
LaunchedEffect(regions) {
if (firstLaunch) {
firstLaunch = false
} else {
visible = true
}
}
AnimatedVisibility(
visible = visible && regions.isNotEmpty(),
modifier = modifier.clickable(interactionSource = null, indication = null) {
onDismiss()
visible = false
}.fillMaxSize(),
enter = fadeIn(AnimationSpec),
exit = fadeOut(AnimationSpec),
) {
BoxWithConstraints {
val fontSize = MaterialTheme.typography.headlineSmall.fontSize
val style = TextStyle(
color = Color.White,
fontSize = fontSize,
)
val strokeStyle = TextStyle(
color = Color.Black,
fontSize = fontSize,
drawStyle = Stroke(8f),
)
val scaleX = maxWidth
val scaleY = maxHeight
regions.forEach { region ->
val rect = region.rectF
val color = colorResource(region.type.colorRes)
val text = stringResource(region.type.nameRes)
Box(
modifier = Modifier.size(scaleX * rect.width(), scaleY * rect.height())
.offset(scaleX * rect.left, scaleY * rect.top).background(color),
contentAlignment = Alignment.Center,
) {
Text(text, style = strokeStyle)
Text(text, style = style)
}
}
}
}
}
typealias NavigationRegions = List<ViewerNavigation.Region>
private val AnimationSpec = tween<Float>(1000)
@@ -61,6 +61,7 @@ fun Activity.setCustomBrightnessValue(value: Int) {
window.attributes = window.attributes.apply { screenBrightness = readerBrightness }
}
@Stable
fun Alignment.Companion.fromPreferences(value: Int, mode: ReadingModeType) = when (value) {
1 -> when (mode) {
VERTICAL -> CenterHorizontally
@@ -37,6 +37,11 @@ enum class ReadingModeType(
WEBTOON, CONTINUOUS_VERTICAL -> true
}
fun isVertical(type: ReadingModeType) = when (type) {
DEFAULT, LEFT_TO_RIGHT, RIGHT_TO_LEFT -> false
VERTICAL, WEBTOON, CONTINUOUS_VERTICAL -> true
}
const val MASK = 0x00000007
fun fromPreference(preference: Int?): ReadingModeType = entries.find { it.flagValue == preference } ?: DEFAULT