Add vertical chapter navigator for long strip mode (#3377)

This commit is contained in:
AntsyLich
2026-06-04 01:54:52 +06:00
committed by GitHub
parent d28c605a89
commit a330258f6a
8 changed files with 273 additions and 47 deletions
+1
View File
@@ -13,6 +13,7 @@ The format is a modified version of [Keep a Changelog](https://keepachangelog.co
## [Unreleased]
### Added
- Add support for `tachiyomix` extension index format ([@AntsyLich](https://github.com/AntsyLich)) ([#3349](https://github.com/mihonapp/mihon/pull/3349))
- Add vertical chapter navigator for long strip mode ([@AntsyLich](https://github.com/AntsyLich)) ([#3377](https://github.com/mihonapp/mihon/pull/3377))
### Changed
- Change all reference of extension repo to extension store ([@AntsyLich](https://github.com/AntsyLich)) ([#3349](https://github.com/mihonapp/mihon/pull/3349))
@@ -74,8 +74,8 @@ object SettingsReaderScreen : SearchableSettings {
@Composable
private fun getDisplayGroup(readerPreferences: ReaderPreferences): Preference.PreferenceGroup {
val fullscreenPref = readerPreferences.fullscreen
val fullscreen by fullscreenPref.collectAsState()
val fullscreen by readerPreferences.fullscreen.collectAsState()
val verticalNavigatorForLongStrip by readerPreferences.verticalNavigatorForLongStrip.collectAsState()
return Preference.PreferenceGroup(
title = stringResource(MR.strings.pref_category_display),
preferenceItems = persistentListOf(
@@ -97,7 +97,7 @@ object SettingsReaderScreen : SearchableSettings {
title = stringResource(MR.strings.pref_reader_theme),
),
Preference.PreferenceItem.SwitchPreference(
preference = fullscreenPref,
preference = readerPreferences.fullscreen,
title = stringResource(MR.strings.pref_fullscreen),
),
Preference.PreferenceItem.SwitchPreference(
@@ -113,6 +113,15 @@ object SettingsReaderScreen : SearchableSettings {
preference = readerPreferences.showPageNumber,
title = stringResource(MR.strings.pref_show_page_number),
),
Preference.PreferenceItem.SwitchPreference(
preference = readerPreferences.verticalNavigatorForLongStrip,
title = stringResource(MR.strings.pref_webtoon_vertical_navigator),
),
Preference.PreferenceItem.SwitchPreference(
preference = readerPreferences.verticalNavigatorOnLeft,
title = stringResource(MR.strings.pref_webtoon_vertical_navigator_on_left),
enabled = verticalNavigatorForLongStrip,
),
),
)
}
@@ -4,13 +4,16 @@ import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInHorizontally
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutHorizontally
import androidx.compose.animation.slideOutVertically
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxHeight
@@ -21,14 +24,16 @@ import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.surfaceColorAtElevation
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
import eu.kanade.presentation.reader.components.ChapterNavigator
import eu.kanade.presentation.reader.components.ChapterNavigatorType
import eu.kanade.tachiyomi.ui.reader.setting.ReaderOrientation
import eu.kanade.tachiyomi.ui.reader.setting.ReadingMode
import eu.kanade.tachiyomi.ui.reader.viewer.Viewer
import eu.kanade.tachiyomi.ui.reader.viewer.pager.R2LPagerViewer
import tachiyomi.presentation.core.components.material.padding
private val readerBarsSlideAnimationSpec = tween<IntOffset>(200)
@@ -48,7 +53,7 @@ fun ReaderAppBars(
onOpenInBrowser: (() -> Unit)?,
onShare: (() -> Unit)?,
viewer: Viewer?,
chapterNavigatorType: ChapterNavigatorType,
onNextChapter: () -> Unit,
enabledNext: Boolean,
onPreviousChapter: () -> Unit,
@@ -65,7 +70,6 @@ fun ReaderAppBars(
onClickCropBorder: () -> Unit,
onClickSettings: () -> Unit,
) {
val isRtl = viewer is R2LPagerViewer
val backgroundColor = MaterialTheme.colorScheme
.surfaceColorAtElevation(3.dp)
.copy(alpha = if (isSystemInDarkTheme()) 0.9f else 0.95f)
@@ -73,10 +77,8 @@ fun ReaderAppBars(
Column(modifier = Modifier.fillMaxHeight()) {
AnimatedVisibility(
visible = visible,
enter = slideInVertically(initialOffsetY = { -it }, animationSpec = readerBarsSlideAnimationSpec) +
fadeIn(animationSpec = readerBarsFadeAnimationSpec),
exit = slideOutVertically(targetOffsetY = { -it }, animationSpec = readerBarsSlideAnimationSpec) +
fadeOut(animationSpec = readerBarsFadeAnimationSpec),
enter = slideInVertically(readerBarsSlideAnimationSpec) { -it } + fadeIn(readerBarsFadeAnimationSpec),
exit = slideOutVertically(readerBarsSlideAnimationSpec) { -it } + fadeOut(readerBarsFadeAnimationSpec),
) {
ReaderTopBar(
modifier = Modifier
@@ -93,18 +95,21 @@ fun ReaderAppBars(
)
}
Spacer(modifier = Modifier.weight(1f))
if (!chapterNavigatorType.isHorizontal()) {
val sliderOnLeft = chapterNavigatorType == ChapterNavigatorType.VERTICAL_LEFT
CompositionLocalProvider(
LocalLayoutDirection provides if (sliderOnLeft) LayoutDirection.Ltr else LayoutDirection.Rtl,
) {
Row(modifier = Modifier.weight(1f)) {
AnimatedVisibility(
visible = visible,
enter = slideInVertically(initialOffsetY = { it }, animationSpec = readerBarsSlideAnimationSpec) +
fadeIn(animationSpec = readerBarsFadeAnimationSpec),
exit = slideOutVertically(targetOffsetY = { it }, animationSpec = readerBarsSlideAnimationSpec) +
fadeOut(animationSpec = readerBarsFadeAnimationSpec),
enter = slideInHorizontally(readerBarsSlideAnimationSpec) { if (sliderOnLeft) -it else it } +
fadeIn(readerBarsFadeAnimationSpec),
exit = slideOutHorizontally(readerBarsSlideAnimationSpec) { if (sliderOnLeft) -it else it } +
fadeOut(readerBarsFadeAnimationSpec),
) {
Column(verticalArrangement = Arrangement.spacedBy(MaterialTheme.padding.small)) {
ChapterNavigator(
isRtl = isRtl,
type = chapterNavigatorType,
onNextChapter = onNextChapter,
enabledNext = enabledNext,
onPreviousChapter = onPreviousChapter,
@@ -113,6 +118,32 @@ fun ReaderAppBars(
totalPages = totalPages,
onPageIndexChange = onPageIndexChange,
)
}
Spacer(modifier = Modifier.weight(1f))
}
}
} else {
Spacer(Modifier.weight(1f))
}
AnimatedVisibility(
visible = visible,
enter = slideInVertically(readerBarsSlideAnimationSpec) { it } + fadeIn(readerBarsFadeAnimationSpec),
exit = slideOutVertically(readerBarsSlideAnimationSpec) { it } + fadeOut(readerBarsFadeAnimationSpec),
) {
Column(verticalArrangement = Arrangement.spacedBy(MaterialTheme.padding.small)) {
if (chapterNavigatorType.isHorizontal()) {
ChapterNavigator(
type = chapterNavigatorType,
onNextChapter = onNextChapter,
enabledNext = enabledNext,
onPreviousChapter = onPreviousChapter,
enabledPrevious = enabledPrevious,
currentPage = currentPage,
totalPages = totalPages,
onPageIndexChange = onPageIndexChange,
)
}
ReaderBottomBar(
modifier = Modifier
.fillMaxWidth()
@@ -5,8 +5,10 @@ import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsDraggedAsState
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
@@ -15,9 +17,14 @@ import androidx.compose.material.icons.outlined.SkipNext
import androidx.compose.material.icons.outlined.SkipPrevious
import androidx.compose.material3.FilledIconButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButtonColors
import androidx.compose.material3.IconButtonDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Slider
import androidx.compose.material3.SliderState
import androidx.compose.material3.Text
import androidx.compose.material3.VerticalSlider
import androidx.compose.material3.rememberSliderState
import androidx.compose.material3.surfaceColorAtElevation
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
@@ -29,22 +36,34 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.rotate
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
import eu.kanade.presentation.theme.TachiyomiPreviewTheme
import eu.kanade.presentation.util.isTabletUi
import tachiyomi.i18n.MR
import tachiyomi.presentation.core.components.material.Slider
import tachiyomi.presentation.core.i18n.stringResource
import kotlin.math.roundToInt
enum class ChapterNavigatorType {
HORIZONTAL_LTR,
HORIZONTAL_RTL,
VERTICAL_LEFT,
VERTICAL_RIGHT,
;
fun isHorizontal() = this in setOf(HORIZONTAL_LTR, HORIZONTAL_RTL)
}
@Composable
fun ChapterNavigator(
isRtl: Boolean,
type: ChapterNavigatorType,
onNextChapter: () -> Unit,
enabledNext: Boolean,
onPreviousChapter: () -> Unit,
@@ -53,11 +72,27 @@ fun ChapterNavigator(
totalPages: Int,
onPageIndexChange: (Int) -> Unit,
) {
val isTabletUi = isTabletUi()
val horizontalPadding = if (isTabletUi) 24.dp else 8.dp
val layoutDirection = if (isRtl) LayoutDirection.Rtl else LayoutDirection.Ltr
val haptic = LocalHapticFeedback.current
val state = rememberSliderState(
value = currentPage.toFloat(),
steps = totalPages - 2,
valueRange = 1f..totalPages.toFloat(),
)
state.value = currentPage.toFloat()
state.onValueChange = { onPageIndexChange(it.roundToInt() - 1) }
val interactionSource = remember { MutableInteractionSource() }
val sliderDragged by interactionSource.collectIsDraggedAsState()
LaunchedEffect(currentPage) {
if (sliderDragged) {
haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove)
}
}
val isTabletUi = isTabletUi()
val mainAxisPadding = if (isTabletUi) 24.dp else 8.dp
// Match with toolbar background color set in ReaderActivity
val backgroundColor = MaterialTheme.colorScheme
.surfaceColorAtElevation(3.dp)
@@ -67,12 +102,61 @@ fun ChapterNavigator(
disabledContainerColor = backgroundColor,
)
if (type.isHorizontal()) {
ChapterNavigator(
isRtl = type == ChapterNavigatorType.HORIZONTAL_RTL,
state = state,
onNextChapter = onNextChapter,
enabledNext = enabledNext,
onPreviousChapter = onPreviousChapter,
enabledPrevious = enabledPrevious,
currentPage = currentPage,
totalPages = totalPages,
interactionSource = interactionSource,
mainAxisPadding = mainAxisPadding,
backgroundColor = backgroundColor,
buttonColor = buttonColor,
)
} else {
VerticalChapterNavigator(
state = state,
onNextChapter = onNextChapter,
enabledNext = enabledNext,
onPreviousChapter = onPreviousChapter,
enabledPrevious = enabledPrevious,
currentPage = currentPage,
totalPages = totalPages,
interactionSource = interactionSource,
mainAxisPadding = mainAxisPadding,
backgroundColor = backgroundColor,
buttonColor = buttonColor,
)
}
}
@Composable
fun ChapterNavigator(
isRtl: Boolean,
state: SliderState,
onNextChapter: () -> Unit,
enabledNext: Boolean,
onPreviousChapter: () -> Unit,
enabledPrevious: Boolean,
currentPage: Int,
totalPages: Int,
interactionSource: MutableInteractionSource,
mainAxisPadding: Dp,
backgroundColor: Color,
buttonColor: IconButtonColors,
) {
val layoutDirection = if (isRtl) LayoutDirection.Rtl else LayoutDirection.Ltr
// We explicitly handle direction based on the reader viewer rather than the system direction
CompositionLocalProvider(LocalLayoutDirection provides LayoutDirection.Ltr) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = horizontalPadding),
.padding(horizontal = mainAxisPadding),
verticalAlignment = Alignment.CenterVertically,
) {
FilledIconButton(
@@ -104,23 +188,11 @@ fun ChapterNavigator(
Text(text = totalPages.toString(), color = Color.Transparent)
}
val interactionSource = remember { MutableInteractionSource() }
val sliderDragged by interactionSource.collectIsDraggedAsState()
LaunchedEffect(currentPage) {
if (sliderDragged) {
haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove)
}
}
Slider(
state = state,
modifier = Modifier
.weight(1f)
.padding(horizontal = 8.dp),
value = currentPage,
valueRange = 1..totalPages,
onValueChange = f@{
if (it == currentPage) return@f
onPageIndexChange(it - 1)
},
interactionSource = interactionSource,
)
@@ -147,13 +219,84 @@ fun ChapterNavigator(
}
}
@Composable
fun VerticalChapterNavigator(
state: SliderState,
onNextChapter: () -> Unit,
enabledNext: Boolean,
onPreviousChapter: () -> Unit,
enabledPrevious: Boolean,
currentPage: Int,
totalPages: Int,
interactionSource: MutableInteractionSource,
mainAxisPadding: Dp,
backgroundColor: Color,
buttonColor: IconButtonColors,
) {
Column(
modifier = Modifier
.fillMaxHeight()
.padding(vertical = mainAxisPadding),
horizontalAlignment = Alignment.CenterHorizontally,
) {
FilledIconButton(
enabled = enabledPrevious,
onClick = onPreviousChapter,
colors = buttonColor,
) {
Icon(
imageVector = Icons.Outlined.SkipPrevious,
contentDescription = stringResource(MR.strings.action_previous_chapter),
modifier = Modifier.rotate(90f),
)
}
if (totalPages > 1) {
Column(
modifier = Modifier
.weight(1f)
.clip(RoundedCornerShape(24.dp))
.background(backgroundColor)
.padding(vertical = 16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(text = currentPage.toString())
VerticalSlider(
state = state,
modifier = Modifier
.weight(1f)
.padding(vertical = 8.dp),
interactionSource = interactionSource,
)
Text(text = totalPages.toString())
}
} else {
Spacer(Modifier.weight(1f))
}
FilledIconButton(
enabled = enabledNext,
onClick = onNextChapter,
colors = buttonColor,
) {
Icon(
imageVector = Icons.Outlined.SkipNext,
contentDescription = stringResource(MR.strings.action_next_chapter),
modifier = Modifier.rotate(90f),
)
}
}
}
@Preview
@Composable
private fun ChapterNavigatorPreview() {
var currentPage by remember { mutableIntStateOf(1) }
TachiyomiPreviewTheme {
ChapterNavigator(
isRtl = false,
type = ChapterNavigatorType.VERTICAL_RIGHT,
onNextChapter = {},
enabledNext = true,
onPreviousChapter = {},
@@ -61,6 +61,19 @@ internal fun ColumnScope.GeneralPage(screenModel: ReaderSettingsScreenModel) {
pref = screenModel.preferences.showPageNumber,
)
val verticalNavigatorForLongStrip by screenModel.preferences.verticalNavigatorForLongStrip.collectAsState()
CheckboxItem(
label = stringResource(MR.strings.pref_webtoon_vertical_navigator),
pref = screenModel.preferences.verticalNavigatorForLongStrip,
)
if (verticalNavigatorForLongStrip) {
CheckboxItem(
label = stringResource(MR.strings.pref_webtoon_vertical_navigator_on_left),
pref = screenModel.preferences.verticalNavigatorOnLeft,
)
}
CheckboxItem(
label = stringResource(MR.strings.pref_fullscreen),
pref = screenModel.preferences.fullscreen,
@@ -56,6 +56,7 @@ import eu.kanade.presentation.reader.ReaderPageActionsDialog
import eu.kanade.presentation.reader.ReaderPageIndicator
import eu.kanade.presentation.reader.ReadingModeSelectDialog
import eu.kanade.presentation.reader.appbars.ReaderAppBars
import eu.kanade.presentation.reader.components.ChapterNavigatorType
import eu.kanade.presentation.reader.settings.ReaderSettingsDialog
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.coil.TachiyomiImageDecoder
@@ -76,6 +77,7 @@ import eu.kanade.tachiyomi.ui.reader.setting.ReaderPreferences
import eu.kanade.tachiyomi.ui.reader.setting.ReaderSettingsScreenModel
import eu.kanade.tachiyomi.ui.reader.setting.ReadingMode
import eu.kanade.tachiyomi.ui.reader.viewer.ReaderProgressIndicator
import eu.kanade.tachiyomi.ui.reader.viewer.pager.R2LPagerViewer
import eu.kanade.tachiyomi.ui.webview.WebViewActivity
import eu.kanade.tachiyomi.util.system.isNightMode
import eu.kanade.tachiyomi.util.system.openInBrowser
@@ -458,6 +460,9 @@ class ReaderActivity : BaseActivity() {
val isPagerType = ReadingMode.isPagerType(viewModel.getMangaReadingMode())
val cropEnabled = if (isPagerType) cropBorderPaged else cropBorderWebtoon
val verticalNavigatorForLongStrip by readerPreferences.verticalNavigatorForLongStrip.collectAsState()
val verticalNavigatorOnLeft by readerPreferences.verticalNavigatorOnLeft.collectAsState()
ReaderAppBars(
visible = state.menuVisible,
@@ -471,7 +476,19 @@ class ReaderActivity : BaseActivity() {
onOpenInBrowser = ::openChapterInBrowser.takeIf { isHttpSource },
onShare = ::shareChapter.takeIf { isHttpSource },
viewer = state.viewer,
chapterNavigatorType = if (isPagerType || !verticalNavigatorForLongStrip) {
if (state.viewer is R2LPagerViewer) {
ChapterNavigatorType.HORIZONTAL_RTL
} else {
ChapterNavigatorType.HORIZONTAL_LTR
}
} else {
if (verticalNavigatorOnLeft) {
ChapterNavigatorType.VERTICAL_LEFT
} else {
ChapterNavigatorType.VERTICAL_RIGHT
}
},
onNextChapter = ::loadNextChapter,
enabledNext = state.viewerChapters?.nextChapter != null,
onPreviousChapter = ::loadPreviousChapter,
@@ -28,6 +28,16 @@ class ReaderPreferences(
val showPageNumber: Preference<Boolean> = preferenceStore.getBoolean("pref_show_page_number_key", true)
val verticalNavigatorForLongStrip: Preference<Boolean> = preferenceStore.getBoolean(
"pref_webtoon_vertical_navigator",
true,
)
val verticalNavigatorOnLeft: Preference<Boolean> = preferenceStore.getBoolean(
"pref_webtoon_vertical_navigator_on_left",
false,
)
val showReadingMode: Preference<Boolean> = preferenceStore.getBoolean("pref_show_reading_mode", true)
val fullscreen: Preference<Boolean> = preferenceStore.getBoolean("fullscreen", true)
@@ -400,6 +400,8 @@
<string name="pref_flash_style_white_black">White and Black</string>
<string name="pref_double_tap_anim_speed">Double tap animation speed</string>
<string name="pref_show_page_number">Show page number</string>
<string name="pref_webtoon_vertical_navigator">Use vertical navigator for long strip reading</string>
<string name="pref_webtoon_vertical_navigator_on_left">Place vertical navigator on the left side</string>
<string name="pref_show_reading_mode">Show reading mode</string>
<string name="pref_show_reading_mode_summary">Briefly show current mode when reader is opened</string>
<string name="pref_hardware_bitmap_threshold">Custom hardware bitmap threshold</string>