diff --git a/app/src/main/kotlin/androidx/compose/material3/NavigationDrawer2.kt b/app/src/main/kotlin/androidx/compose/material3/NavigationDrawer2.kt
index 4d1231191..bb6d43fdc 100644
--- a/app/src/main/kotlin/androidx/compose/material3/NavigationDrawer2.kt
+++ b/app/src/main/kotlin/androidx/compose/material3/NavigationDrawer2.kt
@@ -17,6 +17,7 @@ import androidx.compose.foundation.gestures.snapTo
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.offset
+import androidx.compose.foundation.layout.systemBarsPadding
import androidx.compose.runtime.Composable
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.Stable
@@ -28,12 +29,14 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.Saver
import androidx.compose.runtime.saveable.rememberSaveable
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.input.pointer.pointerInput
import androidx.compose.ui.input.pointer.positionChange
import androidx.compose.ui.input.pointer.util.VelocityTracker
import androidx.compose.ui.input.pointer.util.addPointerInputChange
+import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.unit.Density
@@ -228,25 +231,6 @@ fun rememberDrawerState(
}
}
-/**
- * Material Design navigation drawer.
- *
- * Navigation drawers provide ergonomic access to destinations in an app.
- *
- * Modal navigation drawers block interaction with the rest of an app’s content with a scrim.
- * They are elevated above most of the app’s UI and don’t affect the screen’s layout grid.
- *
- * 
- *
- * @sample androidx.compose.material3.samples.ModalNavigationDrawerSample
- *
- * @param drawerContent content inside this drawer
- * @param modifier the [Modifier] to be applied to this drawer
- * @param drawerState state of the drawer
- * @param gesturesEnabled whether or not the drawer can be interacted by gestures
- * @param scrimColor color of the scrim that obscures content when the drawer is open
- * @param content content of the rest of the UI
- */
@Composable
fun ModalNavigationDrawer(
drawerContent: @Composable () -> Unit,
@@ -258,7 +242,7 @@ fun ModalNavigationDrawer(
) {
val scope = rememberCoroutineScope()
val density = LocalDensity.current
- val minValue = -with(density) { ContainerWidth.toPx() }
+ var minValue by remember { mutableStateOf(-with(density) { ContainerWidth.toPx() }) }
val maxValue = 0f
SideEffect {
@@ -279,13 +263,107 @@ fun ModalNavigationDrawer(
awaitEachGesture {
fun canConsume(slop: Float) = (slop > 0 && drawerState.isClosed) || (slop < 0 && drawerState.isOpen)
val down = awaitFirstDown(requireUnconsumed = false)
- var overSlop: Float
+ var ignore = false
val drag = awaitHorizontalTouchSlopOrCancellation(down.id) { change, over ->
- overSlop = over * multiplier
- if (canConsume(overSlop)) {
+ val overSlop = over * multiplier
+ val canConsume = canConsume(overSlop)
+ if (canConsume && !ignore) {
velocityTracker.addPointerInputChange(change)
change.consume()
drawerState.anchoredDraggableState.dispatchRawDelta(overSlop)
+ } else {
+ ignore = true
+ }
+ }
+ if (drag != null) {
+ horizontalDrag(drag.id) {
+ velocityTracker.addPointerInputChange(it)
+ drawerState.anchoredDraggableState.dispatchRawDelta(it.positionChange().x * multiplier)
+ }
+ val velocity = velocityTracker.calculateVelocity()
+ velocityTracker.resetTracking()
+ scope.launch {
+ drawerState.anchoredDraggableState.settle(velocity.x * multiplier)
+ }
+ }
+ }
+ }
+ } else {
+ Modifier
+ }
+ Box(modifier.fillMaxSize().then(dragModifier)) {
+ Box {
+ content()
+ }
+ Scrim(
+ open = drawerState.isOpen,
+ onClose = {
+ if (gesturesEnabled && drawerState.confirmStateChange(DrawerValue.Closed)) {
+ scope.launch { drawerState.close() }
+ }
+ },
+ fraction = {
+ calculateFraction(minValue, maxValue, drawerState.requireOffset())
+ },
+ color = scrimColor,
+ )
+ Box(
+ modifier = Modifier.offset {
+ IntOffset(drawerState.requireOffset().roundToInt(), 0)
+ }.onGloballyPositioned {
+ val w = it.size.width
+ if (w != 0) {
+ minValue = -it.size.width.toFloat()
+ }
+ },
+ ) {
+ drawerContent()
+ }
+ }
+}
+
+@Composable
+fun SideDrawer(
+ drawerContent: @Composable () -> Unit,
+ modifier: Modifier = Modifier,
+ drawerState: DrawerState2 = rememberDrawerState(DrawerValue.Closed),
+ gesturesEnabled: Boolean = true,
+ scrimColor: Color = DrawerDefaults.scrimColor,
+ content: @Composable () -> Unit,
+) {
+ val scope = rememberCoroutineScope()
+ val density = LocalDensity.current
+ var minValue by remember { mutableStateOf(with(density) { ContainerWidth.toPx() }) }
+ val maxValue = 0f
+
+ SideEffect {
+ drawerState.density = density
+ drawerState.anchoredDraggableState.updateAnchors(
+ DraggableAnchors {
+ DrawerValue.Closed at minValue
+ DrawerValue.Open at maxValue
+ },
+ )
+ }
+
+ val isRtl = LocalLayoutDirection.current == LayoutDirection.Rtl
+ val velocityTracker = remember { VelocityTracker() }
+ val dragModifier = if (gesturesEnabled) {
+ Modifier.pointerInput(isRtl) {
+ val multiplier = if (isRtl) -1 else 1
+ awaitEachGesture {
+ fun canConsume(slop: Float) = (slop > 0 && drawerState.isOpen) || (slop < 0 && drawerState.isClosed)
+ val down = awaitFirstDown(requireUnconsumed = false)
+ var ignore = false
+ val drag = awaitHorizontalTouchSlopOrCancellation(down.id) { change, over ->
+ val overSlop = over * multiplier
+ val canConsume = canConsume(overSlop)
+ if (canConsume && !ignore) {
+ velocityTracker.addPointerInputChange(change)
+ change.consume()
+ drawerState.anchoredDraggableState.dispatchRawDelta(overSlop)
+ } else {
+ ignore = true
}
}
if (drag != null) {
@@ -324,7 +402,12 @@ fun ModalNavigationDrawer(
Box(
modifier = Modifier.offset {
IntOffset(drawerState.requireOffset().roundToInt(), 0)
- },
+ }.onGloballyPositioned {
+ val w = it.size.width
+ if (w != 0) {
+ minValue = it.size.width.toFloat()
+ }
+ }.align(Alignment.TopEnd).systemBarsPadding(),
) {
drawerContent()
}
diff --git a/app/src/main/kotlin/com/hippo/ehviewer/EhDB.kt b/app/src/main/kotlin/com/hippo/ehviewer/EhDB.kt
index d7a293d4a..8aa99213c 100644
--- a/app/src/main/kotlin/com/hippo/ehviewer/EhDB.kt
+++ b/app/src/main/kotlin/com/hippo/ehviewer/EhDB.kt
@@ -130,7 +130,7 @@ object EhDB {
deleteGalleryInfo(galleryInfo)
}
- suspend fun removeLocalFavorites(galleryInfoList: List) {
+ suspend fun removeLocalFavorites(galleryInfoList: Collection) {
galleryInfoList.forEach {
removeLocalFavorites(it)
}
@@ -146,7 +146,7 @@ object EhDB {
db.localFavoritesDao().upsert(LocalFavoriteInfo(galleryInfo.gid))
}
- suspend fun putLocalFavorites(galleryInfoList: List) {
+ suspend fun putLocalFavorites(galleryInfoList: Collection) {
galleryInfoList.forEach {
putLocalFavorites(it)
}
diff --git a/app/src/main/kotlin/com/hippo/ehviewer/Settings.kt b/app/src/main/kotlin/com/hippo/ehviewer/Settings.kt
index f67ad7705..946b6ff58 100644
--- a/app/src/main/kotlin/com/hippo/ehviewer/Settings.kt
+++ b/app/src/main/kotlin/com/hippo/ehviewer/Settings.kt
@@ -25,6 +25,9 @@ object Settings : DataStorePreferences(null) {
var favCount by intArrayPref("fav_count", 10).emitTo(_favFlow)
var favCloudCount by intPref("fav_cloud", 0).emitTo(_favFlow)
+ val listModeBackField = intPref("list_mode_2", 0)
+ var listMode by listModeBackField
+
val needSignInFlow: Flow
var downloadScheme by stringOrNullPref("image_scheme", null)
var downloadAuthority by stringOrNullPref("image_authority", null)
@@ -38,7 +41,6 @@ object Settings : DataStorePreferences(null) {
var preloadImage by intPref("preload_image_2", 5)
var downloadTimeout by intPref("download_timeout", 60)
var theme by intPref("theme_2", -1).observed { updateWhenThemeChanges() }
- var listMode by intPref("list_mode_2", 0)
var detailSize by intPref("detail_size_2", 0)
var thumbResolution by intPref("thumb_resolution_2", 0)
var readCacheSize by intPref("read_cache_size_2", 640)
diff --git a/app/src/main/kotlin/com/hippo/ehviewer/client/data/FavListUrlBuilder.kt b/app/src/main/kotlin/com/hippo/ehviewer/client/data/FavListUrlBuilder.kt
index fbc4d574f..80f65c3de 100644
--- a/app/src/main/kotlin/com/hippo/ehviewer/client/data/FavListUrlBuilder.kt
+++ b/app/src/main/kotlin/com/hippo/ehviewer/client/data/FavListUrlBuilder.kt
@@ -22,16 +22,16 @@ import com.hippo.ehviewer.client.ehUrl
import kotlinx.parcelize.Parcelize
@Parcelize
-class FavListUrlBuilder(
- var mPrev: String? = null,
- var mNext: String? = null,
+data class FavListUrlBuilder(
+ val favCat: Int = FAV_CAT_ALL,
+ val keyword: String? = null,
var jumpTo: String? = null,
- var keyword: String? = null,
- var favCat: Int = FAV_CAT_ALL,
+ var prev: String? = null,
+ var next: String? = null,
) : Parcelable {
fun setIndex(index: String?, isNext: Boolean) {
- mNext = index.takeIf { isNext }
- mPrev = index.takeUnless { isNext }
+ next = index.takeIf { isNext }
+ prev = index.takeUnless { isNext }
}
fun build() = ehUrl {
@@ -42,8 +42,8 @@ class FavListUrlBuilder(
addQueryParameter("favcat", "all")
}
addQueryParameterIfNotBlank("f_search", keyword)
- addQueryParameterIfNotBlank("prev", mPrev)
- addQueryParameterIfNotBlank("next", mNext)
+ addQueryParameterIfNotBlank("prev", prev)
+ addQueryParameterIfNotBlank("next", next)
addQueryParameterIfNotBlank("seek", jumpTo)
}.toString()
diff --git a/app/src/main/kotlin/com/hippo/ehviewer/ui/MainActivity.kt b/app/src/main/kotlin/com/hippo/ehviewer/ui/MainActivity.kt
index e2bbab1ec..e86b3ea83 100644
--- a/app/src/main/kotlin/com/hippo/ehviewer/ui/MainActivity.kt
+++ b/app/src/main/kotlin/com/hippo/ehviewer/ui/MainActivity.kt
@@ -31,6 +31,7 @@ import androidx.annotation.RequiresApi
import androidx.annotation.StringRes
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.ime
@@ -40,6 +41,7 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.shape.CornerSize
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Download
@@ -49,18 +51,24 @@ import androidx.compose.material.icons.filled.History
import androidx.compose.material.icons.filled.Home
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material.icons.filled.Whatshot
+import androidx.compose.material3.DrawerState2
import androidx.compose.material3.DrawerValue
import androidx.compose.material3.Icon
import androidx.compose.material3.ModalDrawerSheet
import androidx.compose.material3.ModalNavigationDrawer
import androidx.compose.material3.NavigationDrawerItem
+import androidx.compose.material3.ShapeDefaults
+import androidx.compose.material3.SideDrawer
import androidx.compose.material3.Text
import androidx.compose.material3.rememberDrawerState
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.currentRecomposeScope
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.ContentScale
@@ -140,6 +148,37 @@ class MainActivity : EhActivity() {
private lateinit var navController: NavController
private val isInitializedFlow = MutableStateFlow(false)
+ private var sideSheet by mutableStateOf<(@Composable ColumnScope.(DrawerState2) -> Unit)?>(null)
+
+ @Composable
+ fun ProvideSideSheetContent(content: @Composable ColumnScope.(DrawerState2) -> Unit) {
+ DisposableEffect(content) {
+ require(sideSheet == null) {
+ "Provide SideSheet content when previous content not released!!!"
+ }
+ sideSheet = content
+ onDispose {
+ require(sideSheet == content) {
+ "Try to release SideSheetContent not belong to us"
+ }
+ sideSheet = null
+ }
+ }
+ }
+
+ private var shareUrl: String? = null
+
+ @Composable
+ fun ProvideAssistContent(url: String) {
+ val urlState by rememberUpdatedState(url)
+ DisposableEffect(urlState) {
+ shareUrl = urlState
+ onDispose {
+ shareUrl = null
+ }
+ }
+ }
+
private fun saveImageToTempFile(uri: Uri): File? {
val bitmap = runCatching {
decodeBitmap(uri)
@@ -221,6 +260,7 @@ class MainActivity : EhActivity() {
var drawerLocked by mutableStateOf(false)
private var openDrawerFlow = MutableSharedFlow(extraBufferCapacity = 1)
+ private var openSideSheetFlow = MutableSharedFlow(extraBufferCapacity = 1)
private var recomposeFlow = MutableSharedFlow(extraBufferCapacity = 1)
fun recompose() {
@@ -311,33 +351,60 @@ class MainActivity : EhActivity() {
drawerState = drawerState,
gesturesEnabled = !drawerLocked || drawerState.isOpen,
) {
- val insets = buildWindowInsets {
- set(
- WindowInsetsCompat.Type.statusBars(),
- WindowInsets.statusBars,
- )
- set(
- WindowInsetsCompat.Type.navigationBars(),
- WindowInsets.navigationBars,
- )
- set(
- WindowInsetsCompat.Type.ime(),
- WindowInsets.ime,
- )
+ val sheet = sideSheet
+ val sideDrawerState = rememberDrawerState(DrawerValue.Closed)
+ LaunchedEffect(Unit) {
+ openSideSheetFlow.collectLatest {
+ sideDrawerState.open()
+ }
}
- AndroidViewBinding(factory = { inflater, parent, attachToParent ->
- ActivityMainBinding.inflate(inflater, parent, attachToParent).apply {
- val navHostFragment = fragmentContainer.getFragment()
- navController = navHostFragment.navController.apply {
- graph = navInflater.inflate(R.navigation.nav_graph).apply {
- check(launchPage in 0..3)
- setStartDestination(navItems[launchPage].first)
+ BackHandler(sideDrawerState.isOpen) {
+ scope.launch {
+ sideDrawerState.close()
+ }
+ }
+ SideDrawer(
+ drawerContent = {
+ if (sheet != null) {
+ ModalDrawerSheet(
+ modifier = Modifier.widthIn(max = (configuration.screenWidthDp - 112).dp),
+ drawerShape = ShapeDefaults.Large.copy(topEnd = CornerSize(0), bottomEnd = CornerSize(0)),
+ ) {
+ sheet(sideDrawerState)
}
}
- isInitializedFlow.value = true
+ },
+ drawerState = sideDrawerState,
+ gesturesEnabled = sheet != null,
+ ) {
+ val insets = buildWindowInsets {
+ set(
+ WindowInsetsCompat.Type.statusBars(),
+ WindowInsets.statusBars,
+ )
+ set(
+ WindowInsetsCompat.Type.navigationBars(),
+ WindowInsets.navigationBars,
+ )
+ set(
+ WindowInsetsCompat.Type.ime(),
+ WindowInsets.ime,
+ )
+ }
+ AndroidViewBinding(factory = { inflater, parent, attachToParent ->
+ ActivityMainBinding.inflate(inflater, parent, attachToParent).apply {
+ val navHostFragment = fragmentContainer.getFragment()
+ navController = navHostFragment.navController.apply {
+ graph = navInflater.inflate(R.navigation.nav_graph).apply {
+ check(launchPage in 0..3)
+ setStartDestination(navItems[launchPage].first)
+ }
+ }
+ isInitializedFlow.value = true
+ }
+ }) {
+ ViewCompat.dispatchApplyWindowInsets(root, insets)
}
- }) {
- ViewCompat.dispatchApplyWindowInsets(root, insets)
}
}
}
@@ -485,6 +552,10 @@ class MainActivity : EhActivity() {
openDrawerFlow.tryEmit(Unit)
}
+ fun openSideSheet() {
+ openSideSheetFlow.tryEmit(Unit)
+ }
+
fun showTip(@StringRes id: Int, length: Int, useToast: Boolean = false) {
showTip(getString(id), length, useToast)
}
@@ -508,7 +579,6 @@ class MainActivity : EhActivity() {
}
}
- var shareUrl: String? = null
override fun onProvideAssistContent(outContent: AssistContent?) {
super.onProvideAssistContent(outContent)
shareUrl?.let { outContent?.webUri = Uri.parse(shareUrl) }
diff --git a/app/src/main/kotlin/com/hippo/ehviewer/ui/legacy/FabLayout.kt b/app/src/main/kotlin/com/hippo/ehviewer/ui/legacy/FabLayout.kt
index dc1037955..08b971917 100644
--- a/app/src/main/kotlin/com/hippo/ehviewer/ui/legacy/FabLayout.kt
+++ b/app/src/main/kotlin/com/hippo/ehviewer/ui/legacy/FabLayout.kt
@@ -47,8 +47,7 @@ class FabLayout @JvmOverloads constructor(
var autoCancel by mutableStateOf(true)
private var hidden by mutableStateOf(false)
- private val expandedBackingField = mutableStateOf(false)
- var expanded by expandedBackingField
+ var expanded by mutableStateOf(false)
private val listeners = mutableListOf()
fun addOnExpandStateListener(listener: OnExpandStateListener) {
@@ -95,7 +94,7 @@ class FabLayout @JvmOverloads constructor(
contentAlignment = Alignment.BottomEnd,
) {
Box(contentAlignment = Alignment.Center) {
- val animatedProgress by animateFloatMergePredictiveBackAsState(expandedBackingField)
+ val animatedProgress by animateFloatMergePredictiveBackAsState(expanded) { expanded = false }
FloatingActionButton(
onClick = { expanded = !expanded },
modifier = Modifier.rotate(lerp(-90f, 0f, appearState)).scale(appearState),
diff --git a/app/src/main/kotlin/com/hippo/ehviewer/ui/main/FabLayout.kt b/app/src/main/kotlin/com/hippo/ehviewer/ui/main/FabLayout.kt
new file mode 100644
index 000000000..af59c7221
--- /dev/null
+++ b/app/src/main/kotlin/com/hippo/ehviewer/ui/main/FabLayout.kt
@@ -0,0 +1,111 @@
+package com.hippo.ehviewer.ui.main
+
+import androidx.compose.animation.core.animateFloatAsState
+import androidx.compose.animation.core.tween
+import androidx.compose.foundation.gestures.detectTapGestures
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.navigationBarsPadding
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Add
+import androidx.compose.material3.FloatingActionButton
+import androidx.compose.material3.Icon
+import androidx.compose.material3.SmallFloatingActionButton
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.rotate
+import androidx.compose.ui.draw.scale
+import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.input.pointer.pointerInput
+import androidx.compose.ui.layout.layout
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.util.lerp
+import com.hippo.ehviewer.ui.legacy.FAB_ANIMATE_TIME
+import com.hippo.ehviewer.ui.tools.animateFloatMergePredictiveBackAsState
+import eu.kanade.tachiyomi.util.lang.launchIO
+import moe.tarsin.coroutines.runSuspendCatching
+
+fun interface FabBuilder {
+ fun onClick(icon: ImageVector, that: suspend () -> Unit)
+}
+
+@Composable
+fun FabLayout(
+ hidden: Boolean,
+ expanded: Boolean,
+ onExpandChanged: (Boolean) -> Unit,
+ autoCancel: Boolean,
+ fabBuilder: FabBuilder.() -> Unit,
+) {
+ val secondaryFab = remember(fabBuilder) {
+ buildList {
+ fabBuilder { icon, action ->
+ add(icon to action)
+ }
+ }
+ }
+ if (expanded) {
+ if (autoCancel) {
+ Spacer(
+ modifier = Modifier.fillMaxSize().pointerInput(expanded) {
+ detectTapGestures {
+ onExpandChanged(false)
+ }
+ },
+ )
+ }
+ }
+ val coroutineScope = rememberCoroutineScope()
+ val appearState by animateFloatAsState(
+ targetValue = if (hidden) 0f else 1f,
+ animationSpec = tween(FAB_ANIMATE_TIME),
+ label = "hiddenState",
+ )
+ Box(
+ modifier = Modifier.fillMaxSize().navigationBarsPadding().padding(16.dp),
+ contentAlignment = Alignment.BottomEnd,
+ ) {
+ Box(contentAlignment = Alignment.Center) {
+ val animatedProgress by animateFloatMergePredictiveBackAsState(expanded) { onExpandChanged(false) }
+ FloatingActionButton(
+ onClick = { onExpandChanged(!expanded) },
+ modifier = Modifier.rotate(lerp(-90f, 0f, appearState)).scale(appearState),
+ ) {
+ Icon(
+ imageVector = Icons.Default.Add,
+ contentDescription = null,
+ modifier = Modifier.rotate(lerp(135f, 0f, animatedProgress)),
+ )
+ }
+ secondaryFab.run {
+ forEachIndexed { index, (imageVector, onClick) ->
+ SmallFloatingActionButton(
+ onClick = {
+ coroutineScope.launchIO {
+ runSuspendCatching {
+ onClick()
+ }
+ onExpandChanged(false)
+ }
+ },
+ modifier = Modifier.layout { measurable, constraints ->
+ val placeable = measurable.measure(constraints)
+ layout(placeable.width, placeable.height) {
+ val distance = lerp(150 * (size - index) + 50, 0, animatedProgress)
+ placeable.placeRelative(0, -distance, -(size - index).toFloat())
+ }
+ }.rotate(lerp(90f, 0f, appearState)).scale(appearState),
+ ) {
+ Icon(imageVector = imageVector, contentDescription = null)
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/app/src/main/kotlin/com/hippo/ehviewer/ui/scene/ActionBuilder.kt b/app/src/main/kotlin/com/hippo/ehviewer/ui/scene/ActionBuilder.kt
index 205d378c8..4ed584e6a 100644
--- a/app/src/main/kotlin/com/hippo/ehviewer/ui/scene/ActionBuilder.kt
+++ b/app/src/main/kotlin/com/hippo/ehviewer/ui/scene/ActionBuilder.kt
@@ -1,13 +1,9 @@
package com.hippo.ehviewer.ui.scene
-interface ActionScope {
+fun interface ActionScope {
infix fun String.thenDo(that: suspend () -> Unit)
}
inline fun buildAction(builder: ActionScope.() -> Unit) = buildList {
- builder(object : ActionScope {
- override fun String.thenDo(that: suspend () -> Unit) {
- add(this to that)
- }
- })
+ builder { that -> add(this to that) }
}
diff --git a/app/src/main/kotlin/com/hippo/ehviewer/ui/scene/FavoritesFragment.kt b/app/src/main/kotlin/com/hippo/ehviewer/ui/scene/FavoritesFragment.kt
new file mode 100644
index 000000000..bb3bfc76e
--- /dev/null
+++ b/app/src/main/kotlin/com/hippo/ehviewer/ui/scene/FavoritesFragment.kt
@@ -0,0 +1,516 @@
+package com.hippo.ehviewer.ui.scene
+
+import android.os.Bundle
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.widthIn
+import androidx.compose.foundation.lazy.staggeredgrid.StaggeredGridCells
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.text2.input.rememberTextFieldState
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.filled.DriveFileMove
+import androidx.compose.material.icons.automirrored.filled.LastPage
+import androidx.compose.material.icons.filled.Delete
+import androidx.compose.material.icons.filled.DoneAll
+import androidx.compose.material.icons.filled.Download
+import androidx.compose.material.icons.filled.Refresh
+import androidx.compose.material.icons.outlined.FolderSpecial
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.ListItem
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.ShapeDefaults
+import androidx.compose.material3.Text
+import androidx.compose.material3.TopAppBar
+import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.SideEffect
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.currentRecomposeScope
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateMapOf
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.saveable.rememberSaveable
+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.geometry.Offset
+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.res.dimensionResource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.unit.dp
+import androidx.core.os.bundleOf
+import androidx.lifecycle.viewModelScope
+import androidx.navigation.NavController
+import androidx.navigation.fragment.findNavController
+import androidx.paging.LoadState
+import androidx.paging.Pager
+import androidx.paging.PagingConfig
+import androidx.paging.PagingSource
+import androidx.paging.PagingState
+import androidx.paging.cachedIn
+import androidx.paging.compose.collectAsLazyPagingItems
+import androidx.paging.compose.itemContentType
+import androidx.paging.compose.itemKey
+import com.google.android.material.datepicker.CalendarConstraints
+import com.google.android.material.datepicker.CompositeDateValidator
+import com.google.android.material.datepicker.DateValidatorPointBackward
+import com.google.android.material.datepicker.DateValidatorPointForward
+import com.google.android.material.datepicker.MaterialDatePicker
+import com.hippo.ehviewer.EhDB
+import com.hippo.ehviewer.R
+import com.hippo.ehviewer.Settings
+import com.hippo.ehviewer.client.EhCookieStore
+import com.hippo.ehviewer.client.EhEngine
+import com.hippo.ehviewer.client.data.BaseGalleryInfo
+import com.hippo.ehviewer.client.data.FavListUrlBuilder
+import com.hippo.ehviewer.icons.EhIcons
+import com.hippo.ehviewer.icons.big.SadAndroid
+import com.hippo.ehviewer.icons.filled.GoTo
+import com.hippo.ehviewer.ui.MainActivity
+import com.hippo.ehviewer.ui.main.FabLayout
+import com.hippo.ehviewer.ui.main.GalleryInfoGridItem
+import com.hippo.ehviewer.ui.main.GalleryInfoListItem
+import com.hippo.ehviewer.ui.startDownload
+import com.hippo.ehviewer.ui.tools.FastScrollLazyColumn
+import com.hippo.ehviewer.ui.tools.FastScrollLazyVerticalStaggeredGrid
+import com.hippo.ehviewer.ui.tools.LocalDialogState
+import com.hippo.ehviewer.ui.tools.rememberInVM
+import com.hippo.ehviewer.util.ExceptionUtils
+import com.hippo.ehviewer.util.findActivity
+import com.hippo.ehviewer.util.mapToLongArray
+import com.ramcosta.composedestinations.annotation.Destination
+import eu.kanade.tachiyomi.util.lang.withIOContext
+import eu.kanade.tachiyomi.util.system.pxToDp
+import java.time.Instant
+import java.time.LocalDateTime
+import java.time.ZoneId
+import java.time.ZoneOffset
+import java.time.format.DateTimeFormatter
+import java.util.Locale
+import kotlin.math.roundToInt
+import kotlinx.coroutines.CancellationException
+import kotlinx.coroutines.launch
+import moe.tarsin.coroutines.runSuspendCatching
+
+@Destination
+@Composable
+fun FavouritesScreen(navigator: NavController) {
+ // Meta State
+ var urlBuilder by rememberSaveable { mutableStateOf(FavListUrlBuilder(favCat = Settings.recentFavCat)) }
+ var searchBarOffsetY by remember { mutableStateOf(0) }
+
+ // Derived State
+ val keyword = remember(urlBuilder) { urlBuilder.keyword.orEmpty() }
+ val localFavName = stringResource(R.string.local_favorites)
+ val cloudFavName = stringResource(R.string.cloud_favorites)
+ val favCatName = remember(urlBuilder) {
+ when (val favCat = urlBuilder.favCat) {
+ in 0..9 -> Settings.favCat[favCat]
+ FavListUrlBuilder.FAV_CAT_LOCAL -> localFavName
+ else -> cloudFavName
+ }
+ }
+ val favTitle = stringResource(R.string.favorites_title, favCatName)
+ val favTitleWithKeyword = stringResource(R.string.favorites_title_2, favCatName, keyword)
+ val title = remember(urlBuilder) { if (keyword.isBlank()) favTitle else favTitleWithKeyword }
+ val context = LocalContext.current
+ val dialogState = LocalDialogState.current
+ val activity = remember(context) { context.findActivity() }
+ val coroutineScope = rememberCoroutineScope()
+ val localFavCountFlow = rememberInVM { EhDB.localFavCount }
+ val searchBarHint = stringResource(R.string.search_bar_hint, favCatName)
+
+ fun switchFav(newCat: Int, keyword: String? = null) {
+ urlBuilder = urlBuilder.copy(
+ keyword = keyword,
+ favCat = newCat,
+ jumpTo = null,
+ ).apply { setIndex(null, true) }
+ Settings.recentFavCat = urlBuilder.favCat
+ }
+
+ with(activity) {
+ ProvideSideSheetContent { sheetState ->
+ val localFavCount by localFavCountFlow.collectAsState(0)
+ TopAppBar(title = { Text(text = stringResource(id = R.string.collections)) })
+ val scope = currentRecomposeScope
+ LaunchedEffect(Unit) {
+ Settings.favChangesFlow.collect {
+ scope.invalidate()
+ }
+ }
+ val localFav = stringResource(id = R.string.local_favorites) to localFavCount
+ val faves = if (EhCookieStore.hasSignedIn()) {
+ arrayOf(
+ localFav,
+ stringResource(id = R.string.cloud_favorites) to Settings.favCloudCount,
+ *Settings.favCat.zip(Settings.favCount.toTypedArray()).toTypedArray(),
+ )
+ } else {
+ arrayOf(localFav)
+ }
+ Column(modifier = Modifier.verticalScroll(rememberScrollState())) {
+ faves.forEachIndexed { index, (name, count) ->
+ ListItem(
+ headlineContent = { Text(text = name) },
+ trailingContent = { Text(text = count.toString(), style = MaterialTheme.typography.bodyLarge) },
+ modifier = Modifier.clickable {
+ switchFav(index - 2)
+ coroutineScope.launch { sheetState.close() }
+ },
+ )
+ }
+ }
+ }
+ }
+
+ val searchFieldState = rememberTextFieldState()
+ val data = rememberInVM(urlBuilder) {
+ if (urlBuilder.favCat == FavListUrlBuilder.FAV_CAT_LOCAL) {
+ Pager(PagingConfig(20, jumpThreshold = 40)) {
+ if (keyword.isBlank()) {
+ EhDB.localFavLazyList
+ } else {
+ EhDB.searchLocalFav(keyword)
+ }
+ }
+ } else {
+ Pager(PagingConfig(25)) {
+ object : PagingSource() {
+ override fun getRefreshKey(state: PagingState): String? = null
+ override suspend fun load(params: LoadParams) = withIOContext {
+ when (params) {
+ is LoadParams.Prepend -> urlBuilder.setIndex(params.key, isNext = false)
+ is LoadParams.Append -> urlBuilder.setIndex(params.key, isNext = true)
+ is LoadParams.Refresh -> {
+ val key = params.key
+ if (key.isNullOrBlank()) {
+ if (urlBuilder.jumpTo != null) {
+ urlBuilder.next ?: urlBuilder.setIndex("2", true)
+ }
+ } else {
+ urlBuilder.setIndex(key, false)
+ }
+ }
+ }
+ val r = runSuspendCatching {
+ EhEngine.getFavorites(urlBuilder.build())
+ }.onFailure {
+ return@withIOContext LoadResult.Error(it)
+ }.getOrThrow()
+ Settings.favCat = r.catArray
+ Settings.favCount = r.countArray
+ Settings.favCloudCount = r.countArray.sum()
+ urlBuilder.jumpTo = null
+ LoadResult.Page(r.galleryInfoList, r.prev, r.next)
+ }
+ }
+ }
+ }.flow.cachedIn(viewModelScope)
+ }.collectAsLazyPagingItems()
+
+ val refreshState = rememberPullToRefreshState {
+ data.loadState.refresh is LoadState.NotLoading
+ }
+
+ if (refreshState.isRefreshing) {
+ LaunchedEffect(Unit) {
+ data.refresh()
+ }
+ }
+
+ val searchBarConnection = remember {
+ object : NestedScrollConnection {
+ override fun onPostScroll(consumed: Offset, available: Offset, source: NestedScrollSource): Offset {
+ searchBarOffsetY = (searchBarOffsetY + consumed.y).roundToInt().coerceIn(-300, 0)
+ return Offset.Zero // We never consume it
+ }
+ }
+ }
+ val combinedModifier = Modifier.nestedScroll(searchBarConnection).nestedScroll(refreshState.nestedScrollConnection)
+
+ var expanded by remember { mutableStateOf(false) }
+ var hidden by remember { mutableStateOf(false) }
+ var selectMode by remember { mutableStateOf(false) }
+ val checkedInfoMap = remember { mutableStateMapOf() }
+ SideEffect {
+ if (checkedInfoMap.isEmpty()) selectMode = false
+ if (selectMode) expanded = true
+ }
+
+ SearchBarScreen(
+ title = title,
+ searchFieldState = searchFieldState,
+ searchFieldHint = searchBarHint,
+ onApplySearch = { switchFav(urlBuilder.favCat, it) },
+ onSearchExpanded = { hidden = true },
+ onSearchHidden = { hidden = false },
+ refreshState = refreshState,
+ searchBarOffsetY = searchBarOffsetY,
+ trailingIcon = {
+ IconButton(onClick = { activity.openSideSheet() }) {
+ Icon(imageVector = Icons.Outlined.FolderSpecial, contentDescription = null)
+ }
+ },
+ ) {
+ val realPadding = PaddingValues(
+ top = it.calculateTopPadding() + 8.dp,
+ bottom = it.calculateBottomPadding(),
+ )
+ val listMode by remember {
+ Settings.listModeBackField.valueFlow()
+ }.collectAsState(Settings.listMode)
+ val marginH = dimensionResource(id = R.dimen.gallery_list_margin_h)
+ Box(
+ modifier = Modifier.fillMaxSize(),
+ contentAlignment = Alignment.Center,
+ ) {
+ when (val state = data.loadState.refresh) {
+ is LoadState.Loading -> if (data.itemCount == 0) CircularProgressIndicator()
+ is LoadState.Error -> {
+ Column(
+ modifier = Modifier.widthIn(max = 228.dp).clip(ShapeDefaults.Small).clickable { data.retry() },
+ horizontalAlignment = Alignment.CenterHorizontally,
+ ) {
+ Icon(
+ imageVector = EhIcons.Big.Default.SadAndroid,
+ contentDescription = null,
+ modifier = Modifier.padding(16.dp).size(120.dp),
+ tint = MaterialTheme.colorScheme.tertiary,
+ )
+ Text(
+ text = ExceptionUtils.getReadableString(state.error),
+ style = MaterialTheme.typography.bodyLarge,
+ textAlign = TextAlign.Center,
+ )
+ }
+ }
+ is LoadState.NotLoading -> SideEffect {
+ refreshState.endRefresh()
+ }
+ }
+ if (listMode == 0) {
+ val height = (3 * Settings.listThumbSize * 3).pxToDp.dp
+ val showPages = Settings.showGalleryPages
+ FastScrollLazyColumn(
+ modifier = Modifier.padding(horizontal = marginH) then combinedModifier,
+ contentPadding = realPadding,
+ verticalArrangement = Arrangement.spacedBy(dimensionResource(R.dimen.gallery_list_interval)),
+ ) {
+ items(
+ count = data.itemCount,
+ key = data.itemKey(key = { item -> item.gid }),
+ contentType = data.itemContentType(),
+ ) { index ->
+ val info = data[index]
+ if (info != null) {
+ val checked = info.gid in checkedInfoMap
+ CheckableItem(checked = checked) {
+ GalleryInfoListItem(
+ onClick = {
+ if (selectMode) {
+ if (checked) {
+ checkedInfoMap.remove(info.gid)
+ } else {
+ checkedInfoMap[info.gid] = info
+ }
+ } else {
+ navigator.navAnimated(
+ R.id.galleryDetailScene,
+ bundleOf(GalleryDetailScene.KEY_ARGS to GalleryInfoArgs(info)),
+ )
+ }
+ },
+ onLongClick = {
+ selectMode = true
+ checkedInfoMap[info.gid] = info
+ },
+ info = info,
+ isInFavScene = true,
+ showPages = showPages,
+ modifier = Modifier.height(height),
+ )
+ }
+ }
+ }
+ }
+ } else {
+ val gridInterval = dimensionResource(R.dimen.gallery_grid_interval)
+ FastScrollLazyVerticalStaggeredGrid(
+ columns = StaggeredGridCells.Adaptive(Settings.thumbSizeDp.dp),
+ modifier = Modifier.padding(horizontal = marginH) then combinedModifier,
+ verticalItemSpacing = gridInterval,
+ horizontalArrangement = Arrangement.spacedBy(gridInterval),
+ contentPadding = realPadding,
+ ) {
+ items(
+ count = data.itemCount,
+ key = data.itemKey(key = { item -> item.gid }),
+ contentType = data.itemContentType(),
+ ) { index ->
+ val info = data[index]
+ if (info != null) {
+ val checked = info.gid in checkedInfoMap
+ CheckableItem(checked = checked) {
+ GalleryInfoGridItem(
+ onClick = {
+ if (selectMode) {
+ if (checked) {
+ checkedInfoMap.remove(info.gid)
+ } else {
+ checkedInfoMap[info.gid] = info
+ }
+ } else {
+ navigator.navAnimated(
+ R.id.galleryDetailScene,
+ bundleOf(GalleryDetailScene.KEY_ARGS to GalleryInfoArgs(info)),
+ )
+ }
+ },
+ onLongClick = {
+ selectMode = true
+ checkedInfoMap[info.gid] = info
+ },
+ info = info,
+ )
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+
+ // Explicitly reallocate fabBuilder lambda to recompose secondary fab
+ val select = selectMode
+ FabLayout(
+ hidden = hidden,
+ expanded = expanded,
+ onExpandChanged = {
+ expanded = it
+ checkedInfoMap.clear()
+ selectMode = false
+ },
+ autoCancel = !selectMode,
+ ) {
+ if (!select) {
+ onClick(EhIcons.Default.GoTo) {
+ val local = LocalDateTime.of(2007, 3, 21, 0, 0)
+ val fromDate = local.atZone(ZoneId.ofOffset("UTC", ZoneOffset.UTC)).toInstant().toEpochMilli()
+ val toDate = MaterialDatePicker.todayInUtcMilliseconds()
+ val listValidators = ArrayList()
+ listValidators.add(DateValidatorPointForward.from(fromDate))
+ listValidators.add(DateValidatorPointBackward.before(toDate))
+ val constraintsBuilder = CalendarConstraints.Builder()
+ .setStart(fromDate)
+ .setEnd(toDate)
+ .setValidator(CompositeDateValidator.allOf(listValidators))
+ val datePicker = MaterialDatePicker.Builder.datePicker()
+ .setCalendarConstraints(constraintsBuilder.build())
+ .setTitleText(R.string.go_to)
+ .setSelection(toDate)
+ .build()
+ datePicker.show(activity.supportFragmentManager, "date-picker")
+ datePicker.addOnPositiveButtonClickListener { time: Long ->
+ val formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd", Locale.US).withZone(ZoneOffset.UTC)
+ val jumpTo = formatter.format(Instant.ofEpochMilli(time))
+ urlBuilder = urlBuilder.copy(jumpTo = jumpTo)
+ }
+ }
+ onClick(Icons.Default.Refresh) {
+ switchFav(urlBuilder.favCat)
+ }
+ onClick(Icons.AutoMirrored.Default.LastPage) {
+ urlBuilder = urlBuilder.copy().apply { setIndex("1-0", false) }
+ }
+ } else {
+ onClick(Icons.Default.DoneAll) {
+ val info = data.itemSnapshotList.filterNotNull().associateBy { it.gid }
+ checkedInfoMap.putAll(info)
+ throw CancellationException()
+ }
+ onClick(Icons.Default.Download) {
+ val info = checkedInfoMap.run { values.also { clear() } }
+ dialogState.startDownload(context, false, *info.toTypedArray())
+ }
+ onClick(Icons.Default.Delete) {
+ val info = checkedInfoMap.run { values.also { clear() } }
+ dialogState.awaitPermissionOrCancel(title = R.string.delete_favorites_dialog_title) {
+ Text(text = stringResource(R.string.delete_favorites_dialog_message, info.size))
+ }
+ val srcCat = urlBuilder.favCat
+ if (srcCat == FavListUrlBuilder.FAV_CAT_LOCAL) { // Delete local fav
+ EhDB.removeLocalFavorites(info)
+ } else {
+ val delList = info.mapToLongArray(BaseGalleryInfo::gid)
+ EhEngine.modifyFavorites(delList, srcCat, -1)
+ }
+ data.refresh()
+ }
+ onClick(Icons.AutoMirrored.Default.DriveFileMove) {
+ // First is local favorite, the other 10 is cloud favorite
+ val array = if (EhCookieStore.hasSignedIn()) {
+ arrayOf(localFavName, *Settings.favCat)
+ } else {
+ arrayOf(localFavName)
+ }
+ val index = dialogState.showSelectItem(*array, title = R.string.move_favorites_dialog_title)
+ val srcCat = urlBuilder.favCat
+ val dstCat = if (index == 0) FavListUrlBuilder.FAV_CAT_LOCAL else index - 1
+ val info = checkedInfoMap.run { values.also { clear() } }
+ if (srcCat != dstCat) {
+ if (srcCat == FavListUrlBuilder.FAV_CAT_LOCAL) {
+ // Move from local to cloud
+ EhDB.removeLocalFavorites(info)
+ val galleryList = info.map { it.gid to it.token!! }
+ runSuspendCatching {
+ EhEngine.addFavorites(galleryList, dstCat)
+ }
+ } else if (dstCat == FavListUrlBuilder.FAV_CAT_LOCAL) {
+ // Move from cloud to local
+ EhDB.putLocalFavorites(info)
+ } else {
+ // Move from cloud to cloud
+ val gidArray = info.mapToLongArray(BaseGalleryInfo::gid)
+ runSuspendCatching {
+ EhEngine.modifyFavorites(gidArray, srcCat, dstCat)
+ }
+ }
+ data.refresh()
+ }
+ }
+ }
+ }
+}
+
+class FavoritesFragment : BaseScene() {
+ override val enableDrawerGestures = true
+
+ override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
+ return ComposeWithMD3 {
+ val navController = remember { findNavController() }
+ FavouritesScreen(navController)
+ }
+ }
+}
diff --git a/app/src/main/kotlin/com/hippo/ehviewer/ui/scene/FavoritesScene.kt b/app/src/main/kotlin/com/hippo/ehviewer/ui/scene/FavoritesScene.kt
deleted file mode 100644
index aaa9284b6..000000000
--- a/app/src/main/kotlin/com/hippo/ehviewer/ui/scene/FavoritesScene.kt
+++ /dev/null
@@ -1,518 +0,0 @@
-/*
- * Copyright 2016 Hippo Seven
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-package com.hippo.ehviewer.ui.scene
-
-import android.os.Bundle
-import android.view.LayoutInflater
-import android.view.ViewGroup
-import androidx.compose.foundation.clickable
-import androidx.compose.foundation.layout.Column
-import androidx.compose.foundation.rememberScrollState
-import androidx.compose.foundation.verticalScroll
-import androidx.compose.material.icons.Icons
-import androidx.compose.material.icons.automirrored.filled.DriveFileMove
-import androidx.compose.material.icons.automirrored.filled.Redo
-import androidx.compose.material.icons.filled.ArrowDownward
-import androidx.compose.material.icons.filled.Delete
-import androidx.compose.material.icons.filled.DoneAll
-import androidx.compose.material.icons.filled.Download
-import androidx.compose.material.icons.filled.Refresh
-import androidx.compose.material.icons.outlined.FolderSpecial
-import androidx.compose.material3.ElevatedCard
-import androidx.compose.material3.Icon
-import androidx.compose.material3.IconButton
-import androidx.compose.material3.ListItem
-import androidx.compose.material3.MaterialTheme
-import androidx.compose.material3.Text
-import androidx.compose.material3.TopAppBar
-import androidx.compose.runtime.Composable
-import androidx.compose.runtime.LaunchedEffect
-import androidx.compose.runtime.collectAsState
-import androidx.compose.runtime.currentRecomposeScope
-import androidx.compose.runtime.getValue
-import androidx.compose.ui.Modifier
-import androidx.compose.ui.res.stringResource
-import androidx.core.content.ContextCompat
-import androidx.core.os.bundleOf
-import androidx.fragment.app.viewModels
-import androidx.lifecycle.ViewModel
-import androidx.lifecycle.lifecycleScope
-import androidx.lifecycle.viewModelScope
-import androidx.paging.LoadState
-import androidx.paging.Pager
-import androidx.paging.PagingConfig
-import androidx.paging.PagingSource
-import androidx.paging.PagingState
-import androidx.paging.cachedIn
-import androidx.recyclerview.widget.ItemTouchHelper
-import androidx.recyclerview.widget.RecyclerView
-import androidx.viewbinding.ViewBinding
-import com.google.android.material.datepicker.CalendarConstraints
-import com.google.android.material.datepicker.CalendarConstraints.DateValidator
-import com.google.android.material.datepicker.CompositeDateValidator
-import com.google.android.material.datepicker.DateValidatorPointBackward
-import com.google.android.material.datepicker.DateValidatorPointForward
-import com.google.android.material.datepicker.MaterialDatePicker
-import com.hippo.ehviewer.EhDB
-import com.hippo.ehviewer.R
-import com.hippo.ehviewer.Settings
-import com.hippo.ehviewer.client.EhCookieStore
-import com.hippo.ehviewer.client.EhEngine
-import com.hippo.ehviewer.client.data.BaseGalleryInfo
-import com.hippo.ehviewer.client.data.FavListUrlBuilder
-import com.hippo.ehviewer.databinding.SceneFavoritesBinding
-import com.hippo.ehviewer.ui.legacy.HandlerDrawable
-import com.hippo.ehviewer.ui.legacy.SecondaryFab
-import com.hippo.ehviewer.ui.legacy.ViewTransition
-import com.hippo.ehviewer.ui.startDownload
-import com.hippo.ehviewer.ui.tools.DialogState
-import com.hippo.ehviewer.util.ExceptionUtils
-import com.hippo.ehviewer.util.getValue
-import com.hippo.ehviewer.util.lazyMut
-import com.hippo.ehviewer.util.mapToLongArray
-import com.hippo.ehviewer.util.setValue
-import eu.kanade.tachiyomi.util.lang.launchIO
-import eu.kanade.tachiyomi.util.lang.withIOContext
-import java.time.Instant
-import java.time.LocalDateTime
-import java.time.ZoneId
-import java.time.ZoneOffset
-import java.time.format.DateTimeFormatter
-import java.util.Locale
-import kotlinx.coroutines.CancellationException
-import kotlinx.coroutines.Job
-import kotlinx.coroutines.cancel
-import kotlinx.coroutines.delay
-import kotlinx.coroutines.flow.collectLatest
-import kotlinx.coroutines.launch
-import moe.tarsin.coroutines.runSuspendCatching
-import rikka.core.res.resolveColor
-
-// Note that we do not really follow mvvm structure, just use it as ... storage
-class VMStorage : ViewModel() {
- var urlBuilder = FavListUrlBuilder(favCat = Settings.recentFavCat)
- private val cloudDataFlow = Pager(PagingConfig(25)) {
- object : PagingSource() {
- override fun getRefreshKey(state: PagingState): String? = null
- override suspend fun load(params: LoadParams) = withIOContext {
- when (params) {
- is LoadParams.Prepend -> urlBuilder.setIndex(params.key, isNext = false)
- is LoadParams.Append -> urlBuilder.setIndex(params.key, isNext = true)
- is LoadParams.Refresh -> {
- val key = params.key
- if (key.isNullOrBlank()) {
- if (urlBuilder.jumpTo != null) {
- urlBuilder.mNext ?: urlBuilder.setIndex("2", true)
- }
- } else {
- urlBuilder.setIndex(key, false)
- }
- }
- }
- val r = runSuspendCatching {
- EhEngine.getFavorites(urlBuilder.build())
- }.onFailure {
- return@withIOContext LoadResult.Error(it)
- }.getOrThrow()
- Settings.favCat = r.catArray
- Settings.favCount = r.countArray
- Settings.favCloudCount = r.countArray.sum()
- urlBuilder.jumpTo = null
- LoadResult.Page(r.galleryInfoList, r.prev, r.next)
- }
- }
- }.flow.cachedIn(viewModelScope)
-
- private val localFavDataFlow = Pager(PagingConfig(20, jumpThreshold = 40)) {
- val keyword = urlBuilder.keyword
- if (keyword.isNullOrBlank()) {
- EhDB.localFavLazyList
- } else {
- EhDB.searchLocalFav(keyword)
- }
- }.flow.cachedIn(viewModelScope)
- fun dataflow() = if (urlBuilder.favCat == FavListUrlBuilder.FAV_CAT_LOCAL) localFavDataFlow else cloudDataFlow
- val localFavCount = EhDB.localFavCount
-}
-
-class FavoritesScene : SearchBarScene() {
- private val vm: VMStorage by viewModels()
- private var urlBuilder by lazyMut { vm::urlBuilder }
- private var _binding: SceneFavoritesBinding? = null
- private val binding get() = _binding!!
- private var mAdapter: GalleryAdapter? = null
- private val tracker get() = mAdapter!!.tracker!!
- private var showNormalFabsJob: Job? = null
-
- private val dialogState = DialogState()
-
- override val fabLayout get() = binding.fabLayout
- override val fastScroller get() = binding.fastScroller
- override val recyclerView get() = binding.recyclerView
-
- private var onDrawerItemClick: ((Int) -> Unit)? = null
-
- private var collectJob: Job? = null
-
- private fun switchFav(newCat: Int, keyword: String? = null) {
- _binding ?: return
- urlBuilder.keyword = keyword
- urlBuilder.favCat = newCat
- urlBuilder.jumpTo = null
- urlBuilder.setIndex(null, true)
- collectJob?.cancel()
- collectJob = viewLifecycleOwner.lifecycleScope.launchIO {
- vm.dataflow().collectLatest {
- mAdapter?.submitData(it)
- }
- }
- mAdapter?.refresh()
- val favCatName: String = when (val favCat = urlBuilder.favCat) {
- in 0..9 -> Settings.favCat[favCat]
- FavListUrlBuilder.FAV_CAT_LOCAL -> getString(R.string.local_favorites)
- else -> getString(R.string.cloud_favorites)
- }
- if (keyword.isNullOrEmpty()) {
- setTitle(getString(R.string.favorites_title, favCatName))
- setSearchBarText(null)
- } else {
- setTitle(getString(R.string.favorites_title_2, favCatName, keyword))
- }
- setSearchBarHint(getString(R.string.search_bar_hint, favCatName))
- Settings.recentFavCat = urlBuilder.favCat
- }
-
- @Composable
- override fun TrailingIcon() {
- dialogState.Intercept()
- IconButton(onClick = { openSideSheet() }) {
- Icon(imageVector = Icons.Outlined.FolderSpecial, contentDescription = stringResource(id = R.string.collections))
- }
- }
-
- override fun onSaveInstanceState(outState: Bundle) {
- super.onSaveInstanceState(outState)
- mAdapter?.let { tracker.saveSelection(outState) }
- }
-
- override fun onResume() {
- super.onResume()
- mAdapter?.type = Settings.listMode
- }
-
- private val SceneFavoritesBinding.fastScroller get() = contentLayout.fastScroller
- private val SceneFavoritesBinding.refreshLayout get() = contentLayout.refreshLayout
- private val SceneFavoritesBinding.recyclerView get() = contentLayout.recyclerView
- private val SceneFavoritesBinding.progress get() = contentLayout.progress
- private val SceneFavoritesBinding.tip get() = contentLayout.tip
-
- override fun onCreateViewWithToolbar(
- inflater: LayoutInflater,
- container: ViewGroup,
- savedInstanceState: Bundle?,
- ): ViewBinding {
- _binding = SceneFavoritesBinding.inflate(inflater, container)
- val fabActions = listOf(
- // Normal
- Icons.AutoMirrored.Filled.Redo to {
- showGoToDialog()
- },
- Icons.Default.Refresh to {
- switchFav(urlBuilder.favCat)
- },
- Icons.Default.ArrowDownward to {
- urlBuilder.setIndex("1-0", false)
- mAdapter?.refresh()
- },
-
- // Select
- Icons.Default.DoneAll to {
- tracker.selectAll()
- throw CancellationException()
- },
- Icons.Default.Download to {
- dialogState.startDownload(requireContext(), false, *takeCheckedInfo().toTypedArray())
- },
- Icons.Default.Delete to {
- dialogState.awaitPermissionOrCancel(title = R.string.delete_favorites_dialog_title) {
- Text(text = stringResource(R.string.delete_favorites_dialog_message, checkedSize()))
- }
- val info = takeCheckedInfo()
- val srcCat = urlBuilder.favCat
- if (srcCat == FavListUrlBuilder.FAV_CAT_LOCAL) { // Delete local fav
- EhDB.removeLocalFavorites(info)
- } else {
- val delList = info.mapToLongArray(BaseGalleryInfo::gid)
- EhEngine.modifyFavorites(delList, srcCat, -1)
- }
- mAdapter?.refresh()
- },
- Icons.AutoMirrored.Default.DriveFileMove to {
- // First is local favorite, the other 10 is cloud favorite
- val localFav = getString(R.string.local_favorites)
- val array = if (EhCookieStore.hasSignedIn()) {
- arrayOf(localFav, *Settings.favCat)
- } else {
- arrayOf(localFav)
- }
- val index = dialogState.showSelectItem(*array, title = R.string.move_favorites_dialog_title)
- val srcCat = urlBuilder.favCat
- val dstCat = if (index == 0) FavListUrlBuilder.FAV_CAT_LOCAL else index - 1
- val info = takeCheckedInfo()
- if (srcCat != dstCat) {
- if (srcCat == FavListUrlBuilder.FAV_CAT_LOCAL) {
- // Move from local to cloud
- EhDB.removeLocalFavorites(info)
- val galleryList = info.map { it.gid to it.token!! }
- runSuspendCatching {
- EhEngine.addFavorites(galleryList, dstCat)
- }
- } else if (dstCat == FavListUrlBuilder.FAV_CAT_LOCAL) {
- // Move from cloud to local
- EhDB.putLocalFavorites(info)
- } else {
- // Move from cloud to cloud
- val gidArray = info.mapToLongArray(BaseGalleryInfo::gid)
- runSuspendCatching {
- EhEngine.modifyFavorites(gidArray, srcCat, dstCat)
- }
- }
- mAdapter?.refresh()
- }
- },
- )
- fun updateJumpFab() {
- val isLocalFav = urlBuilder.favCat == FavListUrlBuilder.FAV_CAT_LOCAL
- binding.fabLayout.secondaryFab = if (isLocalFav) listOf(fabActions[1]) else fabActions.take(3)
- }
- onDrawerItemClick = {
- // Skip if in search mode
- if (!tracker.isInCustomChoice) {
- switchFav(it - 2)
- updateJumpFab()
- closeSideSheet()
- }
- }
- setOnApplySearch {
- if (!tracker.isInCustomChoice) {
- switchFav(urlBuilder.favCat, it)
- }
- }
- binding.fastScroller.attachToRecyclerView(binding.recyclerView)
- binding.fastScroller.setHandlerDrawable(HandlerDrawable().apply { setColor(inflater.context.theme.resolveColor(androidx.appcompat.R.attr.colorPrimary)) })
- binding.fabLayout.run {
- updateJumpFab()
- addOnExpandStateListener {
- if (tracker.isInCustomChoice && !it) tracker.clearSelection()
- }
- }
- binding.recyclerView.run {
- mAdapter = GalleryAdapter(
- this@run,
- false,
- { info ->
- navAnimated(
- R.id.galleryDetailScene,
- bundleOf(GalleryDetailScene.KEY_ARGS to GalleryInfoArgs(info)),
- )
- },
- {},
- ).also { adapter ->
- val drawable = ContextCompat.getDrawable(context, R.drawable.big_sad_pandroid)!!
- drawable.setBounds(0, 0, drawable.intrinsicWidth, drawable.intrinsicHeight)
- binding.tip.setCompoundDrawables(null, drawable, null, null)
- binding.tip.setOnClickListener { mAdapter?.retry() }
- binding.refreshLayout.setOnRefreshListener { switchFav(urlBuilder.favCat) }
- val transition = ViewTransition(binding.refreshLayout, binding.progress, binding.tip)
- val empty = getString(R.string.gallery_list_empty_hit)
- viewLifecycleOwner.lifecycleScope.launch {
- adapter.loadStateFlow.collectLatest {
- when (val state = it.refresh) {
- is LoadState.Loading -> {
- if (!binding.refreshLayout.isRefreshing) {
- transition.showView(1)
- }
- }
- is LoadState.Error -> {
- binding.refreshLayout.isRefreshing = false
- binding.tip.text = ExceptionUtils.getReadableString(state.error)
- transition.showView(2)
- }
- is LoadState.NotLoading -> {
- binding.refreshLayout.isRefreshing = false
- if (mAdapter?.itemCount == 0) {
- binding.tip.text = empty
- transition.showView(2)
- } else {
- transition.showView(0)
- }
- }
- }
- }
- }
- adapter.tracker = GallerySelectionTracker(
- "favorite-selection",
- this,
- { adapter.snapshot().items },
- { (this as? GalleryHolder)?.galleryId },
- ).apply {
- addCustomChoiceListener(
- onIntoCustomChoiceListener = {
- showNormalFabsJob?.cancel()
- binding.fabLayout.secondaryFab = fabActions.drop(3)
- binding.fabLayout.autoCancel = false
- binding.fabLayout.expanded = true
- binding.refreshLayout.isEnabled = false
- lockDrawer()
- },
- onOutOfCustomChoiceListener = {
- // Delay showing normal fab to avoid mutation
- showNormalFabsJob = lifecycleScope.launch {
- delay(300)
- updateJumpFab()
- }
- binding.fabLayout.autoCancel = true
- binding.fabLayout.expanded = false
- binding.refreshLayout.isEnabled = true
- unlockDrawer()
- },
- )
- restoreSelection(savedInstanceState)
- }
- }
- switchFav(Settings.recentFavCat)
- }
- val itemTouchHelper = ItemTouchHelper(object : ItemTouchHelper.Callback() {
- override fun getMovementFlags(recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder): Int {
- return if (tracker.isInCustomChoice) {
- 0
- } else {
- makeMovementFlags(0, ItemTouchHelper.LEFT)
- }
- }
- override fun onMove(
- recyclerView: RecyclerView,
- viewHolder: RecyclerView.ViewHolder,
- target: RecyclerView.ViewHolder,
- ) = false
- override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) {
- val position = viewHolder.bindingAdapterPosition
- val info = mAdapter!!.peek(position)!!
- lifecycleScope.launchIO {
- dialogState.awaitPermissionOrCancel(
- confirmText = R.string.delete,
- title = R.string.delete_favorites_dialog_title,
- onDismiss = { mAdapter!!.notifyItemChanged(position) },
- ) {
- Text(text = stringResource(id = R.string.delete_favorites_dialog_message, 1))
- }
- if (urlBuilder.favCat == FavListUrlBuilder.FAV_CAT_LOCAL) {
- EhDB.removeLocalFavorites(info)
- } else {
- EhEngine.modifyFavorites(info.gid, info.token)
- }
- }
- }
- })
- itemTouchHelper.attachToRecyclerView(binding.recyclerView)
- return binding
- }
-
- override fun onRelease() {
- super.onRelease()
- binding.recyclerView.stopScroll()
- mAdapter = null
- _binding = null
- showNormalFabsJob = null
- onDrawerItemClick = null
- }
-
- override fun onCreateDrawerView(inflater: LayoutInflater) = ComposeWithMD3 {
- val localFavCount by vm.localFavCount.collectAsState(0)
- ElevatedCard {
- TopAppBar(title = { Text(text = stringResource(id = R.string.collections)) })
- val scope = currentRecomposeScope
- LaunchedEffect(Unit) {
- Settings.favChangesFlow.collect {
- scope.invalidate()
- }
- }
- val localFav = stringResource(id = R.string.local_favorites) to localFavCount
- val faves = if (EhCookieStore.hasSignedIn()) {
- arrayOf(
- localFav,
- stringResource(id = R.string.cloud_favorites) to Settings.favCloudCount,
- *Settings.favCat.zip(Settings.favCount.toTypedArray()).toTypedArray(),
- )
- } else {
- arrayOf(localFav)
- }
- Column(modifier = Modifier.verticalScroll(rememberScrollState())) {
- faves.forEachIndexed { index, (name, count) ->
- ListItem(
- headlineContent = { Text(text = name) },
- trailingContent = { Text(text = count.toString(), style = MaterialTheme.typography.bodyLarge) },
- modifier = Modifier.clickable { onDrawerItemClick?.invoke(index) },
- )
- }
- }
- }
- }
-
- override fun onSearchViewExpanded() {
- binding.fabLayout.hide()
- super.onSearchViewExpanded()
- }
-
- override fun onSearchViewHidden() {
- super.onSearchViewHidden()
- lifecycleScope.launch {
- delay(300)
- _binding ?: return@launch
- binding.fabLayout.show()
- }
- }
-
- private fun showGoToDialog() {
- context ?: return
- val local = LocalDateTime.of(2007, 3, 21, 0, 0)
- val fromDate = local.atZone(ZoneId.ofOffset("UTC", ZoneOffset.UTC)).toInstant().toEpochMilli()
- val toDate = MaterialDatePicker.todayInUtcMilliseconds()
- val listValidators = ArrayList()
- listValidators.add(DateValidatorPointForward.from(fromDate))
- listValidators.add(DateValidatorPointBackward.before(toDate))
- val constraintsBuilder = CalendarConstraints.Builder()
- .setStart(fromDate)
- .setEnd(toDate)
- .setValidator(CompositeDateValidator.allOf(listValidators))
- val datePicker = MaterialDatePicker.Builder.datePicker()
- .setCalendarConstraints(constraintsBuilder.build())
- .setTitleText(R.string.go_to)
- .setSelection(toDate)
- .build()
- datePicker.show(requireActivity().supportFragmentManager, "date-picker")
- datePicker.addOnPositiveButtonClickListener { time: Long ->
- val formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd", Locale.US).withZone(ZoneOffset.UTC)
- val jumpTo = formatter.format(Instant.ofEpochMilli(time))
- urlBuilder.jumpTo = jumpTo
- mAdapter?.refresh()
- }
- }
-
- private fun takeCheckedInfo() = tracker.getAndClearSelection()
-
- private fun checkedSize() = tracker.selectionSize
-}
diff --git a/app/src/main/kotlin/com/hippo/ehviewer/ui/scene/GalleryCommentsFragment.kt b/app/src/main/kotlin/com/hippo/ehviewer/ui/scene/GalleryCommentsFragment.kt
index 5adb0d3e6..e39a02a00 100644
--- a/app/src/main/kotlin/com/hippo/ehviewer/ui/scene/GalleryCommentsFragment.kt
+++ b/app/src/main/kotlin/com/hippo/ehviewer/ui/scene/GalleryCommentsFragment.kt
@@ -15,6 +15,7 @@
*/
package com.hippo.ehviewer.ui.scene
+import android.annotation.SuppressLint
import android.content.Context
import android.graphics.Typeface
import android.os.Bundle
@@ -75,6 +76,7 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.SolidColor
+import androidx.compose.ui.graphics.compositeOver
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.layout.layout
import androidx.compose.ui.layout.onGloballyPositioned
@@ -83,6 +85,7 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.platform.LocalTextToolbar
+import androidx.compose.ui.res.colorResource
import androidx.compose.ui.res.dimensionResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.TextFieldValue
@@ -176,9 +179,8 @@ fun GalleryCommentsScreen(galleryDetail: GalleryDetail, navigator: NavController
val dialogState = LocalDialogState.current
val coroutineScope = rememberCoroutineScope()
- val commentingBackField = rememberSaveable { mutableStateOf(false) }
- var commenting by commentingBackField
- val animationProgress by animateFloatMergePredictiveBackAsState(enable = commentingBackField)
+ var commenting by rememberSaveable { mutableStateOf(false) }
+ val animationProgress by animateFloatMergePredictiveBackAsState(enable = commenting) { commenting = false }
val userCommentBackField = remember { mutableStateOf(TextFieldValue()) }
var userComment by userCommentBackField
@@ -431,9 +433,15 @@ fun GalleryCommentsScreen(galleryDetail: GalleryDetail, navigator: NavController
}
}
}
+
+ @SuppressLint("PrivateResource")
+ val overlay = colorResource(com.google.android.material.R.color.m3_popupmenu_overlay_color)
+ val surface = MaterialTheme.colorScheme.surface
+ val background = overlay.compositeOver(surface)
PullToRefreshContainer(
state = refreshState,
modifier = Modifier.align(Alignment.TopCenter).padding(top = paddingValues.calculateTopPadding()),
+ containerColor = background,
)
Surface(
modifier = Modifier.align(Alignment.BottomCenter).layout { measurable, constraints ->
diff --git a/app/src/main/kotlin/com/hippo/ehviewer/ui/scene/GalleryDetailScene.kt b/app/src/main/kotlin/com/hippo/ehviewer/ui/scene/GalleryDetailScene.kt
index 0a8bdcb4b..dcf6f2f36 100644
--- a/app/src/main/kotlin/com/hippo/ehviewer/ui/scene/GalleryDetailScene.kt
+++ b/app/src/main/kotlin/com/hippo/ehviewer/ui/scene/GalleryDetailScene.kt
@@ -74,7 +74,6 @@ import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.windowsizeclass.WindowWidthSizeClass
import androidx.compose.material3.windowsizeclass.calculateWindowSizeClass
import androidx.compose.runtime.Composable
-import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
@@ -255,11 +254,8 @@ fun GalleryDetailScreen(args: GalleryDetailScreenArgs, navigator: NavController)
).setAction(R.string.read) { context.navToReader(gi.findBaseInfo(), page) }.show()
}
}
- DisposableEffect(galleryDetailUrl) {
- activity.shareUrl = galleryDetailUrl
- onDispose {
- activity.shareUrl = null
- }
+ with(activity) {
+ ProvideAssistContent(galleryDetailUrl)
}
var getDetailError by rememberSaveable { mutableStateOf("") }
val dialogState = LocalDialogState.current
diff --git a/app/src/main/kotlin/com/hippo/ehviewer/ui/scene/SearchBarScene.kt b/app/src/main/kotlin/com/hippo/ehviewer/ui/scene/SearchBarScene.kt
index 962418c75..0d5e26353 100644
--- a/app/src/main/kotlin/com/hippo/ehviewer/ui/scene/SearchBarScene.kt
+++ b/app/src/main/kotlin/com/hippo/ehviewer/ui/scene/SearchBarScene.kt
@@ -1,5 +1,6 @@
package com.hippo.ehviewer.ui.scene
+import android.annotation.SuppressLint
import android.os.Bundle
import android.os.Parcelable
import android.util.SparseArray
@@ -10,14 +11,21 @@ import androidx.annotation.CallSuper
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.asPaddingValues
+import androidx.compose.foundation.layout.displayCutoutPadding
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.ime
import androidx.compose.foundation.layout.navigationBars
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.safeDrawingPadding
+import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.layout.union
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
@@ -36,30 +44,37 @@ import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.ListItem
import androidx.compose.material3.ListItemDefaults
+import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SearchBar
+import androidx.compose.material3.SearchBarDefaults
import androidx.compose.material3.Text
+import androidx.compose.material3.pulltorefresh.PullToRefreshContainer
+import androidx.compose.material3.pulltorefresh.PullToRefreshState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
-import androidx.compose.runtime.MutableState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCompositionContext
import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.rotate
import androidx.compose.ui.draw.scale
import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.compositeOver
import androidx.compose.ui.layout.layout
import androidx.compose.ui.platform.ComposeView
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalWindowInfo
import androidx.compose.ui.platform.WindowInfo
+import androidx.compose.ui.res.colorResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.compose.ui.util.lerp
@@ -118,8 +133,9 @@ fun SearchBarScreen(
onApplySearch: (String) -> Unit,
onSearchExpanded: () -> Unit,
onSearchHidden: () -> Unit,
+ refreshState: PullToRefreshState? = null,
suggestionProvider: SuggestionProvider? = null,
- searchBarOffsetY: MutableState,
+ searchBarOffsetY: Int,
trailingIcon: @Composable () -> Unit,
content: @Composable (PaddingValues) -> Unit,
) {
@@ -231,102 +247,122 @@ fun SearchBarScreen(
}
}
- var searchbarOfs by searchBarOffsetY
- Scaffold(
- topBar = {
- SearchBar(
- modifier = Modifier.fillMaxWidth().layout { measurable, constraints ->
- val placeable = measurable.measure(constraints)
- layout(placeable.width, placeable.height) {
- placeable.placeRelative(0, searchbarOfs)
- }
- },
- state = searchFieldState,
- onSearch = {
- hideSearchView()
- onApplySearch()
- },
- active = active,
- onActiveChange = {
- if (it) {
- searchbarOfs = 0
- onSearchViewExpanded()
- } else {
- onSearchViewHidden()
- }
- active = it
- },
- title = title?.let { { Text(it) } },
- placeholder = searchFieldHint?.let { { Text(it) } },
- leadingIcon = {
- if (active) {
- IconButton(onClick = { hideSearchView() }) {
- Icon(Icons.AutoMirrored.Default.ArrowBack, contentDescription = null)
- }
- } else {
- IconButton(onClick = { activity.openDrawer() }) {
- Icon(Icons.Default.Menu, contentDescription = null)
- }
- }
- },
- trailingIcon = {
- if (active) {
- if (searchFieldState.text.isNotEmpty()) {
- IconButton(onClick = { searchFieldState.clearText() }) {
- Icon(Icons.Default.Close, contentDescription = null)
- }
- }
- } else {
- Row {
- trailingIcon()
- }
- }
- },
- ) {
- LazyColumn(
- contentPadding = WindowInsets.navigationBars.union(WindowInsets.ime)
- .asPaddingValues(),
+ val searchbarOfs by rememberUpdatedState(searchBarOffsetY)
+ val scrollAwayModifier = if (!active) {
+ Modifier.layout { measurable, constraints ->
+ val placeable = measurable.measure(constraints)
+ layout(placeable.width, placeable.height) {
+ placeable.placeRelative(0, searchbarOfs)
+ }
+ }
+ } else {
+ Modifier
+ }
+ Box(modifier = Modifier.fillMaxSize()) {
+ Scaffold(
+ topBar = {
+ // Placeholder, fill immutable SearchBar padding
+ Spacer(
+ modifier = Modifier.statusBarsPadding().displayCutoutPadding()
+ .height(SearchBarDefaults.InputFieldHeight + 16.dp),
+ )
+ },
+ floatingActionButton = {
+ val hiddenState by animateFloatAsState(
+ targetValue = if (showSearchFab) 1f else 0f,
+ animationSpec = tween(FAB_ANIMATE_TIME),
+ label = "hiddenState",
+ )
+ FloatingActionButton(
+ onClick = ::onApplySearch,
+ modifier = Modifier.rotate(lerp(90f, 0f, hiddenState)).scale(hiddenState),
) {
- items(mSuggestionList) {
- ListItem(
- headlineContent = { Text(text = it.keyword) },
- supportingContent = it.hint.ifNotNullThen { Text(text = it.hint!!) },
- leadingContent = it.canOpenDirectly.ifTrueThen {
- Icon(
- imageVector = Icons.AutoMirrored.Default.MenuBook,
- contentDescription = null,
- )
- },
- trailingContent = it.canDelete.ifTrueThen {
- IconButton(onClick = { deleteKeyword(it.keyword) }) {
- Icon(
- imageVector = Icons.Default.Close,
- contentDescription = null,
- )
- }
- },
- colors = ListItemDefaults.colors(containerColor = Color.Transparent),
- modifier = Modifier.clickable { it.onClick() },
- )
+ Icon(imageVector = Icons.Default.Search, contentDescription = null)
+ }
+ },
+ content = content,
+ )
+ if (refreshState != null) {
+ @SuppressLint("PrivateResource")
+ val overlay = colorResource(com.google.android.material.R.color.m3_popupmenu_overlay_color)
+ val surface = MaterialTheme.colorScheme.surface
+ val background = overlay.compositeOver(surface)
+ PullToRefreshContainer(
+ state = refreshState,
+ modifier = Modifier.align(Alignment.TopCenter).safeDrawingPadding()
+ .padding(top = 48.dp) then scrollAwayModifier,
+ containerColor = background,
+ )
+ }
+ SearchBar(
+ modifier = Modifier.fillMaxWidth().align(Alignment.TopCenter) then scrollAwayModifier,
+ state = searchFieldState,
+ onSearch = {
+ hideSearchView()
+ onApplySearch()
+ },
+ active = active,
+ onActiveChange = {
+ if (it) {
+ onSearchViewExpanded()
+ } else {
+ onSearchViewHidden()
+ }
+ active = it
+ },
+ title = title?.let { { Text(it) } },
+ placeholder = searchFieldHint?.let { { Text(it) } },
+ leadingIcon = {
+ if (active) {
+ IconButton(onClick = { hideSearchView() }) {
+ Icon(Icons.AutoMirrored.Default.ArrowBack, contentDescription = null)
+ }
+ } else {
+ IconButton(onClick = { activity.openDrawer() }) {
+ Icon(Icons.Default.Menu, contentDescription = null)
}
}
+ },
+ trailingIcon = {
+ if (active) {
+ if (searchFieldState.text.isNotEmpty()) {
+ IconButton(onClick = { searchFieldState.clearText() }) {
+ Icon(Icons.Default.Close, contentDescription = null)
+ }
+ }
+ } else {
+ Row {
+ trailingIcon()
+ }
+ }
+ },
+ ) {
+ LazyColumn(contentPadding = WindowInsets.navigationBars.union(WindowInsets.ime).asPaddingValues()) {
+ items(mSuggestionList) {
+ ListItem(
+ headlineContent = { Text(text = it.keyword) },
+ supportingContent = it.hint.ifNotNullThen { Text(text = it.hint!!) },
+ leadingContent = it.canOpenDirectly.ifTrueThen {
+ Icon(
+ imageVector = Icons.AutoMirrored.Default.MenuBook,
+ contentDescription = null,
+ )
+ },
+ trailingContent = it.canDelete.ifTrueThen {
+ IconButton(onClick = { deleteKeyword(it.keyword) }) {
+ Icon(
+ imageVector = Icons.Default.Close,
+ contentDescription = null,
+ )
+ }
+ },
+ colors = ListItemDefaults.colors(containerColor = Color.Transparent),
+ modifier = Modifier.clickable { it.onClick() },
+ )
+ }
}
- },
- floatingActionButton = {
- val hiddenState by animateFloatAsState(
- targetValue = if (showSearchFab) 1f else 0f,
- animationSpec = tween(FAB_ANIMATE_TIME),
- label = "hiddenState",
- )
- FloatingActionButton(
- onClick = ::onApplySearch,
- modifier = Modifier.rotate(lerp(90f, 0f, hiddenState)).scale(hiddenState),
- ) {
- Icon(imageVector = Icons.Default.Search, contentDescription = null)
- }
- },
- content = content,
- )
+ }
+ }
}
abstract class SearchBarScene : BaseScene() {
@@ -348,11 +384,10 @@ abstract class SearchBarScene : BaseScene() {
override val enableDrawerGestures = true
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
- val ofs = mutableIntStateOf(0)
+ var ofs by mutableIntStateOf(0)
val onScrollListener = object : OnScrollListener() {
override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
- var toChange by ofs
- toChange = (toChange - dy).coerceIn(-300, 0)
+ ofs = (ofs - dy).coerceIn(-300, 0)
}
}
return ComposeWithMD3 {
diff --git a/app/src/main/kotlin/com/hippo/ehviewer/ui/tools/LazyGrid.kt b/app/src/main/kotlin/com/hippo/ehviewer/ui/tools/LazyGrid.kt
index 68a22a7f6..5c33ae55e 100644
--- a/app/src/main/kotlin/com/hippo/ehviewer/ui/tools/LazyGrid.kt
+++ b/app/src/main/kotlin/com/hippo/ehviewer/ui/tools/LazyGrid.kt
@@ -7,10 +7,16 @@ import androidx.compose.foundation.lazy.grid.LazyGridScope
import androidx.compose.foundation.lazy.grid.LazyGridState
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.rememberLazyGridState
+import androidx.compose.foundation.lazy.staggeredgrid.LazyStaggeredGridScope
+import androidx.compose.foundation.lazy.staggeredgrid.LazyStaggeredGridState
+import androidx.compose.foundation.lazy.staggeredgrid.LazyVerticalStaggeredGrid
+import androidx.compose.foundation.lazy.staggeredgrid.StaggeredGridCells
+import androidx.compose.foundation.lazy.staggeredgrid.rememberLazyStaggeredGridState
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
@Composable
@@ -49,3 +55,40 @@ fun FastScrollLazyVerticalGrid(
)
}
}
+
+@Composable
+fun FastScrollLazyVerticalStaggeredGrid(
+ columns: StaggeredGridCells,
+ modifier: Modifier = Modifier,
+ state: LazyStaggeredGridState = rememberLazyStaggeredGridState(),
+ thumbAllowed: () -> Boolean = { true },
+ thumbColor: Color = MaterialTheme.colorScheme.primary,
+ contentPadding: PaddingValues = PaddingValues(0.dp),
+ reverseLayout: Boolean = false,
+ verticalItemSpacing: Dp = 0.dp,
+ horizontalArrangement: Arrangement.Horizontal = Arrangement.Start,
+ userScrollEnabled: Boolean = true,
+ content: LazyStaggeredGridScope.() -> Unit,
+) {
+ VerticalStaggeredGridFastScroller(
+ state = state,
+ columns = columns,
+ arrangement = horizontalArrangement,
+ contentPadding = contentPadding,
+ thumbAllowed = thumbAllowed,
+ thumbColor = thumbColor,
+ topContentPadding = contentPadding.calculateTopPadding(),
+ ) {
+ LazyVerticalStaggeredGrid(
+ columns = columns,
+ modifier = modifier,
+ state = state,
+ contentPadding = contentPadding,
+ reverseLayout = reverseLayout,
+ verticalItemSpacing = verticalItemSpacing,
+ horizontalArrangement = horizontalArrangement,
+ userScrollEnabled = userScrollEnabled,
+ content = content,
+ )
+ }
+}
diff --git a/app/src/main/kotlin/com/hippo/ehviewer/ui/tools/PredictiveBack.kt b/app/src/main/kotlin/com/hippo/ehviewer/ui/tools/PredictiveBack.kt
index 280aa254d..9727cf9f0 100644
--- a/app/src/main/kotlin/com/hippo/ehviewer/ui/tools/PredictiveBack.kt
+++ b/app/src/main/kotlin/com/hippo/ehviewer/ui/tools/PredictiveBack.kt
@@ -5,7 +5,6 @@ import androidx.compose.animation.core.AnimationSpec
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.spring
import androidx.compose.runtime.Composable
-import androidx.compose.runtime.MutableState
import androidx.compose.runtime.State
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
@@ -30,15 +29,13 @@ import kotlinx.coroutines.CancellationException
*/
@Composable
fun animateFloatMergePredictiveBackAsState(
- enable: MutableState,
+ enable: Boolean,
animationSpec: AnimationSpec = spring(),
+ onBack: () -> Unit,
): State {
- // Principal Value
- var principal by enable
-
// Natural animator state correspond with principal value
val coState by animateFloatAsState(
- targetValue = if (principal) 0f else 1f,
+ targetValue = if (enable) 0f else 1f,
animationSpec = animationSpec,
label = "animationProgress",
)
@@ -46,16 +43,15 @@ fun animateFloatMergePredictiveBackAsState(
val ret = remember { mutableFloatStateOf(1F) }
var animationProgress by ret
// Update UI animation state
- animationProgress = if (principal || !isAtLeastU) coState else max(animationProgress, coState)
+ animationProgress = if (enable || !isAtLeastU) coState else max(animationProgress, coState)
- PredictiveBackHandler(principal) { progress ->
+ PredictiveBackHandler(enable) { progress ->
try {
progress.collect {
animationProgress = it.progress
}
- principal = false
+ onBack()
} catch (e: CancellationException) {
- principal = true
animationProgress = 0F
}
}
diff --git a/app/src/main/kotlin/com/hippo/ehviewer/ui/tools/VerticalFastScroller.kt b/app/src/main/kotlin/com/hippo/ehviewer/ui/tools/VerticalFastScroller.kt
index c00110652..953759843 100644
--- a/app/src/main/kotlin/com/hippo/ehviewer/ui/tools/VerticalFastScroller.kt
+++ b/app/src/main/kotlin/com/hippo/ehviewer/ui/tools/VerticalFastScroller.kt
@@ -22,6 +22,8 @@ import androidx.compose.foundation.lazy.LazyListItemInfo
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyGridState
+import androidx.compose.foundation.lazy.staggeredgrid.LazyStaggeredGridState
+import androidx.compose.foundation.lazy.staggeredgrid.StaggeredGridCells
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.systemGestureExclusion
import androidx.compose.material3.MaterialTheme
@@ -218,6 +220,37 @@ private fun rememberColumnWidthSums(
}
}
+@Composable
+private fun rememberColumnWidthSums(
+ columns: StaggeredGridCells,
+ horizontalArrangement: Arrangement.Horizontal,
+ contentPadding: PaddingValues,
+) = remember List>(
+ columns,
+ horizontalArrangement,
+ contentPadding,
+) {
+ {
+ constraints ->
+ require(constraints.maxWidth != Constraints.Infinity) {
+ "LazyVerticalGrid's width should be bound by parent"
+ }
+ val horizontalPadding = contentPadding.calculateStartPadding(LayoutDirection.Ltr) +
+ contentPadding.calculateEndPadding(LayoutDirection.Ltr)
+ val gridWidth = constraints.maxWidth - horizontalPadding.roundToPx()
+ with(columns) {
+ calculateCrossAxisCellSizes(
+ gridWidth,
+ horizontalArrangement.spacing.roundToPx(),
+ ).toMutableList().apply {
+ for (i in 1.. Boolean = { true },
+ thumbColor: Color = MaterialTheme.colorScheme.primary,
+ topContentPadding: Dp = Dp.Hairline,
+ bottomContentPadding: Dp = Dp.Hairline,
+ endContentPadding: Dp = Dp.Hairline,
+ content: @Composable () -> Unit,
+) {
+ val slotSizesSums = rememberColumnWidthSums(
+ columns = columns,
+ horizontalArrangement = arrangement,
+ contentPadding = contentPadding,
+ )
+
+ SubcomposeLayout(modifier = modifier) { constraints ->
+ val contentPlaceable = subcompose("content", content).map { it.measure(constraints) }
+ val contentHeight = contentPlaceable.fastMaxBy { it.height }?.height ?: 0
+ val contentWidth = contentPlaceable.fastMaxBy { it.width }?.width ?: 0
+
+ val scrollerConstraints = constraints.copy(minWidth = 0, minHeight = 0)
+ val scrollerPlaceable = subcompose("scroller") {
+ val layoutInfo = state.layoutInfo
+ val showScroller = layoutInfo.visibleItemsInfo.size < layoutInfo.totalItemsCount
+ if (!showScroller) return@subcompose
+ val thumbTopPadding = with(LocalDensity.current) { topContentPadding.toPx() }
+ var thumbOffsetY by remember(thumbTopPadding) { mutableFloatStateOf(thumbTopPadding) }
+
+ val dragInteractionSource = remember { MutableInteractionSource() }
+ val isThumbDragged by dragInteractionSource.collectIsDraggedAsState()
+ val scrolled = remember {
+ MutableSharedFlow(
+ extraBufferCapacity = 1,
+ onBufferOverflow = BufferOverflow.DROP_OLDEST,
+ )
+ }
+
+ val thumbBottomPadding = with(LocalDensity.current) { bottomContentPadding.toPx() }
+ val heightPx = contentHeight.toFloat() - thumbTopPadding - thumbBottomPadding - state.layoutInfo.afterContentPadding
+ val thumbHeightPx = with(LocalDensity.current) { ThumbLength.toPx() }
+ val trackHeightPx = heightPx - thumbHeightPx
+
+ val columnCount = remember { slotSizesSums(constraints).size }
+
+ // When thumb dragged
+ LaunchedEffect(thumbOffsetY) {
+ if (layoutInfo.totalItemsCount == 0 || !isThumbDragged) return@LaunchedEffect
+ val scrollRatio = (thumbOffsetY - thumbTopPadding) / trackHeightPx
+ val scrollItem = layoutInfo.totalItemsCount * scrollRatio
+ // I can't think of anything else rn but this'll do
+ val scrollItemWhole = scrollItem.toInt()
+ val columnNum = ((scrollItemWhole + 1) % columnCount).takeIf { it != 0 } ?: columnCount
+ val scrollItemFraction = if (scrollItemWhole == 0) scrollItem else scrollItem % scrollItemWhole
+ val offsetPerItem = 1f / columnCount
+ val offsetRatio = (offsetPerItem * scrollItemFraction) + (offsetPerItem * (columnNum - 1))
+
+ // TODO: Sometimes item height is not available when scrolling up
+ val scrollItemSize = (1..columnCount).maxOf { num ->
+ val actualIndex = if (num != columnNum) {
+ scrollItemWhole + num - columnCount
+ } else {
+ scrollItemWhole
+ }
+ layoutInfo.visibleItemsInfo.find { it.index == actualIndex }?.size?.height ?: 0
+ }
+ val scrollItemOffset = scrollItemSize * offsetRatio
+
+ state.scrollToItem(index = scrollItemWhole, scrollOffset = scrollItemOffset.roundToInt())
+ scrolled.tryEmit(Unit)
+ }
+
+ // When list scrolled
+ LaunchedEffect(state.firstVisibleItemScrollOffset) {
+ if (state.layoutInfo.totalItemsCount == 0 || isThumbDragged) return@LaunchedEffect
+ val scrollOffset = computeScrollOffset(state = state)
+ val scrollRange = computeScrollRange(state = state)
+ val proportion = scrollOffset.toFloat() / (scrollRange.toFloat() - heightPx)
+ thumbOffsetY = trackHeightPx * proportion + thumbTopPadding
+ scrolled.tryEmit(Unit)
+ }
+
+ // Thumb alpha
+ val alpha = remember { Animatable(0f) }
+ val isThumbVisible = alpha.value > 0f
+ LaunchedEffect(scrolled, alpha) {
+ scrolled
+ .sample(100)
+ .collectLatest {
+ if (thumbAllowed()) {
+ alpha.snapTo(1f)
+ alpha.animateTo(0f, animationSpec = FadeOutAnimationSpec)
+ } else {
+ alpha.animateTo(0f, animationSpec = ImmediateFadeOutAnimationSpec)
+ }
+ }
+ }
+
+ Box(
+ modifier = Modifier
+ .offset { IntOffset(0, thumbOffsetY.roundToInt()) }
+ .then(
+ // Recompose opts
+ if (isThumbVisible && !state.isScrollInProgress) {
+ Modifier.draggable(
+ interactionSource = dragInteractionSource,
+ orientation = Orientation.Vertical,
+ state = rememberDraggableState { delta ->
+ val newOffsetY = thumbOffsetY + delta
+ thumbOffsetY = newOffsetY.coerceIn(
+ thumbTopPadding,
+ thumbTopPadding + trackHeightPx,
+ )
+ },
+ )
+ } else {
+ Modifier
+ },
+ )
+ .then(
+ // Exclude thumb from gesture area only when needed
+ if (isThumbVisible && !isThumbDragged && !state.isScrollInProgress) {
+ Modifier.systemGestureExclusion()
+ } else {
+ Modifier
+ },
+ )
+ .height(ThumbLength)
+ .padding(horizontal = 8.dp)
+ .padding(end = endContentPadding)
+ .width(ThumbThickness)
+ .alpha(alpha.value)
+ .background(color = thumbColor, shape = ThumbShape),
+ )
+ }.map { it.measure(scrollerConstraints) }
+ val scrollerWidth = scrollerPlaceable.fastMaxBy { it.width }?.width ?: 0
+
+ layout(contentWidth, contentHeight) {
+ contentPlaceable.fastForEach {
+ it.place(0, 0)
+ }
+ scrollerPlaceable.fastForEach {
+ it.placeRelative(contentWidth - scrollerWidth, 0)
+ }
+ }
+ }
+}
+
private fun computeScrollOffset(state: LazyGridState): Int {
if (state.layoutInfo.totalItemsCount == 0) return 0
val visibleItems = state.layoutInfo.visibleItemsInfo
@@ -385,6 +570,21 @@ private fun computeScrollOffset(state: LazyGridState): Int {
return (itemsBefore * avgSizePerRow + (0 - startDecoratedTop)).roundToInt()
}
+private fun computeScrollOffset(state: LazyStaggeredGridState): Int {
+ if (state.layoutInfo.totalItemsCount == 0) return 0
+ val visibleItems = state.layoutInfo.visibleItemsInfo
+ val startChild = visibleItems.first()
+ val endChild = visibleItems.last()
+ val minPosition = min(startChild.index, endChild.index)
+ val maxPosition = max(startChild.index, endChild.index)
+ val itemsBefore = minPosition.coerceAtLeast(0)
+ val startDecoratedTop = startChild.offset.y
+ val laidOutArea = abs((endChild.offset.y + endChild.size.height) - startDecoratedTop)
+ val itemRange = abs(minPosition - maxPosition) + 1
+ val avgSizePerRow = laidOutArea.toFloat() / itemRange
+ return (itemsBefore * avgSizePerRow + (0 - startDecoratedTop)).roundToInt()
+}
+
private fun computeScrollRange(state: LazyGridState): Int {
if (state.layoutInfo.totalItemsCount == 0) return 0
val visibleItems = state.layoutInfo.visibleItemsInfo
@@ -395,6 +595,16 @@ private fun computeScrollRange(state: LazyGridState): Int {
return (laidOutArea.toFloat() / laidOutRange * state.layoutInfo.totalItemsCount).roundToInt()
}
+private fun computeScrollRange(state: LazyStaggeredGridState): Int {
+ if (state.layoutInfo.totalItemsCount == 0) return 0
+ val visibleItems = state.layoutInfo.visibleItemsInfo
+ val startChild = visibleItems.first()
+ val endChild = visibleItems.last()
+ val laidOutArea = (endChild.offset.y + endChild.size.height) - startChild.offset.y
+ val laidOutRange = abs(startChild.index - endChild.index) + 1
+ return (laidOutArea.toFloat() / laidOutRange * state.layoutInfo.totalItemsCount).roundToInt()
+}
+
private fun computeScrollOffset(state: LazyListState): Int {
if (state.layoutInfo.totalItemsCount == 0) return 0
val visibleItems = state.layoutInfo.visibleItemsInfo
diff --git a/app/src/main/res/navigation/nav_graph.xml b/app/src/main/res/navigation/nav_graph.xml
index 48ad7573a..0b51a231b 100644
--- a/app/src/main/res/navigation/nav_graph.xml
+++ b/app/src/main/res/navigation/nav_graph.xml
@@ -57,7 +57,7 @@
android:label="GalleryPreviewsScene" />