[Reader] ReaderOptions TopSheet

This commit is contained in:
tom5079
2021-12-23 22:37:38 +09:00
parent 4625bb5806
commit 7e52a2e296
5 changed files with 405 additions and 72 deletions

View File

@@ -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

View File

@@ -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)
)
}
}
}

View File

@@ -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)

View File

@@ -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)

View File

@@ -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;
}