From 7e52a2e296e9b30ca75ecf997aea68bcd61ff841 Mon Sep 17 00:00:00 2001 From: tom5079 Date: Thu, 23 Dec 2021 22:37:38 +0900 Subject: [PATCH] [Reader] ReaderOptions TopSheet --- .../sources/composable/ModalTopSheetLayout.kt | 6 +- .../pupil/sources/composable/ReaderBase.kt | 425 +++++++++++++++--- .../xyz/quaver/pupil/sources/hitomi/Hitomi.kt | 2 + .../quaver/pupil/sources/manatoki/Manatoki.kt | 22 +- app/src/main/proto/settings.proto | 22 + 5 files changed, 405 insertions(+), 72 deletions(-) diff --git a/app/src/main/java/xyz/quaver/pupil/sources/composable/ModalTopSheetLayout.kt b/app/src/main/java/xyz/quaver/pupil/sources/composable/ModalTopSheetLayout.kt index 723aeeb6..0ad7f1b6 100644 --- a/app/src/main/java/xyz/quaver/pupil/sources/composable/ModalTopSheetLayout.kt +++ b/app/src/main/java/xyz/quaver/pupil/sources/composable/ModalTopSheetLayout.kt @@ -45,7 +45,7 @@ import xyz.quaver.pupil.sources.composable.ModalTopSheetState.Expanded import xyz.quaver.pupil.sources.composable.ModalTopSheetState.Hidden import kotlin.math.roundToInt -class ModalTopSheetLayout( +class ModalTopSheetLayoutShape( private val cornerRadius: Dp, private val handleRadius: Dp ): Shape { @@ -152,7 +152,7 @@ private fun Scrim( @Composable @ExperimentalMaterialApi -fun SearchOptionDrawer( +fun ModalTopSheetLayout( drawerContent: @Composable ColumnScope.() -> Unit, modifier: Modifier = Modifier, drawerCornerRadius: Dp = SearchOptionDrawerDefaults.CornerRadius, @@ -229,7 +229,7 @@ fun SearchOptionDrawer( .onGloballyPositioned { sheetHeight = it.size.height.toFloat() }, - shape = ModalTopSheetLayout(drawerCornerRadius, drawerHandleRadius), + shape = ModalTopSheetLayoutShape(drawerCornerRadius, drawerHandleRadius), elevation = drawerElevation, color = drawerBackgroundColor, contentColor = drawerContentColor diff --git a/app/src/main/java/xyz/quaver/pupil/sources/composable/ReaderBase.kt b/app/src/main/java/xyz/quaver/pupil/sources/composable/ReaderBase.kt index d5bfc82f..4e632682 100644 --- a/app/src/main/java/xyz/quaver/pupil/sources/composable/ReaderBase.kt +++ b/app/src/main/java/xyz/quaver/pupil/sources/composable/ReaderBase.kt @@ -20,6 +20,7 @@ package xyz.quaver.pupil.sources.composable import android.app.Application import android.net.Uri +import androidx.compose.animation.core.* import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.border import androidx.compose.foundation.combinedClickable @@ -28,11 +29,21 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.material.* import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.AutoFixHigh import androidx.compose.material.icons.filled.BrokenImage +import androidx.compose.material.icons.materialIcon +import androidx.compose.material.icons.materialPath import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clipToBounds +import androidx.compose.ui.draw.rotate +import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.input.nestedscroll.NestedScrollConnection +import androidx.compose.ui.input.nestedscroll.NestedScrollSource +import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.res.stringResource @@ -50,6 +61,7 @@ import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.Job import kotlinx.coroutines.cancelAndJoin import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.takeWhile import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex @@ -63,12 +75,93 @@ import xyz.quaver.graphics.subsampledimage.* import xyz.quaver.io.FileX import xyz.quaver.pupil.R import xyz.quaver.pupil.db.AppDatabase +import xyz.quaver.pupil.proto.ReaderOptions +import xyz.quaver.pupil.proto.settingsDataStore import xyz.quaver.pupil.ui.theme.Orange500 import xyz.quaver.pupil.util.NetworkCache import xyz.quaver.pupil.util.activity import xyz.quaver.pupil.util.rememberFileXImageSource import java.util.concurrent.ConcurrentHashMap import kotlin.math.abs +import kotlin.math.sign + +private var _singleImage: ImageVector? = null +val SingleImage: ImageVector + get() { + if (_singleImage != null) { + return _singleImage!! + } + + _singleImage = materialIcon(name = "ReaderBase.SingleImage") { + materialPath { + moveTo(17.0f, 3.0f) + lineTo(7.0f, 3.0f) + curveToRelative(-1.1f, 0.0f, -2.0f, 0.9f, -2.0f, 2.0f) + verticalLineToRelative(14.0f) + curveToRelative(0.0f, 1.1f, 0.9f, 2.0f, 2.0f, 2.0f) + horizontalLineToRelative(10.0f) + curveToRelative(1.1f, 0.0f, 2.0f, -0.9f, 2.0f, -2.0f) + lineTo(19.0f, 5.0f) + curveToRelative(0.0f, -1.1f, -0.9f, -2.0f, -2.0f, -2.0f) + close() + moveTo(17.0f, 19.0f) + lineTo(7.0f, 19.0f) + lineTo(7.0f, 5.0f) + horizontalLineToRelative(10.0f) + verticalLineToRelative(14.0f) + close() + } + } + + return _singleImage!! + } + +private var _doubleImage: ImageVector? = null +val DoubleImage: ImageVector + get() { + if (_doubleImage != null) { + return _doubleImage!! + } + + _doubleImage = materialIcon(name = "ReaderBase.DoubleImage") { + materialPath { + moveTo(9.0f, 3.0f) + lineTo(2.0f, 3.0f) + curveToRelative(-1.1f, 0.0f, -2.0f, 0.9f, -2.0f, 2.0f) + verticalLineToRelative(14.0f) + curveToRelative(0.0f, 1.1f, 0.9f, 2.0f, 2.0f, 2.0f) + horizontalLineToRelative(7.0f) + curveToRelative(1.1f, 0.0f, 2.0f, -0.9f, 2.0f, -2.0f) + lineTo(11.0f, 5.0f) + curveToRelative(0.0f, -1.1f, -0.9f, -2.0f, -2.0f, -2.0f) + close() + moveTo(9.0f, 19.0f) + lineTo(2.0f, 19.0f) + lineTo(2.0f, 5.0f) + horizontalLineToRelative(7.0f) + verticalLineToRelative(14.0f) + close() + moveTo(21.0f, 3.0f) + lineTo(14.0f, 3.0f) + curveToRelative(-1.1f, 0.0f, -2.0f, 0.9f, -2.0f, 2.0f) + verticalLineToRelative(14.0f) + curveToRelative(0.0f, 1.1f, 0.9f, 2.0f, 2.0f, 2.0f) + horizontalLineToRelative(7.0f) + curveToRelative(1.1f, 0.0f, 2.0f, -0.9f, 2.0f, -2.0f) + lineTo(23.0f, 5.0f) + curveToRelative(0.0f, -1.1f, -0.9f, -2.0f, -2.0f, -2.0f) + close() + moveTo(21.0f, 19.0f) + lineTo(14.0f, 19.0f) + lineTo(14.0f, 5.0f) + horizontalLineToRelative(7.0f) + verticalLineToRelative(14.0f) + close() + } + } + + return _doubleImage!! + } open class ReaderBaseViewModel(app: Application) : AndroidViewModel(app), DIAware { override val di by closestDI(app) @@ -155,7 +248,8 @@ open class ReaderBaseViewModel(app: Application) : AndroidViewModel(app), DIAwar } } -@OptIn(ExperimentalFoundationApi::class) +@ExperimentalMaterialApi +@ExperimentalFoundationApi @Composable fun ReaderBase( modifier: Modifier = Modifier, @@ -164,9 +258,26 @@ fun ReaderBase( val context = LocalContext.current val haptic = LocalHapticFeedback.current + val coroutineScope = rememberCoroutineScope() + val scaffoldState = rememberScaffoldState() val snackbarCoroutineScope = rememberCoroutineScope() + var scrollDirection by remember { mutableStateOf(0f) } + val handleOffset by animateDpAsState(if (model.fullscreen || scrollDirection < 0f) (-36).dp else 0.dp) + + val mainReaderOptions by remember { + context.settingsDataStore.data.map { it.mainReaderOption } + }.collectAsState(ReaderOptions.getDefaultInstance()) + + val nestedScrollConnection = remember { object: NestedScrollConnection { + override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset { + scrollDirection = available.y.sign + + return Offset.Zero + } + } } + LaunchedEffect(model.fullscreen) { context.activity?.window?.let { window -> ViewCompat.getWindowInsetsController(window.decorView)?.let { @@ -191,78 +302,266 @@ fun ReaderBase( } Box(modifier) { - LazyColumn( - Modifier - .fillMaxSize() - .align(Alignment.TopStart), - verticalArrangement = Arrangement.spacedBy(4.dp), - contentPadding = rememberInsetsPaddingValues(LocalWindowInsets.current.navigationBars) - ) { - itemsIndexed(model.imageList) { i, uri -> - val state = rememberSubSampledImageState(ScaleTypes.FIT_WIDTH) + ModalTopSheetLayout( + modifier = Modifier.offset(0.dp, handleOffset), + drawerContent = { + CompositionLocalProvider(LocalTextStyle provides MaterialTheme.typography.h6) { + Column(Modifier.padding(16.dp, 0.dp)) { + val layout = mainReaderOptions.layout + val snap = mainReaderOptions.snap + val orientation = mainReaderOptions.orientation + val padding = mainReaderOptions.padding - Box( - Modifier - .wrapContentHeight(state, 500.dp) - .fillMaxWidth() - .border(1.dp, Color.Gray), - contentAlignment = Alignment.Center - ) { - val progress = model.progressList.getOrNull(i) ?: 0f - - if (progress == Float.NEGATIVE_INFINITY) - Icon(Icons.Filled.BrokenImage, null, tint = Orange500) - else if (progress.isFinite()) - Column( - horizontalAlignment = Alignment.CenterHorizontally + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween ) { - LinearProgressIndicator(progress) - Text((i + 1).toString()) - } - else if (uri != null && progress == Float.POSITIVE_INFINITY) { - val imageSource = kotlin.runCatching { - rememberFileXImageSource(FileX(context, uri)) - }.getOrNull() + Text("Layout") - if (imageSource != null) - SubSampledImage( - modifier = Modifier - .fillMaxSize() - .run { - if (model.fullscreen) - doubleClickCycleZoom(state, 2f) - else - combinedClickable( - onLongClick = { - - } - ) { - model.fullscreen = true + Row { + listOf( + ReaderOptions.Layout.SINGLE_PAGE to SingleImage, + ReaderOptions.Layout.DOUBLE_PAGE to DoubleImage, + ReaderOptions.Layout.AUTO to Icons.Default.AutoFixHigh + ).forEach { (option, icon) -> + IconButton(onClick = { + coroutineScope.launch { + context.settingsDataStore.updateData { + it.toBuilder().setMainReaderOption( + it.mainReaderOption.toBuilder() + .setLayout(option) + .build() + ).build() } - }, - imageSource = imageSource, - state = state, - onError = { - model.error(i) + } + }) { + Icon( + icon, + contentDescription = null, + tint = + if (layout == option) MaterialTheme.colors.secondary + else LocalContentColor.current + ) + } } + } + } + + val infiniteTransition = rememberInfiniteTransition() + + val isVertical = + orientation == ReaderOptions.Orientation.VERTICAL_DOWN || + orientation == ReaderOptions.Orientation.VERTICAL_UP + val isReverse = + orientation == ReaderOptions.Orientation.VERTICAL_UP || + orientation == ReaderOptions.Orientation.HORIZONTAL_LEFT + + val animationOrientation = if (isReverse) -1f else 1f + val animationSpacing by animateFloatAsState(if (padding) 48f else 32f) + val animationOffset by infiniteTransition.animateFloat( + initialValue = animationOrientation * (if (snap) 0f else animationSpacing/2), + targetValue = animationOrientation * (if (snap) -animationSpacing else -animationSpacing/2), + animationSpec = infiniteRepeatable( + animation = tween( + durationMillis = 1000, + easing = if(snap) FastOutSlowInEasing else LinearEasing + ), + repeatMode = RepeatMode.Restart ) + ) + val animationRotation by animateFloatAsState(if (isVertical) 90f else 0f) + + val setOrientation: (Boolean, Boolean) -> Unit = { isVertical, isReverse -> + val orientation = when { + isVertical && !isReverse -> ReaderOptions.Orientation.VERTICAL_DOWN + isVertical && isReverse -> ReaderOptions.Orientation.VERTICAL_UP + !isVertical && !isReverse -> ReaderOptions.Orientation.HORIZONTAL_RIGHT + !isVertical && isReverse -> ReaderOptions.Orientation.HORIZONTAL_LEFT + else -> error("Invalid value") + } + + coroutineScope.launch { + context.settingsDataStore.updateData { + it.toBuilder().setMainReaderOption( + mainReaderOptions.toBuilder() + .setOrientation(orientation) + .build() + ).build() + } + } + } + + Box( + modifier = Modifier + .size(48.dp) + .clipToBounds() + .rotate(animationRotation) + .align(Alignment.CenterHorizontally) + ) { + for (i in 0..4) + Icon( + SingleImage, + contentDescription = null, + modifier = Modifier + .size(48.dp) + .align(Alignment.CenterStart) + .offset((animationOffset + animationSpacing * (i - 2)).dp, 0.dp) + ) + } + + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text("Orientation") + + CompositionLocalProvider(LocalTextStyle provides MaterialTheme.typography.caption) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text("H") + Switch(checked = isVertical, onCheckedChange = { + setOrientation(!isVertical, isReverse) + }) + Text("V") + } + } + } + + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text("Reverse") + Switch(checked = isReverse, onCheckedChange = { + setOrientation(isVertical, !isReverse) + }) + } + + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text("Snap") + + Switch(checked = snap, onCheckedChange = { + coroutineScope.launch { + context.settingsDataStore.updateData { + it.toBuilder().setMainReaderOption( + mainReaderOptions.toBuilder() + .setSnap(!snap) + .build() + ).build() + } + } + }) + } + + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text("Padding") + + Switch(checked = padding, onCheckedChange = { + coroutineScope.launch { + context.settingsDataStore.updateData { + it.toBuilder().setMainReaderOption( + mainReaderOptions.toBuilder() + .setPadding(!padding) + .build() + ).build() + } + } + }) + } + + Box( + Modifier + .fillMaxWidth() + .height(8.dp)) } } } - } + ) { + LazyColumn( + Modifier + .fillMaxSize() + .align(Alignment.TopStart) + .nestedScroll(nestedScrollConnection), + verticalArrangement = Arrangement.spacedBy(4.dp), + contentPadding = rememberInsetsPaddingValues(LocalWindowInsets.current.navigationBars) + ) { + itemsIndexed(model.imageList) { i, uri -> + val state = rememberSubSampledImageState(ScaleTypes.FIT_WIDTH) - if (model.progressList.any { it.isFinite() }) - LinearProgressIndicator( - modifier = Modifier - .fillMaxWidth() - .align(Alignment.TopCenter), - progress = model.progressList.map { if (it.isInfinite()) 1f else abs(it) }.sum() / model.progressList.size, - color = MaterialTheme.colors.secondary + Box( + Modifier + .wrapContentHeight(state, 500.dp) + .fillMaxWidth() + .border(1.dp, Color.Gray), + contentAlignment = Alignment.Center + ) { + val progress = model.progressList.getOrNull(i) ?: 0f + + if (progress == Float.NEGATIVE_INFINITY) + Icon(Icons.Filled.BrokenImage, null, tint = Orange500) + else if (progress.isFinite()) + Column( + horizontalAlignment = Alignment.CenterHorizontally + ) { + LinearProgressIndicator(progress) + Text((i + 1).toString()) + } + else if (uri != null && progress == Float.POSITIVE_INFINITY) { + val imageSource = kotlin.runCatching { + rememberFileXImageSource(FileX(context, uri)) + }.getOrNull() + + if (imageSource != null) + SubSampledImage( + modifier = Modifier + .fillMaxSize() + .run { + if (model.fullscreen) + doubleClickCycleZoom(state, 2f) + else + combinedClickable( + onLongClick = { + + } + ) { + model.fullscreen = true + } + }, + imageSource = imageSource, + state = state, + onError = { + model.error(i) + } + ) + } + } + } + } + + if (model.progressList.any { it.isFinite() }) + LinearProgressIndicator( + modifier = Modifier + .fillMaxWidth() + .align(Alignment.TopCenter), + progress = model.progressList.map { if (it.isInfinite()) 1f else abs(it) } + .sum() / model.progressList.size, + color = MaterialTheme.colors.secondary + ) + + SnackbarHost( + scaffoldState.snackbarHostState, + modifier = Modifier.align(Alignment.BottomCenter) ) - - SnackbarHost( - scaffoldState.snackbarHostState, - modifier = Modifier.align(Alignment.BottomCenter) - ) + } } } \ No newline at end of file diff --git a/app/src/main/java/xyz/quaver/pupil/sources/hitomi/Hitomi.kt b/app/src/main/java/xyz/quaver/pupil/sources/hitomi/Hitomi.kt index 942a2625..e8b7883c 100644 --- a/app/src/main/java/xyz/quaver/pupil/sources/hitomi/Hitomi.kt +++ b/app/src/main/java/xyz/quaver/pupil/sources/hitomi/Hitomi.kt @@ -20,6 +20,7 @@ package xyz.quaver.pupil.sources.hitomi import android.app.Application import androidx.activity.compose.BackHandler +import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.Image import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Row @@ -66,6 +67,7 @@ import xyz.quaver.pupil.sources.hitomi.lib.getReferer import xyz.quaver.pupil.sources.hitomi.lib.imageUrlFromImage import xyz.quaver.pupil.ui.theme.Orange500 +@OptIn(ExperimentalMaterialApi::class, ExperimentalFoundationApi::class) class Hitomi(app: Application) : Source(), DIAware { override val di by closestDI(app) diff --git a/app/src/main/java/xyz/quaver/pupil/sources/manatoki/Manatoki.kt b/app/src/main/java/xyz/quaver/pupil/sources/manatoki/Manatoki.kt index 57bb0dc1..3e22f56c 100644 --- a/app/src/main/java/xyz/quaver/pupil/sources/manatoki/Manatoki.kt +++ b/app/src/main/java/xyz/quaver/pupil/sources/manatoki/Manatoki.kt @@ -18,7 +18,6 @@ package xyz.quaver.pupil.sources.manatoki import android.app.Application -import android.util.LruCache import androidx.activity.compose.BackHandler import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.ExperimentalAnimationApi @@ -47,6 +46,9 @@ import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Size import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.nestedscroll.NestedScrollConnection +import androidx.compose.ui.input.nestedscroll.NestedScrollSource +import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity @@ -72,8 +74,6 @@ import com.google.accompanist.insets.ui.TopAppBar import io.ktor.client.* import kotlinx.coroutines.delay import kotlinx.coroutines.launch -import kotlinx.coroutines.sync.Mutex -import kotlinx.coroutines.sync.withLock import org.kodein.di.DIAware import org.kodein.di.android.closestDI import org.kodein.di.compose.rememberInstance @@ -89,6 +89,7 @@ import xyz.quaver.pupil.sources.manatoki.composable.* import xyz.quaver.pupil.sources.manatoki.viewmodel.* import xyz.quaver.pupil.ui.theme.Orange500 import kotlin.math.max +import kotlin.math.sign private val imageUserAgent = "Mozilla/5.0 (Linux; Android 6.0; Nexus 5 Build/MRA58N) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/96.0.4664.45 Mobile Safari/537.36" @@ -413,6 +414,15 @@ class Manatoki(app: Application) : Source(), DIAware { val listState = rememberLazyListState() + var scrollDirection by remember { mutableStateOf(0f) } + val nestedScrollConnection = remember { object: NestedScrollConnection { + override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset { + scrollDirection = available.y.sign + + return Offset.Zero + } + } } + BackHandler { when { sheetState.isVisible -> coroutineScope.launch { sheetState.hide() } @@ -492,7 +502,7 @@ class Manatoki(app: Application) : Source(), DIAware { }, floatingActionButton = { AnimatedVisibility( - !model.fullscreen, + !(model.fullscreen || scrollDirection < 0f), enter = scaleIn(), exit = scaleOut() ) { @@ -562,7 +572,7 @@ class Manatoki(app: Application) : Source(), DIAware { } ) { contentPadding -> ReaderBase( - Modifier.padding(contentPadding), + Modifier.padding(contentPadding).nestedScroll(nestedScrollConnection), model ) } @@ -711,7 +721,7 @@ class Manatoki(app: Application) : Source(), DIAware { } ) { contentPadding -> Box(Modifier.padding(contentPadding)) { - SearchOptionDrawer( + ModalTopSheetLayout( modifier = Modifier.run { if (drawerState.currentValue == ModalTopSheetState.Hidden) offset(0.dp, handleOffset) diff --git a/app/src/main/proto/settings.proto b/app/src/main/proto/settings.proto index d20062e4..6c1015b4 100644 --- a/app/src/main/proto/settings.proto +++ b/app/src/main/proto/settings.proto @@ -5,4 +5,26 @@ option java_multiple_files = true; message Settings { optional string recent_source = 1; + optional ReaderOptions mainReaderOption = 2; + optional ReaderOptions fullscreenReaderOption = 3; +} + +message ReaderOptions { + enum Layout { + AUTO = 0; + SINGLE_PAGE = 1; + DOUBLE_PAGE = 2; + } + + enum Orientation { + VERTICAL_DOWN = 0; + VERTICAL_UP = 1; + HORIZONTAL_RIGHT = 2; + HORIZONTAL_LEFT = 3; + } + + optional Layout layout = 1; + optional Orientation orientation = 2; + optional bool snap = 3; + optional bool padding = 4; } \ No newline at end of file