[New Reader] Implement navigation overlay (#1445)
* [New Reader] Implement navigation overlay * Fix typo * Refine * Reuse navigation regions
This commit is contained in:
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
|
||||
|
||||
Reference in new issue
Block a user