From 906012cd40f95c7b5e08b7721c41944b01821e89 Mon Sep 17 00:00:00 2001 From: "Cuong M. Tran" Date: Mon, 29 Jan 2024 23:14:38 +0700 Subject: [PATCH] SourcesSearch with animated search box --- .../presentation/browse/SourcesScreen.kt | 231 +++++++++++++++++- .../ui/browse/source/SourcesScreenModel.kt | 40 ++- .../tachiyomi/ui/browse/source/SourcesTab.kt | 3 + .../commonMain/resources/MR/base/strings.xml | 1 + 4 files changed, 264 insertions(+), 11 deletions(-) diff --git a/app/src/main/java/eu/kanade/presentation/browse/SourcesScreen.kt b/app/src/main/java/eu/kanade/presentation/browse/SourcesScreen.kt index c7db70c99..41b730e93 100644 --- a/app/src/main/java/eu/kanade/presentation/browse/SourcesScreen.kt +++ b/app/src/main/java/eu/kanade/presentation/browse/SourcesScreen.kt @@ -1,15 +1,29 @@ package eu.kanade.presentation.browse +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.expandVertically +import androidx.compose.animation.shrinkVertically import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.PushPin +import androidx.compose.material.icons.filled.Search import androidx.compose.material.icons.outlined.PushPin import androidx.compose.material3.AlertDialog import androidx.compose.material3.Icon @@ -18,11 +32,28 @@ import androidx.compose.material3.LocalTextStyle import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.material3.TextButton +import androidx.compose.material3.TextFieldDefaults import androidx.compose.runtime.Composable +import androidx.compose.runtime.State +import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.produceState import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.VisualTransformation +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import eu.kanade.presentation.browse.components.BaseSourceItem import eu.kanade.tachiyomi.ui.browse.source.SourcesScreenModel @@ -39,10 +70,12 @@ import tachiyomi.presentation.core.components.material.SecondaryItemAlpha import tachiyomi.presentation.core.components.material.padding import tachiyomi.presentation.core.components.material.topSmallPaddingValues import tachiyomi.presentation.core.i18n.stringResource -import tachiyomi.presentation.core.screens.EmptyScreen import tachiyomi.presentation.core.screens.LoadingScreen import tachiyomi.presentation.core.theme.header +import tachiyomi.presentation.core.util.clearFocusOnSoftKeyboardHide import tachiyomi.presentation.core.util.plus +import tachiyomi.presentation.core.util.runOnEnterKeyPressed +import tachiyomi.presentation.core.util.secondaryItemAlpha import tachiyomi.source.local.isLocal @Composable @@ -52,20 +85,34 @@ fun SourcesScreen( onClickItem: (Source, Listing) -> Unit, onClickPin: (Source) -> Unit, onLongClickItem: (Source) -> Unit, + // KMK --> + @Suppress("UNUSED_PARAMETER") modifier: Modifier = Modifier, + onChangeSearchQuery: (String?) -> Unit, + // KMK <-- ) { + // KMK --> + val lazyListState = rememberLazyListState() + // KMK <-- + when { state.isLoading -> LoadingScreen(Modifier.padding(contentPadding)) - state.isEmpty -> EmptyScreen( - MR.strings.source_empty_screen, - modifier = Modifier.padding(contentPadding), - ) - else -> { - // KMK --> + // KMK --> + // Disable this since a query with empty result will cause empty screen and hide search box + // state.isEmpty -> EmptyScreen( + // MR.strings.source_empty_screen, + // modifier = Modifier.padding(contentPadding), + // ) + else -> Column { + AnimatedFloatingSearchBox( + listState = lazyListState, + searchQuery = state.searchQuery, + onChangeSearchQuery = onChangeSearchQuery, + placeholderText = stringResource(MR.strings.action_source_search), + ) + FastScrollLazyColumn( - /* - ScrollbarLazyColumn( + state = lazyListState, // KMK <-- - */ contentPadding = contentPadding + topSmallPaddingValues, ) { items( @@ -111,6 +158,170 @@ fun SourcesScreen( } } +// KMK --> +@Composable +fun AnimatedFloatingSearchBox( + listState: LazyListState, + searchQuery: String?, + onChangeSearchQuery: (String?) -> Unit, + @Suppress("UNUSED_PARAMETER") modifier: Modifier = Modifier, + placeholderText: String? = null, +) { + AnimatedVisibility( + visible = listState.isScrollingUp().value, + enter = expandVertically(), + exit = shrinkVertically() + ) { + SourcesSearch( + searchQuery = searchQuery, + onChangeSearchQuery = onChangeSearchQuery, + placeholderText = placeholderText, + ) + } +} + +@Composable +fun LazyListState.isScrollingUp(): State { + return produceState(initialValue = true) { + var lastIndex = 0 + var lastScroll = Int.MAX_VALUE + snapshotFlow { + firstVisibleItemIndex to firstVisibleItemScrollOffset + }.collect { (currentIndex, currentScroll) -> + if (currentIndex != lastIndex || currentScroll != lastScroll) { + value = currentIndex < lastIndex || + (currentIndex == lastIndex && currentScroll < lastScroll) + lastIndex = currentIndex + lastScroll = currentScroll + } + } + } +} + +@Composable +fun SourcesSearch( + searchQuery: String?, + onChangeSearchQuery: (String?) -> Unit, + @Suppress("UNUSED_PARAMETER") modifier: Modifier = Modifier, + placeholderText: String? = null, +) { + val focusRequester = remember { FocusRequester() } + val keyboardController = LocalSoftwareKeyboardController.current + val focusManager = LocalFocusManager.current + + val searchAndClearFocus: () -> Unit = f@{ + if (searchQuery.isNullOrBlank()) return@f + focusManager.clearFocus() + keyboardController?.hide() + } + val onClickClearSearch: () -> Unit = { + onChangeSearchQuery(null) + focusRequester.requestFocus() + keyboardController?.show() + } + val onClickCloseSearch: () -> Unit = { + onClickClearSearch() + focusManager.clearFocus() + keyboardController?.hide() + } + + var isFocused by remember { mutableStateOf(false) } + + BasicTextField( + value = searchQuery ?: "", + onValueChange = onChangeSearchQuery, + modifier = Modifier + .fillMaxWidth() + .height(66.dp) + .padding(12.dp) + .focusRequester(focusRequester) + .onFocusChanged { isFocused = it.isFocused } + .runOnEnterKeyPressed(action = searchAndClearFocus) + .clearFocusOnSoftKeyboardHide(), + enabled = true, + textStyle = MaterialTheme.typography.bodyMedium.copy( + color = MaterialTheme.colorScheme.onBackground, + ), + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search), + keyboardActions = KeyboardActions(onSearch = { searchAndClearFocus() }), + singleLine = true, + cursorBrush = SolidColor(MaterialTheme.colorScheme.onBackground), + decorationBox = { innerTextField -> TextFieldDefaults.DecorationBox( + value = searchQuery ?: "", + innerTextField = innerTextField, + enabled = true, + singleLine = true, + visualTransformation = VisualTransformation.None, + interactionSource = remember { MutableInteractionSource() }, + placeholder = { Text( + modifier = Modifier.secondaryItemAlpha(), + text = (placeholderText ?: stringResource(MR.strings.action_search_hint)), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + style = MaterialTheme.typography.bodyMedium, + ) }, + leadingIcon = { SearchBoxLeadingIcon( + isFocused || !searchQuery.isNullOrBlank(), + modifier = Modifier, + onClickCloseSearch, + ) }, + trailingIcon = { SearchBoxTrailingIcon( + searchQuery.isNullOrEmpty(), + modifier = Modifier, + onClickClearSearch, + ) }, + shape = RoundedCornerShape(24.dp), + colors = TextFieldDefaults.colors( + focusedIndicatorColor = Color.Transparent, + unfocusedIndicatorColor = Color.Transparent, + errorIndicatorColor = Color.Transparent, + cursorColor = MaterialTheme.colorScheme.onBackground, + ), + contentPadding = PaddingValues(12.dp), + ) } + ) +} + +@Composable +fun SearchBoxLeadingIcon( + isSearching: Boolean, + @Suppress("UNUSED_PARAMETER") modifier: Modifier = Modifier, + onClickCloseSearch: () -> Unit = {}, +) { + if (isSearching) + IconButton( + onClick = onClickCloseSearch, + ) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = "Close", + ) + } + else + Icon( + imageVector = Icons.Filled.Search, + contentDescription = "Search", + ) +} + +@Composable +fun SearchBoxTrailingIcon( + isEmpty: Boolean, + @Suppress("UNUSED_PARAMETER") modifier: Modifier = Modifier, + onClickClearSearch: () -> Unit = {}, +) { + if (!isEmpty) + IconButton( + onClick = onClickClearSearch, + ) { + Icon( + imageVector = Icons.Filled.Close, + contentDescription = "Clear", + ) + } +} +// KMK <-- + @Composable private fun SourceHeader( language: String, diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/SourcesScreenModel.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/SourcesScreenModel.kt index 323a8778b..980e4c71d 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/SourcesScreenModel.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/SourcesScreenModel.kt @@ -16,6 +16,7 @@ import eu.kanade.domain.source.service.SourcePreferences import eu.kanade.domain.source.service.SourcePreferences.DataSaver import eu.kanade.domain.ui.UiPreferences import eu.kanade.presentation.browse.SourceUiModel +import eu.kanade.presentation.components.SEARCH_DEBOUNCE_MILLIS import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList @@ -23,9 +24,12 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.flow.update @@ -60,6 +64,9 @@ class SourcesScreenModel( init { // SY --> combine( + // KMK --> + state.map { it.searchQuery }.distinctUntilChanged().debounce(SEARCH_DEBOUNCE_MILLIS), + // KMK <-- getEnabledSources.subscribe(), getSourceCategories.subscribe(), getShowLatest.subscribe(smartSearchConfig != null), @@ -85,7 +92,27 @@ class SourcesScreenModel( // SY <-- } - private fun collectLatestSources(sources: List, categories: List, showLatest: Boolean, showPin: Boolean) { + private fun collectLatestSources( + // KMK --> + searchQuery: String?, + @Suppress("LocalVariableName") _sources: List, + // KMK <-- + categories: List, showLatest: Boolean, showPin: Boolean + ) { + // KMK --> + val queryFilter: (String?) -> ((Source) -> Boolean) = { query -> + filter@{ source -> + if (query.isNullOrBlank()) return@filter true + query.split(",").any { + val input = it.trim() + if (input.isEmpty()) return@any false + source.name.contains(input, ignoreCase = true) || + source.id == input.toLongOrNull() + } + } + } + val sources = _sources.filter(queryFilter(searchQuery)) + // KMK <-- mutableState.update { state -> val map = TreeMap> { d1, d2 -> // Sources without a lang defined will be placed at the end @@ -159,6 +186,14 @@ class SourcesScreenModel( } // SY <-- + // KMK --> + fun search(query: String?) { + mutableState.update { + it.copy(searchQuery = query) + } + } + // KMK <-- + fun showSourceDialog(source: Source) { mutableState.update { it.copy(dialog = Dialog.SourceLongClick(source)) } } @@ -187,6 +222,9 @@ class SourcesScreenModel( val showLatest: Boolean = false, val dataSaverEnabled: Boolean = false, // SY <-- + // KMK --> + val searchQuery: String? = null, + // KMK <-- ) { val isEmpty = items.isEmpty() } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/SourcesTab.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/SourcesTab.kt index 347f7d45f..e1c99417b 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/SourcesTab.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/SourcesTab.kt @@ -76,6 +76,9 @@ fun Screen.sourcesTab( }, onClickPin = screenModel::togglePin, onLongClickItem = screenModel::showSourceDialog, + // KMK --> + onChangeSearchQuery = screenModel::search, + // KMK <-- ) when (val dialog = state.dialog) { diff --git a/i18n/src/commonMain/resources/MR/base/strings.xml b/i18n/src/commonMain/resources/MR/base/strings.xml index c67c085c1..35926446e 100644 --- a/i18n/src/commonMain/resources/MR/base/strings.xml +++ b/i18n/src/commonMain/resources/MR/base/strings.xml @@ -72,6 +72,7 @@ Search… Search settings Global search + Search for source Select all Select inverse Mark as read