SourcesSearch with animated search box

This commit is contained in:
Cuong M. Tran 2024-01-29 23:14:38 +07:00
parent b94f67d9b6
commit 906012cd40
No known key found for this signature in database
GPG key ID: 733AA7624B9315C2
4 changed files with 264 additions and 11 deletions

View file

@ -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<Boolean> {
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,

View file

@ -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<Source>, categories: List<String>, showLatest: Boolean, showPin: Boolean) {
private fun collectLatestSources(
// KMK -->
searchQuery: String?,
@Suppress("LocalVariableName") _sources: List<Source>,
// KMK <--
categories: List<String>, 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<String, MutableList<Source>> { 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()
}

View file

@ -76,6 +76,9 @@ fun Screen.sourcesTab(
},
onClickPin = screenModel::togglePin,
onLongClickItem = screenModel::showSourceDialog,
// KMK -->
onChangeSearchQuery = screenModel::search,
// KMK <--
)
when (val dialog = state.dialog) {

View file

@ -72,6 +72,7 @@
<string name="action_search_hint">Search…</string>
<string name="action_search_settings">Search settings</string>
<string name="action_global_search">Global search</string>
<string name="action_source_search">Search for source</string>
<string name="action_select_all">Select all</string>
<string name="action_select_inverse">Select inverse</string>
<string name="action_mark_as_read">Mark as read</string>