Compose FavouriteScreen (#295)

* Restore FavouriteScene SideSheet dragToOpen

* Initial move to Compose FavScene

* Refine state

* Refine state

* Introduce ProvideSideSheetContent with extra checkers

* Make sideSheet activity private

* Dont allow provide a null AssistContent

* Restore trailingIcon to openSideSheet

* Refine

* Cleanup

* Implement searchBarConnection

* Reduce allocation for animation

* Initial compose FabLayout API

* Restore showGoToDialog

* Refine

* Refine

* Refine

* PullTORefresh

* PullTORefresh

* Refine Dsl

* Insetless placeholder

* let pullToRefresh use insetless padding as well

* Support drawer gestures

* Fix tip color and ripple

* Make pullToRefresh optional

* Composite container color

* Fix padding

* Fix misadd

* Fix gesture

* Fixes

---------

Co-authored-by: Lokesh Gidra <lokeshgidra@google.com>
This commit is contained in:
Vladimir DostoyevskiyandLokesh Gidra authored and GitHub committed 2023-11-20 07:09:35 +00:00
1 parent 4eed03ebed
commit bac60fbec8
17 files changed
+1251 -704

No files matched your search

@@ -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(
}
}
/**
* <a href="https://m3.material.io/components/navigation-drawer/overview" class="external" target="_blank">Material Design navigation drawer</a>.
*
* 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.
*
* ![Navigation drawer image](https://developer.android.com/images/reference/androidx/compose/material3/navigation-drawer.png)
*
* @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()
}
@@ -130,7 +130,7 @@ object EhDB {
deleteGalleryInfo(galleryInfo)
}
suspend fun removeLocalFavorites(galleryInfoList: List<BaseGalleryInfo>) {
suspend fun removeLocalFavorites(galleryInfoList: Collection<BaseGalleryInfo>) {
galleryInfoList.forEach {
removeLocalFavorites(it)
}
@@ -146,7 +146,7 @@ object EhDB {
db.localFavoritesDao().upsert(LocalFavoriteInfo(galleryInfo.gid))
}
suspend fun putLocalFavorites(galleryInfoList: List<BaseGalleryInfo>) {
suspend fun putLocalFavorites(galleryInfoList: Collection<BaseGalleryInfo>) {
galleryInfoList.forEach {
putLocalFavorites(it)
}
@@ -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<Boolean>
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)
@@ -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()
@@ -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<Unit>(extraBufferCapacity = 1)
private var openSideSheetFlow = MutableSharedFlow<Unit>(extraBufferCapacity = 1)
private var recomposeFlow = MutableSharedFlow<Unit>(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<NavHostFragment>()
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<NavHostFragment>()
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) }
@@ -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<OnExpandStateListener>()
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),
@@ -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)
}
}
}
}
}
}
@@ -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) }
}
@@ -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<MainActivity>() }
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<String, BaseGalleryInfo>() {
override fun getRefreshKey(state: PagingState<String, BaseGalleryInfo>): String? = null
override suspend fun load(params: LoadParams<String>) = 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<Long, BaseGalleryInfo>() }
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<CalendarConstraints.DateValidator>()
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)
}
}
}
@@ -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<String, BaseGalleryInfo>() {
override fun getRefreshKey(state: PagingState<String, BaseGalleryInfo>): String? = null
override suspend fun load(params: LoadParams<String>) = 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<SecondaryFab>(
// 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<DateValidator>()
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
}
@@ -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 ->
@@ -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
@@ -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<Int>,
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 {
@@ -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,
)
}
}
@@ -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<Boolean>,
enable: Boolean,
animationSpec: AnimationSpec<Float> = spring(),
onBack: () -> Unit,
): State<Float> {
// 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
}
}
@@ -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<Density.(Constraints) -> List<Int>>(
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..<size) {
this[i] += this[i - 1]
}
}
}
}
}
@Composable
fun VerticalGridFastScroller(
state: LazyGridState,
@@ -370,6 +403,158 @@ fun VerticalGridFastScroller(
}
}
@Composable
fun VerticalStaggeredGridFastScroller(
state: LazyStaggeredGridState,
columns: StaggeredGridCells,
arrangement: Arrangement.Horizontal,
contentPadding: PaddingValues,
modifier: Modifier = Modifier,
thumbAllowed: () -> 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<Unit>(
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
+1 -1
View File
@@ -57,7 +57,7 @@
android:label="GalleryPreviewsScene" />
<fragment
android:id="@+id/nav_favourite"
android:name="com.hippo.ehviewer.ui.scene.FavoritesScene"
android:name="com.hippo.ehviewer.ui.scene.FavoritesFragment"
android:label="FavoritesScene" />
<fragment
android:id="@+id/nav_history"