SourcesSearch with animated search box
This commit is contained in:
parent
b94f67d9b6
commit
906012cd40
4 changed files with 264 additions and 11 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -76,6 +76,9 @@ fun Screen.sourcesTab(
|
|||
},
|
||||
onClickPin = screenModel::togglePin,
|
||||
onLongClickItem = screenModel::showSourceDialog,
|
||||
// KMK -->
|
||||
onChangeSearchQuery = screenModel::search,
|
||||
// KMK <--
|
||||
)
|
||||
|
||||
when (val dialog = state.dialog) {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Reference in a new issue