diff --git a/app/src/main/kotlin/com/hippo/ehviewer/ui/main/GalleryList.kt b/app/src/main/kotlin/com/hippo/ehviewer/ui/main/GalleryList.kt index 8130865624..c7b6d37ad4 100644 --- a/app/src/main/kotlin/com/hippo/ehviewer/ui/main/GalleryList.kt +++ b/app/src/main/kotlin/com/hippo/ehviewer/ui/main/GalleryList.kt @@ -7,7 +7,6 @@ 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.offset import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.widthIn @@ -44,7 +43,6 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.res.dimensionResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.dp import androidx.paging.LoadState @@ -90,7 +88,6 @@ fun GalleryList( detailItemContent: @Composable (LazyGridItemScope.(BaseGalleryInfo) -> Unit), thumbListState: LazyStaggeredGridState = rememberLazyStaggeredGridState(), thumbItemContent: @Composable (LazyStaggeredGridItemScope.(BaseGalleryInfo) -> Unit), - searchBarOffsetY: () -> Int, scrollToTopOnRefresh: Boolean = true, onRefresh: () -> Unit, onLoading: () -> Unit, @@ -220,12 +217,10 @@ fun GalleryList( ErrorTip(modifier = Modifier.widthIn(max = 228.dp), text = stringResource(id = R.string.gallery_list_empty_hit)) } } - PullToRefreshDefaults.LoadingIndicator( state = refreshState, isRefreshing = isRefreshing, - modifier = Modifier.align(Alignment.TopCenter).padding(top = contentPadding.calculateTopPadding()) - .offset { IntOffset(0, searchBarOffsetY()) }, + modifier = Modifier.align(Alignment.TopCenter).padding(top = contentPadding.calculateTopPadding()), ) } } diff --git a/app/src/main/kotlin/com/hippo/ehviewer/ui/screen/DownloadsScreen.kt b/app/src/main/kotlin/com/hippo/ehviewer/ui/screen/DownloadsScreen.kt index 109de65620..9d59db4c3e 100644 --- a/app/src/main/kotlin/com/hippo/ehviewer/ui/screen/DownloadsScreen.kt +++ b/app/src/main/kotlin/com/hippo/ehviewer/ui/screen/DownloadsScreen.kt @@ -53,12 +53,14 @@ import androidx.compose.material3.IconButton import androidx.compose.material3.IconButtonDefaults import androidx.compose.material3.ListItem import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.SearchBarDefaults import androidx.compose.material3.SwipeToDismissBoxDefaults import androidx.compose.material3.SwipeToDismissBoxValue import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.material3.fork.SwipeToDismissBox import androidx.compose.material3.fork.SwipeToDismissBoxState +import androidx.compose.material3.rememberSearchBarState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState @@ -76,11 +78,7 @@ 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.LocalDensity import androidx.compose.ui.res.dimensionResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp @@ -135,7 +133,6 @@ import com.hippo.ehviewer.ui.tools.awaitSingleChoice import com.ramcosta.composedestinations.annotation.Destination import com.ramcosta.composedestinations.annotation.RootGraph import com.ramcosta.composedestinations.navigation.DestinationsNavigator -import kotlin.math.roundToInt import kotlinx.coroutines.delay import moe.tarsin.navigate import sh.calvin.reorderable.ReorderableItem @@ -151,17 +148,16 @@ fun AnimatedVisibilityScope.DownloadsScreen(navigator: DestinationsNavigator) = var filterState by rememberSerializable { mutableStateOf(DownloadsFilterState(filterMode, Settings.recentDownloadLabel.value)) } var invalidateKey by rememberSaveable { mutableStateOf(false) } var isLoading by rememberSaveable { mutableStateOf(true) } - var searchBarExpanded by rememberSaveable { mutableStateOf(false) } - var searchBarOffsetY by remember { mutableIntStateOf(0) } + val searchBarState = rememberSearchBarState() + val scrollBehavior = SearchBarDefaults.enterAlwaysSearchBarScrollBehavior() val animateItems by Settings.animateItems.collectAsState() var fabExpanded by remember { mutableStateOf(false) } var fabHidden by remember { mutableStateOf(false) } val checkedInfoMap = remember { mutableStateMapOf() } val selectMode by rememberUpdatedState(checkedInfoMap.isNotEmpty()) - DrawerHandle(!selectMode && !searchBarExpanded) + DrawerHandle(!selectMode && !searchBarState.expanded) - val density = LocalDensity.current val canTranslate = Settings.showTagTranslations.value && EhTagDatabase.translatable && EhTagDatabase.initialized val ehTags = EhTagDatabase.takeIf { canTranslate } fun getTranslation(tag: String) = ehTags?.run { @@ -234,7 +230,7 @@ fun AnimatedVisibilityScope.DownloadsScreen(navigator: DestinationsNavigator) = } LaunchedEffect(filterState) { - searchBarOffsetY = 0 + scrollBehavior.reset() } ProvideSideSheetContent { drawerState -> @@ -471,18 +467,18 @@ fun AnimatedVisibilityScope.DownloadsScreen(navigator: DestinationsNavigator) = override val keyword = LABEL_PREFIX + label override fun onClick() { searchFieldState.setTextAndPlaceCursorAtEnd(keyword) - searchBarExpanded = false + launch { searchBarState.animateToCollapsed() } switchLabel(label) } } + searchBarState.CollectExpanded { + fabHidden = it + if (it) checkedInfoMap.clear() + } SearchBarScreen( onApplySearch = { filterState = filterState.copy(keyword = it) }, - expanded = searchBarExpanded, - onExpandedChange = { - searchBarExpanded = it - fabHidden = it - if (it) checkedInfoMap.clear() - }, + searchBarState = searchBarState, + scrollBehavior = scrollBehavior, title = title, searchFieldHint = hint, searchFieldState = searchFieldState, @@ -494,7 +490,6 @@ fun AnimatedVisibilityScope.DownloadsScreen(navigator: DestinationsNavigator) = EhDB.searchDownloadLabel(label, 10).map(::DownloadLabelSuggestion) } }, - searchBarOffsetY = { searchBarOffsetY }, trailingIcon = { var expanded by remember { mutableStateOf(false) } val sideSheetState = LocalSideSheetState.current @@ -557,19 +552,13 @@ fun AnimatedVisibilityScope.DownloadsScreen(navigator: DestinationsNavigator) = ) { contentPadding -> val height by collectListThumbSizeAsState() val realPadding = contentPadding + PaddingValues(dimensionResource(id = com.hippo.ehviewer.R.dimen.gallery_list_margin_h), dimensionResource(id = com.hippo.ehviewer.R.dimen.gallery_list_margin_v)) - val searchBarConnection = remember { + val connection = remember { val slop = ViewConfiguration.get(contextOf()).scaledTouchSlop - val topPaddingPx = with(density) { contentPadding.calculateTopPadding().roundToPx() } - object : NestedScrollConnection { - override fun onPostScroll(consumed: Offset, available: Offset, source: NestedScrollSource): Offset { - val dy = -consumed.y - if (dy >= slop) { - fabHidden = true - } else if (dy <= -slop / 2) { - fabHidden = false - } - searchBarOffsetY = (searchBarOffsetY - dy).roundToInt().coerceIn(-topPaddingPx, 0) - return Offset.Zero // We never consume it + scrollBehavior.nestedScrollConnection.watchPostScroll { (_, y) -> + if (-y >= slop) { + fabHidden = true + } else if (-y <= -slop / 2) { + fabHidden = false } } } @@ -585,7 +574,7 @@ fun AnimatedVisibilityScope.DownloadsScreen(navigator: DestinationsNavigator) = val thumbColumns by Settings.thumbColumns.collectAsState() FastScrollLazyVerticalStaggeredGrid( columns = StaggeredGridCells.Fixed(thumbColumns), - modifier = Modifier.nestedScroll(searchBarConnection).fillMaxSize(), + modifier = Modifier.nestedScroll(connection).fillMaxSize(), contentPadding = realPadding, verticalItemSpacing = gridInterval, horizontalArrangement = Arrangement.spacedBy(gridInterval), @@ -603,7 +592,7 @@ fun AnimatedVisibilityScope.DownloadsScreen(navigator: DestinationsNavigator) = } } else { FastScrollLazyColumn( - modifier = Modifier.nestedScroll(searchBarConnection).fillMaxSize(), + modifier = Modifier.nestedScroll(connection).fillMaxSize(), contentPadding = realPadding, verticalArrangement = Arrangement.spacedBy(dimensionResource(com.hippo.ehviewer.R.dimen.gallery_list_interval)), ) { diff --git a/app/src/main/kotlin/com/hippo/ehviewer/ui/screen/FavoritesScreen.kt b/app/src/main/kotlin/com/hippo/ehviewer/ui/screen/FavoritesScreen.kt index c989a0446b..b7bd2b849b 100644 --- a/app/src/main/kotlin/com/hippo/ehviewer/ui/screen/FavoritesScreen.kt +++ b/app/src/main/kotlin/com/hippo/ehviewer/ui/screen/FavoritesScreen.kt @@ -29,26 +29,22 @@ import androidx.compose.material3.IconButton import androidx.compose.material3.IconButtonDefaults import androidx.compose.material3.ListItem import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.SearchBarDefaults import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar +import androidx.compose.material3.rememberSearchBarState 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.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateMapOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue 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.LocalDensity import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel @@ -86,7 +82,6 @@ import com.hippo.ehviewer.ui.tools.awaitSelectItem import com.ramcosta.composedestinations.annotation.Destination import com.ramcosta.composedestinations.annotation.RootGraph import com.ramcosta.composedestinations.navigation.DestinationsNavigator -import kotlin.math.roundToInt import kotlinx.coroutines.delay import moe.tarsin.coroutines.runSwallowingWithUI import moe.tarsin.navigate @@ -102,8 +97,8 @@ fun AnimatedVisibilityScope.FavouritesScreen(navigator: DestinationsNavigator, v // Meta State var urlBuilder by viewModel.urlBuilder - var searchBarExpanded by rememberSaveable { mutableStateOf(false) } - var searchBarOffsetY by remember { mutableIntStateOf(0) } + val searchBarState = rememberSearchBarState() + val scrollBehavior = SearchBarDefaults.enterAlwaysSearchBarScrollBehavior() var fabExpanded by remember { mutableStateOf(false) } var fabHidden by remember { mutableStateOf(false) } @@ -112,7 +107,7 @@ fun AnimatedVisibilityScope.FavouritesScreen(navigator: DestinationsNavigator, v val favCatName = remember(urlBuilder) { when (val favCat = urlBuilder.favCat) { in 0..9 -> Settings.favCat[favCat] - FavListUrlBuilder.FAV_CAT_LOCAL -> localFavName.also { searchBarOffsetY = 0 } + FavListUrlBuilder.FAV_CAT_LOCAL -> localFavName.also { scrollBehavior.reset() } else -> cloudFavName } } @@ -121,7 +116,6 @@ fun AnimatedVisibilityScope.FavouritesScreen(navigator: DestinationsNavigator, v } else { stringResource(R.string.favorites_title_2, favCatName, keyword) } - val density = LocalDensity.current val searchBarHint = stringResource(R.string.search_bar_hint, favCatName) val data = viewModel.data.collectAsLazyPagingItems() @@ -176,20 +170,20 @@ fun AnimatedVisibilityScope.FavouritesScreen(navigator: DestinationsNavigator, v val checkedInfoMap = remember { mutableStateMapOf() } val selectMode = checkedInfoMap.isNotEmpty() - DrawerHandle(!selectMode && !searchBarExpanded) + DrawerHandle(!selectMode && !searchBarState.expanded) + + searchBarState.CollectExpanded { + fabHidden = it + if (it) checkedInfoMap.clear() + } SearchBarScreen( onApplySearch = { refresh(FavListUrlBuilder(urlBuilder.favCat, it)) }, - expanded = searchBarExpanded, - onExpandedChange = { - searchBarExpanded = it - fabHidden = it - if (it) checkedInfoMap.clear() - }, + searchBarState = searchBarState, + scrollBehavior = scrollBehavior, title = title, searchFieldHint = searchBarHint, localSearch = urlBuilder.isLocal, - searchBarOffsetY = { searchBarOffsetY }, trailingIcon = { val sheetState = LocalSideSheetState.current IconButton(onClick = { launch { sheetState.open() } }, shapes = IconButtonDefaults.shapes()) { @@ -202,25 +196,19 @@ fun AnimatedVisibilityScope.FavouritesScreen(navigator: DestinationsNavigator, v val height by collectListThumbSizeAsState() val showPages by Settings.showGalleryPages.collectAsState() val showProgress by Settings.showReadingProgress.collectAsState() - val searchBarConnection = remember { + val connection = remember { val slop = ViewConfiguration.get(contextOf()).scaledTouchSlop - val topPaddingPx = with(density) { contentPadding.calculateTopPadding().roundToPx() } - object : NestedScrollConnection { - override fun onPostScroll(consumed: Offset, available: Offset, source: NestedScrollSource): Offset { - val dy = -consumed.y - if (dy >= slop) { - fabHidden = true - } else if (dy <= -slop / 2) { - fabHidden = false - } - searchBarOffsetY = (searchBarOffsetY - dy).roundToInt().coerceIn(-topPaddingPx, 0) - return Offset.Zero // We never consume it + scrollBehavior.nestedScrollConnection.watchPostScroll { (_, y) -> + if (-y >= slop) { + fabHidden = true + } else if (-y <= -slop / 2) { + fabHidden = false } } } GalleryList( data = data, - contentModifier = Modifier.nestedScroll(searchBarConnection), + contentModifier = Modifier.nestedScroll(connection), contentPadding = contentPadding, listMode = listMode, detailItemContent = { info -> @@ -282,10 +270,9 @@ fun AnimatedVisibilityScope.FavouritesScreen(navigator: DestinationsNavigator, v ) } }, - searchBarOffsetY = { searchBarOffsetY }, scrollToTopOnRefresh = urlBuilder.favCat != FavListUrlBuilder.FAV_CAT_LOCAL, onRefresh = { refresh() }, - onLoading = { searchBarOffsetY = 0 }, + onLoading = { scrollBehavior.reset() }, ) } diff --git a/app/src/main/kotlin/com/hippo/ehviewer/ui/screen/GalleryListScreen.kt b/app/src/main/kotlin/com/hippo/ehviewer/ui/screen/GalleryListScreen.kt index e182601b81..276426087a 100644 --- a/app/src/main/kotlin/com/hippo/ehviewer/ui/screen/GalleryListScreen.kt +++ b/app/src/main/kotlin/com/hippo/ehviewer/ui/screen/GalleryListScreen.kt @@ -34,12 +34,14 @@ import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.IconButtonDefaults import androidx.compose.material3.ListItem +import androidx.compose.material3.SearchBarDefaults import androidx.compose.material3.SwipeToDismissBoxDefaults import androidx.compose.material3.SwipeToDismissBoxValue import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.material3.fork.SwipeToDismissBox import androidx.compose.material3.fork.SwipeToDismissBoxState +import androidx.compose.material3.rememberSearchBarState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.Stable @@ -49,16 +51,11 @@ import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberUpdatedState -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.LocalDensity import androidx.compose.ui.res.stringArrayResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp @@ -124,7 +121,6 @@ import com.ramcosta.composedestinations.annotation.Destination import com.ramcosta.composedestinations.annotation.RootGraph import com.ramcosta.composedestinations.navigation.DestinationsNavigator import com.ramcosta.composedestinations.spec.Direction -import kotlin.math.roundToInt import kotlin.random.Random import kotlinx.coroutines.delay import moe.tarsin.navigate @@ -158,8 +154,8 @@ fun AnimatedVisibilityScope.GalleryListScreen( ) = Screen(navigator) { val searchFieldState = rememberTextFieldState() var urlBuilder by viewModel.urlBuilder - var searchBarExpanded by rememberSaveable { mutableStateOf(false) } - var searchBarOffsetY by remember { mutableIntStateOf(0) } + val searchBarState = rememberSearchBarState() + val scrollBehavior = SearchBarDefaults.enterAlwaysSearchBarScrollBehavior() var fabExpanded by remember { mutableStateOf(false) } var fabHidden by remember { mutableStateOf(false) } @@ -168,7 +164,7 @@ fun AnimatedVisibilityScope.GalleryListScreen( var category by rememberMutableStateInDataStore("SearchCategory") { EhUtils.ALL_CATEGORY } var advancedSearchOption by rememberMutableStateInDataStore("AdvancedSearchOption") { AdvancedSearchOption() } - DrawerHandle(!searchBarExpanded) + DrawerHandle(!searchBarState.expanded) LaunchedEffect(urlBuilder) { if (urlBuilder.category != EhUtils.NONE) category = urlBuilder.category @@ -179,7 +175,6 @@ fun AnimatedVisibilityScope.GalleryListScreen( searchFieldState.setTextAndPlaceCursorAtEnd(keyword) } - val density = LocalDensity.current val positionalThreshold = SwipeToDismissBoxDefaults.positionalThreshold val listState = rememberLazyGridState() val gridState = rememberLazyStaggeredGridState() @@ -468,13 +463,11 @@ fun AnimatedVisibilityScope.GalleryListScreen( } } + searchBarState.CollectExpanded { fabHidden = it } + SearchBarScreen( onApplySearch = ::onApplySearch, - expanded = searchBarExpanded, - onExpandedChange = { - searchBarExpanded = it - fabHidden = it - }, + searchBarState = searchBarState, title = suitableTitle, searchFieldHint = searchBarHint, searchFieldState = searchFieldState, @@ -486,7 +479,7 @@ fun AnimatedVisibilityScope.GalleryListScreen( }.orEmpty() }, localSearch = false, - searchBarOffsetY = { searchBarOffsetY }, + scrollBehavior = scrollBehavior, trailingIcon = { val sheetState = LocalSideSheetState.current IconButton(onClick = { launch { sheetState.open() } }, shapes = IconButtonDefaults.shapes()) { @@ -508,25 +501,20 @@ fun AnimatedVisibilityScope.GalleryListScreen( val height by collectListThumbSizeAsState() val showPages by Settings.showGalleryPages.collectAsState() val showProgress by Settings.showReadingProgress.collectAsState() - val searchBarConnection = remember { + val connection = remember { val slop = ViewConfiguration.get(contextOf()).scaledTouchSlop - val topPaddingPx = with(density) { contentPadding.calculateTopPadding().roundToPx() } - object : NestedScrollConnection { - override fun onPostScroll(consumed: Offset, available: Offset, source: NestedScrollSource): Offset { - val dy = -consumed.y - if (dy >= slop) { - fabHidden = true - } else if (dy <= -slop / 2) { - fabHidden = false - } - searchBarOffsetY = (searchBarOffsetY - dy).roundToInt().coerceIn(-topPaddingPx, 0) - return Offset.Zero // We never consume it + scrollBehavior.nestedScrollConnection.watchPostScroll { (_, y) -> + val dy = -y + if (dy >= slop) { + fabHidden = true + } else if (dy <= -slop / 2) { + fabHidden = false } } } GalleryList( data = data, - contentModifier = Modifier.nestedScroll(searchBarConnection), + contentModifier = Modifier.nestedScroll(connection), contentPadding = contentPadding, listMode = listMode, detailListState = listState, @@ -550,12 +538,11 @@ fun AnimatedVisibilityScope.GalleryListScreen( showProgress = showProgress, ) }, - searchBarOffsetY = { searchBarOffsetY }, onRefresh = { urlBuilder.setRange(0) data.refresh() }, - onLoading = { searchBarOffsetY = 0 }, + onLoading = { scrollBehavior.reset() }, ) } diff --git a/app/src/main/kotlin/com/hippo/ehviewer/ui/screen/HistoryScreen.kt b/app/src/main/kotlin/com/hippo/ehviewer/ui/screen/HistoryScreen.kt index 7c13753d0e..f64c735d22 100644 --- a/app/src/main/kotlin/com/hippo/ehviewer/ui/screen/HistoryScreen.kt +++ b/app/src/main/kotlin/com/hippo/ehviewer/ui/screen/HistoryScreen.kt @@ -14,23 +14,19 @@ import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.IconButtonDefaults import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.SearchBarDefaults import androidx.compose.material3.Text import androidx.compose.material3.fork.SwipeToDismissBox import androidx.compose.material3.fork.rememberSwipeToDismissBoxState +import androidx.compose.material3.rememberSearchBarState import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember 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.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.LocalDensity import androidx.compose.ui.res.dimensionResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp @@ -62,7 +58,6 @@ import com.hippo.ehviewer.util.FavouriteStatusRouter import com.ramcosta.composedestinations.annotation.Destination import com.ramcosta.composedestinations.annotation.RootGraph import com.ramcosta.composedestinations.navigation.DestinationsNavigator -import kotlin.math.roundToInt import kotlinx.coroutines.delay import kotlinx.coroutines.flow.map import moe.tarsin.navigate @@ -74,13 +69,12 @@ fun AnimatedVisibilityScope.HistoryScreen(navigator: DestinationsNavigator) = Sc val hint = stringResource(R.string.search_bar_hint, title) val animateItems by Settings.animateItems.collectAsState() - var searchBarExpanded by rememberSaveable { mutableStateOf(false) } - var searchBarOffsetY by remember { mutableIntStateOf(0) } + val searchBarState = rememberSearchBarState() + val scrollBehavior = SearchBarDefaults.enterAlwaysSearchBarScrollBehavior() var keyword by rememberSaveable { mutableStateOf("") } - DrawerHandle(!searchBarExpanded) + DrawerHandle(!searchBarState.expanded) - val density = LocalDensity.current val historyData = rememberInVM { Pager(config = PagingConfig(pageSize = 20, jumpThreshold = 40)) { if (keyword.isNotEmpty()) { @@ -101,11 +95,10 @@ fun AnimatedVisibilityScope.HistoryScreen(navigator: DestinationsNavigator) = Sc keyword = it historyData.refresh() }, - expanded = searchBarExpanded, - onExpandedChange = { searchBarExpanded = it }, title = title, searchFieldHint = hint, - searchBarOffsetY = { searchBarOffsetY }, + searchBarState = searchBarState, + scrollBehavior = scrollBehavior, trailingIcon = { IconButton( onClick = { @@ -123,22 +116,12 @@ fun AnimatedVisibilityScope.HistoryScreen(navigator: DestinationsNavigator) = Sc } }, ) { paddingValues -> - val searchBarConnection = remember { - val topPaddingPx = with(density) { paddingValues.calculateTopPadding().roundToPx() } - object : NestedScrollConnection { - override fun onPostScroll(consumed: Offset, available: Offset, source: NestedScrollSource): Offset { - val dy = -consumed.y - searchBarOffsetY = (searchBarOffsetY - dy).roundToInt().coerceIn(-topPaddingPx, 0) - return Offset.Zero // We never consume it - } - } - } val marginH = dimensionResource(id = com.hippo.ehviewer.R.dimen.gallery_list_margin_h) val cardHeight by collectListThumbSizeAsState() val showPages by Settings.showGalleryPages.collectAsState() val showProgress by Settings.showReadingProgress.collectAsState() FastScrollLazyColumn( - modifier = Modifier.nestedScroll(searchBarConnection).fillMaxSize(), + modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection).fillMaxSize(), contentPadding = paddingValues, verticalArrangement = Arrangement.spacedBy(dimensionResource(com.hippo.ehviewer.R.dimen.gallery_list_interval)), ) { diff --git a/app/src/main/kotlin/com/hippo/ehviewer/ui/screen/SearchBarScreen.kt b/app/src/main/kotlin/com/hippo/ehviewer/ui/screen/SearchBarScreen.kt index 523c9913e9..950d125b91 100644 --- a/app/src/main/kotlin/com/hippo/ehviewer/ui/screen/SearchBarScreen.kt +++ b/app/src/main/kotlin/com/hippo/ehviewer/ui/screen/SearchBarScreen.kt @@ -3,27 +3,19 @@ package com.hippo.ehviewer.ui.screen import androidx.compose.animation.AnimatedContent import androidx.compose.foundation.background import androidx.compose.foundation.clickable -import androidx.compose.foundation.focusable import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxWithConstraints -import androidx.compose.foundation.layout.Column 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.WindowInsetsSides import androidx.compose.foundation.layout.asPaddingValues import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.navigationBars -import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.only import androidx.compose.foundation.layout.safeDrawing -import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.statusBars import androidx.compose.foundation.layout.widthIn -import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.layout.windowInsetsTopHeight import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items @@ -37,6 +29,8 @@ import androidx.compose.material.icons.automirrored.filled.MenuBook import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.ImageSearch import androidx.compose.material.icons.filled.Menu +import androidx.compose.material3.AppBarWithSearch +import androidx.compose.material3.ExpandedFullScreenSearchBar import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.IconButtonDefaults @@ -44,10 +38,13 @@ 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.SearchBarDefaults.InputField +import androidx.compose.material3.SearchBarScrollBehavior +import androidx.compose.material3.SearchBarState +import androidx.compose.material3.SearchBarValue import androidx.compose.material3.Text +import androidx.compose.material3.rememberSearchBarState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -56,13 +53,15 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.runtime.snapshotFlow -import androidx.compose.ui.Alignment +import androidx.compose.runtime.snapshots.Snapshot import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.nestedscroll.NestedScrollConnection +import androidx.compose.ui.input.nestedscroll.NestedScrollSource import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.coerceAtMost import androidx.compose.ui.unit.dp import com.ehviewer.core.database.dao.SearchDao @@ -81,7 +80,6 @@ import com.hippo.ehviewer.ui.destinations.ImageSearchScreenDestination import com.hippo.ehviewer.ui.theme.scrim import com.hippo.ehviewer.ui.tools.DialogState import com.hippo.ehviewer.ui.tools.awaitConfirmationOrCancel -import com.hippo.ehviewer.ui.tools.rememberCompositionActiveState import com.ramcosta.composedestinations.navigation.DestinationsNavigator import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.Flow @@ -109,14 +107,13 @@ suspend fun SearchDao.suggestions(prefix: String, limit: Int) = (if (prefix.isBl context(_: DialogState, _: DestinationsNavigator) fun SearchBarScreen( onApplySearch: (String) -> Unit, - expanded: Boolean, - onExpandedChange: (Boolean) -> Unit, title: String?, searchFieldHint: String, + searchBarState: SearchBarState = rememberSearchBarState(), searchFieldState: TextFieldState = rememberTextFieldState(), suggestionProvider: SuggestionProvider? = null, localSearch: Boolean = true, - searchBarOffsetY: () -> Int = { 0 }, + scrollBehavior: SearchBarScrollBehavior = SearchBarDefaults.enterAlwaysSearchBarScrollBehavior(), trailingIcon: @Composable () -> Unit = {}, filter: @Composable (() -> Unit)? = null, floatingActionButton: @Composable () -> Unit = {}, @@ -144,7 +141,7 @@ fun SearchBarScreen( val keywords = if (index == -1) { keyword } else { - "${query.substring(0, index + 1).trimEnd()} $keyword" + "${query.take(index + 1).trimEnd()} $keyword" } searchFieldState.setTextAndPlaceCursorAtEnd(keywords) } @@ -177,7 +174,7 @@ fun SearchBarScreen( mSuggestionList = mergedSuggestionFlow().toList() } - if (expanded) { + if (searchBarState.expanded) { LaunchedEffect(Unit) { snapshotFlow { searchFieldState.text }.collectLatest { updateSuggestions() @@ -185,9 +182,7 @@ fun SearchBarScreen( } } - fun hideSearchView() { - onExpandedChange(false) - } + fun hideSearchView() = scope.launch { searchBarState.animateToCollapsed() } fun onApplySearch() { // May have invalid whitespaces if pasted from clipboard, replace them with spaces @@ -215,106 +210,103 @@ fun SearchBarScreen( BoxWithConstraints(modifier = Modifier.fillMaxSize()) { Scaffold( topBar = { - Column { - val scrim = MaterialTheme.colorScheme.background.scrim() - Box(Modifier.windowInsetsTopHeight(WindowInsets.statusBars).fillMaxWidth().background(scrim)) - - // Placeholder, fill immutable SearchBar padding - Spacer(modifier = Modifier.height(SearchBarDefaults.InputFieldHeight + 16.dp)) - } - }, - floatingActionButton = floatingActionButton, - content = content, - ) - // https://issuetracker.google.com/337191298 - // Workaround for can't exit SearchBar due to refocus in non-touch mode - Box(Modifier.size(1.dp).focusable()) - val activeState = rememberCompositionActiveState() - SearchBar( - modifier = Modifier.align(Alignment.TopCenter).thenIf(!expanded) { offset { IntOffset(0, searchBarOffsetY()) } } - .windowInsetsPadding(WindowInsets.navigationBars.only(WindowInsetsSides.Horizontal)), - inputField = { - InputField( - state = searchFieldState, - onSearch = { - hideSearchView() - onApplySearch() - }, - expanded = expanded, - onExpandedChange = onExpandedChange, - modifier = Modifier.widthIn(max = (maxWidth - SearchBarHorizontalPadding * 2).coerceAtMost(M3SearchBarMaxWidth)).fillMaxWidth(), - placeholder = { - val contentActive by activeState.state - val text = title.takeUnless { expanded || contentActive } ?: searchFieldHint - Text(text, overflow = TextOverflow.Ellipsis, maxLines = 1) - }, - leadingIcon = { - if (expanded) { - IconButton(onClick = { hideSearchView() }, shapes = IconButtonDefaults.shapes()) { - Icon(Icons.AutoMirrored.Default.ArrowBack, contentDescription = null) - } - } else { - val drawerState = LocalNavDrawerState.current - IconButton(onClick = { scope.launch { drawerState.open() } }, shapes = IconButtonDefaults.shapes()) { - Icon(Icons.Default.Menu, contentDescription = null) - } - } - }, - trailingIcon = { - if (expanded) { - AnimatedContent(targetState = searchFieldState.text.isNotEmpty()) { hasText -> - if (hasText) { - IconButton(onClick = { searchFieldState.clearText() }, shapes = IconButtonDefaults.shapes()) { - Icon(Icons.Default.Close, contentDescription = null) - } - } else { - IconButton(onClick = { navigate(ImageSearchScreenDestination) }, shapes = IconButtonDefaults.shapes()) { - Icon(Icons.Default.ImageSearch, contentDescription = null) - } + val inputField = @Composable { active: Boolean -> + InputField( + textFieldState = searchFieldState, + searchBarState = searchBarState, + onSearch = { + hideSearchView() + onApplySearch() + }, + modifier = Modifier.widthIn(max = (maxWidth - SearchBarHorizontalPadding * 2).coerceAtMost(M3SearchBarMaxWidth)).fillMaxWidth(), + placeholder = { + val text = title.takeUnless { active } ?: searchFieldHint + Text(text, overflow = TextOverflow.Ellipsis, maxLines = 1) + }, + leadingIcon = { + if (active) { + IconButton(onClick = { hideSearchView() }, shapes = IconButtonDefaults.shapes()) { + Icon(Icons.AutoMirrored.Default.ArrowBack, contentDescription = null) + } + } else { + val drawerState = LocalNavDrawerState.current + IconButton(onClick = { scope.launch { drawerState.open() } }, shapes = IconButtonDefaults.shapes()) { + Icon(Icons.Default.Menu, contentDescription = null) } } - } else { - Row { - trailingIcon() - } - } - }, - ) - }, - expanded = expanded, - onExpandedChange = onExpandedChange, - ) { - activeState.Anchor() - filter?.invoke() - LazyColumn( - modifier = Modifier.fillMaxSize(), - contentPadding = WindowInsets.safeDrawing.only(WindowInsetsSides.Bottom).asPaddingValues(), - ) { - // Workaround for prepending before the first item - item {} - items(mSuggestionList, key = { it.keyword.hashCode() * 31 + it.canDelete.hashCode() }) { - 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) }, shapes = IconButtonDefaults.shapes()) { - Icon( - imageVector = Icons.Default.Close, - contentDescription = null, - ) + trailingIcon = { + if (active) { + AnimatedContent(targetState = searchFieldState.text.isNotEmpty()) { hasText -> + if (hasText) { + IconButton(onClick = { searchFieldState.clearText() }, shapes = IconButtonDefaults.shapes()) { + Icon(Icons.Default.Close, contentDescription = null) + } + } else { + IconButton(onClick = { navigate(ImageSearchScreenDestination) }, shapes = IconButtonDefaults.shapes()) { + Icon(Icons.Default.ImageSearch, contentDescription = null) + } + } + } + } else { + Row { + trailingIcon() + } } }, - colors = ListItemDefaults.colors(containerColor = Color.Transparent), - modifier = Modifier.clickable { it.onClick() }.thenIf(animateItems) { animateItem() }, ) } - } + AppBarWithSearch( + state = searchBarState, + inputField = { inputField(false) }, + colors = SearchBarDefaults.appBarWithSearchColors( + appBarContainerColor = Color.Transparent, + scrolledAppBarContainerColor = Color.Transparent, + ), + scrollBehavior = scrollBehavior, + ) + ExpandedFullScreenSearchBar( + state = searchBarState, + inputField = { inputField(true) }, + windowInsets = { SearchBarDefaults.windowInsets }, + ) { + filter?.invoke() + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = WindowInsets.safeDrawing.only(WindowInsetsSides.Bottom).asPaddingValues(), + ) { + // Workaround for prepending before the first item + item {} + items(mSuggestionList, key = { it.keyword.hashCode() * 31 + it.canDelete.hashCode() }) { + 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) }, shapes = IconButtonDefaults.shapes()) { + Icon( + imageVector = Icons.Default.Close, + contentDescription = null, + ) + } + }, + colors = ListItemDefaults.colors(containerColor = Color.Transparent), + modifier = Modifier.clickable { it.onClick() }.thenIf(animateItems) { animateItem() }, + ) + } + } + } + }, + floatingActionButton = floatingActionButton, + ) { + val scrim = MaterialTheme.colorScheme.background.scrim() + content(it) + Box(Modifier.windowInsetsTopHeight(WindowInsets.statusBars).fillMaxWidth().background(scrim)) } } } @@ -334,6 +326,28 @@ fun wrapTagKeyword(keyword: String, translate: Boolean = false): String = run { } } +fun SearchBarScrollBehavior.reset() = Snapshot.withMutableSnapshot { + scrollOffset = 0f + contentOffset = 0f +} + +val SearchBarState.expanded + get() = targetValue == SearchBarValue.Expanded + +@Composable +fun SearchBarState.CollectExpanded(f: (Boolean) -> Unit) = LaunchedEffect(Unit) { + snapshotFlow { expanded }.collect(f) +} + +inline fun NestedScrollConnection.watchPostScroll(crossinline f: (Offset) -> Unit) = let { raw -> + object : NestedScrollConnection by raw { + override fun onPostScroll(consumed: Offset, available: Offset, source: NestedScrollSource): Offset { + f(consumed) + return raw.onPostScroll(consumed, available, source) + } + } +} + private val TagTerminators = charArrayOf('"', '$') private val WhitespaceRegex = Regex("\\s+") private val SearchBarHorizontalPadding = 16.dp diff --git a/app/src/main/kotlin/com/hippo/ehviewer/ui/tools/CompositionActive.kt b/app/src/main/kotlin/com/hippo/ehviewer/ui/tools/CompositionActive.kt deleted file mode 100644 index 81f82e62ab..0000000000 --- a/app/src/main/kotlin/com/hippo/ehviewer/ui/tools/CompositionActive.kt +++ /dev/null @@ -1,23 +0,0 @@ -package com.hippo.ehviewer.ui.tools - -import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect -import androidx.compose.runtime.MutableState -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember - -@JvmInline -value class CompositionActiveState(val state: MutableState) { - @Composable - fun Anchor() = DisposableEffect(state) { - state.value = true - onDispose { - state.value = false - } - } -} - -@Composable -fun rememberCompositionActiveState() = remember { - CompositionActiveState(mutableStateOf(false)) -}