SearchVM, SearchService: improve search UX by reducing flickering (#1117)

* Fix: don't treat PH OpeningHours with specified time as `everyDay`

* Use SnapshotStateList in SearchVM and don't clear search-results if
there are no new search results from SearchableRepositories

* Rename parameter

* mergeStateLists: refactor as extension method, add apps to SearchResults by default

---------

Co-authored-by: MM20 <15646950+MM2-0@users.noreply.github.com>
This commit is contained in:
Christoph 2024-12-05 17:44:34 +00:00 committed by GitHub
parent 2b08cb7413
commit 8c4bfb7dc9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 132 additions and 182 deletions

View File

@ -147,7 +147,7 @@ fun AssistantScaffold(
val density = LocalDensity.current
val maxSearchBarOffset = with(density) { 128.dp.toPx() }
var searchBarOffset by remember {
mutableStateOf(0f)
mutableFloatStateOf(0f)
}
val nestedScrollConnection = remember {
@ -159,7 +159,7 @@ fun AssistantScaffold(
}
}
}
val actions by searchVM.searchActionResults
val actions = searchVM.searchActionResults
val webSearchPadding by animateDpAsState(
if (actions.isEmpty()) 0.dp else 48.dp
)

View File

@ -51,7 +51,7 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
@ -124,7 +124,7 @@ fun PagerScaffold(
val isSearchOpen by viewModel.isSearchOpen
val isWidgetEditMode by viewModel.isWidgetEditMode
val actions by searchVM.searchActionResults
val actions = searchVM.searchActionResults
val widgetsScrollState = rememberScrollState()
val searchState = rememberLazyListState()
@ -272,7 +272,7 @@ fun PagerScaffold(
}
}
val searchBarOffset = remember { mutableStateOf(0f) }
val searchBarOffset = remember { mutableFloatStateOf(0f) }
val scope = rememberCoroutineScope()
@ -341,8 +341,8 @@ fun PagerScaffold(
}
val deltaSearchBarOffset =
consumed.y * if (isSearchOpen && reverseSearchResults) 1 else -1
searchBarOffset.value =
(searchBarOffset.value + deltaSearchBarOffset).coerceIn(0f, maxSearchBarOffset)
searchBarOffset.floatValue =
(searchBarOffset.floatValue + deltaSearchBarOffset).coerceIn(0f, maxSearchBarOffset)
return super.onPostScroll(consumed, available, source)
}

View File

@ -47,6 +47,7 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
@ -106,7 +107,7 @@ fun PullDownScaffold(
val density = LocalDensity.current
val context = LocalContext.current
val actions by searchVM.searchActionResults
val actions = searchVM.searchActionResults
val isSearchOpen by viewModel.isSearchOpen
val isWidgetEditMode by viewModel.isWidgetEditMode
@ -116,7 +117,7 @@ fun PullDownScaffold(
val pagerState = rememberPagerState { 2 }
val offsetY = remember { mutableStateOf(0f) }
val offsetY = remember { mutableFloatStateOf(0f) }
val maxOffset = with(density) { 64.dp.toPx() }
val toggleSearchThreshold = with(density) { 48.dp.toPx() }
@ -153,13 +154,13 @@ fun PullDownScaffold(
val isOverThreshold by remember {
derivedStateOf {
offsetY.value.absoluteValue > toggleSearchThreshold
offsetY.floatValue.absoluteValue > toggleSearchThreshold
}
}
val dragProgress by remember {
derivedStateOf {
(offsetY.value.absoluteValue / toggleSearchThreshold).coerceAtMost(1f)
(offsetY.floatValue.absoluteValue / toggleSearchThreshold).coerceAtMost(1f)
}
}
@ -236,7 +237,7 @@ fun PullDownScaffold(
}
}
val searchBarOffset = remember { mutableStateOf(0f) }
val searchBarOffset = remember { mutableFloatStateOf(0f) }
val maxSearchBarOffset = with(density) { 128.dp.toPx() }
@ -281,7 +282,7 @@ fun PullDownScaffold(
}
LaunchedEffect(isWidgetEditMode) {
if (!isWidgetEditMode) searchBarOffset.value = 0f
if (!isWidgetEditMode) searchBarOffset.floatValue = 0f
}
val handleBackOrHomeEvent = {
@ -321,7 +322,7 @@ fun PullDownScaffold(
LaunchedEffect(isOverThreshold) {
if (isOverThreshold) {
view.performHapticFeedback(HapticFeedbackConstants.VIRTUAL_KEY)
} else if (offsetY.value != 0f) {
} else if (offsetY.floatValue != 0f) {
view.performHapticFeedback(HapticFeedbackConstants.VIRTUAL_KEY_RELEASE)
}
}
@ -342,15 +343,15 @@ fun PullDownScaffold(
val canPullUp = isSearchOpen && isSearchAtBottom
val consumed = when {
canPullUp && available.y < 0 || offsetY.value < 0 -> {
canPullUp && available.y < 0 || offsetY.floatValue < 0 -> {
val consumed = available.y
offsetY.value = (offsetY.value + (consumed * 0.5f)).coerceIn(-maxOffset, 0f)
offsetY.floatValue = (offsetY.floatValue + (consumed * 0.5f)).coerceIn(-maxOffset, 0f)
consumed
}
canPullDown && available.y > 0 || offsetY.value > 0 -> {
canPullDown && available.y > 0 || offsetY.floatValue > 0 -> {
val consumed = available.y
offsetY.value = (offsetY.value + (consumed * 0.5f)).coerceIn(0f, maxOffset)
offsetY.floatValue = (offsetY.floatValue + (consumed * 0.5f)).coerceIn(0f, maxOffset)
consumed
}
@ -367,17 +368,17 @@ fun PullDownScaffold(
): Offset {
val deltaSearchBarOffset =
consumed.y * if (isSearchOpen && reverseSearchResults) 1 else -1
searchBarOffset.value =
(searchBarOffset.value + deltaSearchBarOffset).coerceIn(0f, maxSearchBarOffset)
searchBarOffset.floatValue =
(searchBarOffset.floatValue + deltaSearchBarOffset).coerceIn(0f, maxSearchBarOffset)
return super.onPostScroll(consumed, available, source)
}
override suspend fun onPreFling(available: Velocity): Velocity {
if (offsetY.value > toggleSearchThreshold || offsetY.value < -toggleSearchThreshold) {
if (offsetY.floatValue > toggleSearchThreshold || offsetY.floatValue < -toggleSearchThreshold) {
viewModel.toggleSearch()
}
if (!isWidgetEditMode) gestureManager.dispatchDragEnd()
if (offsetY.value != 0f) {
if (offsetY.floatValue != 0f) {
offsetY.animateTo(0f)
return available
}
@ -406,7 +407,7 @@ fun PullDownScaffold(
}
)
}
.offset { IntOffset(0, offsetY.value.toInt()) },
.offset { IntOffset(0, offsetY.floatValue.toInt()) },
contentAlignment = Alignment.TopCenter
) {
BoxWithConstraints(
@ -576,7 +577,7 @@ fun PullDownScaffold(
val searchBarLevel by remember {
derivedStateOf {
when {
offsetY.value != 0f -> SearchBarLevel.Raised
offsetY.floatValue != 0f -> SearchBarLevel.Raised
!isSearchOpen && isWidgetsAtStart && (fillClockHeight || !bottomSearchBar) -> SearchBarLevel.Resting
isSearchOpen && isSearchAtTop && !bottomSearchBar -> SearchBarLevel.Active
isSearchOpen && isSearchAtBottom && bottomSearchBar -> SearchBarLevel.Active
@ -613,7 +614,7 @@ fun PullDownScaffold(
darkColors = LocalPreferDarkContentOverWallpaper.current && searchBarColor == SearchBarColors.Auto || searchBarColor == SearchBarColors.Dark,
bottomSearchBar = bottomSearchBar,
searchBarOffset = {
(if (searchBarFocused || fixedSearchBar) 0 else searchBarOffset.value.toInt() * (if (bottomSearchBar) 1 else -1)) +
(if (searchBarFocused || fixedSearchBar) 0 else searchBarOffset.floatValue.toInt() * (if (bottomSearchBar) 1 else -1)) +
with(density) {
(editModeSearchBarOffset - if (bottomSearchBar) keyboardFilterBarPadding else 0.dp)
.toPx()

View File

@ -1,6 +1,5 @@
package de.mm20.launcher2.ui.launcher.search
import android.util.Log
import androidx.activity.compose.BackHandler
import androidx.appcompat.app.AppCompatActivity
import androidx.compose.animation.AnimatedContent
@ -75,22 +74,22 @@ fun SearchColumn(
val hideFavs by viewModel.hideFavorites
val favoritesEnabled by viewModel.favoritesEnabled.collectAsState(false)
val apps by viewModel.appResults
val workApps by viewModel.workAppResults
val privateApps by viewModel.privateSpaceAppResults
val apps = viewModel.appResults
val workApps = viewModel.workAppResults
val privateApps = viewModel.privateSpaceAppResults
val profiles by viewModel.profiles.collectAsState(emptyList())
val profileStates by viewModel.profileStates.collectAsState(emptyList())
val appShortcuts by viewModel.appShortcutResults
val contacts by viewModel.contactResults
val files by viewModel.fileResults
val events by viewModel.calendarResults
val unitConverter by viewModel.unitConverterResults
val calculator by viewModel.calculatorResults
val wikipedia by viewModel.articleResults
val locations by viewModel.locationResults
val website by viewModel.websiteResults
val hiddenResults by viewModel.hiddenResults
val appShortcuts = viewModel.appShortcutResults
val contacts = viewModel.contactResults
val files = viewModel.fileResults
val events = viewModel.calendarResults
val unitConverter = viewModel.unitConverterResults
val calculator = viewModel.calculatorResults
val wikipedia = viewModel.articleResults
val locations = viewModel.locationResults
val website = viewModel.websiteResults
val hiddenResults = viewModel.hiddenResults
val bestMatch by viewModel.bestMatch

View File

@ -2,7 +2,9 @@ package de.mm20.launcher2.ui.launcher.search
import android.content.Context
import androidx.appcompat.app.AppCompatActivity
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.snapshots.SnapshotStateList
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import de.mm20.launcher2.devicepose.DevicePoseProvider
@ -28,6 +30,7 @@ import de.mm20.launcher2.search.Location
import de.mm20.launcher2.search.ResultScore
import de.mm20.launcher2.search.SavableSearchable
import de.mm20.launcher2.search.SearchFilters
import de.mm20.launcher2.search.SearchResults
import de.mm20.launcher2.search.SearchService
import de.mm20.launcher2.search.Searchable
import de.mm20.launcher2.search.Website
@ -79,8 +82,6 @@ class SearchVM : ViewModel(), KoinComponent {
val expandedCategory = mutableStateOf<SearchCategory?>(null)
val locationResults = mutableStateOf<List<Location>>(emptyList())
val profiles = profileManager.profiles.shareIn(
viewModelScope,
SharingStarted.WhileSubscribed(),
@ -104,22 +105,25 @@ class SearchVM : ViewModel(), KoinComponent {
}
}
val appResults = mutableStateOf<List<Application>>(emptyList())
val workAppResults = mutableStateOf<List<Application>>(emptyList())
val privateSpaceAppResults = mutableStateOf<List<Application>>(emptyList())
val appResults = mutableStateListOf<Application>()
val workAppResults = mutableStateListOf<Application>()
val privateSpaceAppResults = mutableStateListOf<Application>()
val appShortcutResults = mutableStateOf<List<AppShortcut>>(emptyList())
val fileResults = mutableStateOf<List<File>>(emptyList())
val contactResults = mutableStateOf<List<Contact>>(emptyList())
val calendarResults = mutableStateOf<List<CalendarEvent>>(emptyList())
val articleResults = mutableStateOf<List<Article>>(emptyList())
val websiteResults = mutableStateOf<List<Website>>(emptyList())
val calculatorResults = mutableStateOf<List<Calculator>>(emptyList())
val unitConverterResults = mutableStateOf<List<UnitConverter>>(emptyList())
val searchActionResults = mutableStateOf<List<SearchAction>>(emptyList())
val appShortcutResults = mutableStateListOf<AppShortcut>()
val fileResults = mutableStateListOf<File>()
val contactResults = mutableStateListOf<Contact>()
val calendarResults = mutableStateListOf<CalendarEvent>()
val articleResults = mutableStateListOf<Article>()
val websiteResults = mutableStateListOf<Website>()
val calculatorResults = mutableStateListOf<Calculator>()
val unitConverterResults = mutableStateListOf<UnitConverter>()
val searchActionResults = mutableStateListOf<SearchAction>()
val locationResults = mutableStateListOf<Location>()
var previousResults: SearchResults? = null
val hiddenResultsButton = searchUiSettings.hiddenItemsButton
val hiddenResults = mutableStateOf<List<SavableSearchable>>(emptyList())
val hiddenResults = mutableStateListOf<SavableSearchable>()
val favoritesEnabled = searchUiSettings.favorites
val hideFavorites = mutableStateOf(false)
@ -179,7 +183,6 @@ class SearchVM : ViewModel(), KoinComponent {
}
searchQuery.value = query
isSearchEmpty.value = query.isEmpty()
hiddenResults.value = emptyList()
val filters = filters.value
@ -218,15 +221,6 @@ class SearchVM : ViewModel(), KoinComponent {
flowOf(emptyList())
}
val allApps = searchService.getAllApps()
fileResults.value = emptyList()
contactResults.value = emptyList()
calendarResults.value = emptyList()
locationResults.value = emptyList()
articleResults.value = emptyList()
websiteResults.value = emptyList()
calculatorResults.value = emptyList()
unitConverterResults.value = emptyList()
searchActionResults.value = emptyList()
allApps
.combine(hiddenItemKeys) { results, hiddenKeys -> results to hiddenKeys }
@ -253,11 +247,13 @@ class SearchVM : ViewModel(), KoinComponent {
)
}
hiddenItems += hiddenPrivateApps
previousResults = SearchResults(apps = apps)
appResults.value = apps
workAppResults.value = workApps
privateSpaceAppResults.value = privateApps
hiddenResults.value = hiddenItems
searchActionResults.clear()
appResults.mergeWith(apps)
workAppResults.mergeWith(workApps)
privateSpaceAppResults.mergeWith(privateApps)
hiddenResults.mergeWith(hiddenItems)
}
} else {
@ -267,127 +263,65 @@ class SearchVM : ViewModel(), KoinComponent {
searchService.search(
query,
filters = if (query.isEmpty()) filters.copy(apps = true) else filters,
previousResults,
)
.combine(hiddenItemKeys) { results, hiddenKeys -> results to hiddenKeys }
.collectLatest { (results, hiddenKeys) ->
val hiddenItems = mutableListOf<SavableSearchable>()
previousResults = results
if (results.apps != null) {
val (hiddenApps, apps) = results.apps!!.partition {
hiddenKeys.contains(
it.key
)
}
hiddenItems += hiddenApps
appResults.value = apps.applyRanking(query)
} else {
appResults.value = emptyList()
}
workAppResults.value = emptyList()
privateSpaceAppResults.value = emptyList()
hiddenResults.clear()
workAppResults.clear()
privateSpaceAppResults.clear()
if (results.shortcuts != null) {
val (hiddenShortcuts, shortcuts) = results.shortcuts!!.partition {
hiddenKeys.contains(
it.key
)
}
hiddenItems += hiddenShortcuts
appShortcutResults.value = shortcuts.applyRanking(query)
} else {
appShortcutResults.value = emptyList()
}
appResults.mergeWith(results.apps, hiddenKeys, query)
appShortcutResults.mergeWith(results.shortcuts, hiddenKeys, query)
fileResults.mergeWith(results.files, hiddenKeys, query)
if (results.files != null) {
val (hiddenFiles, files) = results.files!!.partition {
hiddenKeys.contains(
it.key
)
}
hiddenItems += hiddenFiles
fileResults.value = files.applyRanking(query)
} else {
fileResults.value = emptyList()
}
if (results.contacts != null) {
val (hiddenContacts, contacts) = results.contacts!!.partition {
hiddenKeys.contains(
it.key
)
}
hiddenItems += hiddenContacts
contactResults.value = contacts.applyRanking(query)
} else {
contactResults.value = emptyList()
}
if (results.calendars != null) {
val (hiddenEvents, events) = results.calendars!!.partition {
hiddenKeys.contains(
it.key
)
}
hiddenItems += hiddenEvents
calendarResults.value = events.applyRanking(query)
} else {
calendarResults.value = emptyList()
}
if (results.locations != null && results.locations!!.isNotEmpty()) {
val (hiddenLocations, locations) = results.locations!!.partition {
hiddenKeys.contains(
it.key
)
}
hiddenItems += hiddenLocations
val lastLocation = devicePoseProvider.lastLocation
if (lastLocation != null) {
locationResults.value = locations.asSequence()
.sortedWith { a, b ->
a.distanceTo(lastLocation)
.compareTo(b.distanceTo(lastLocation))
}
.distinctBy { it.key }
.toList()
} else {
locationResults.value = locations.applyRanking(query)
}
} else {
locationResults.value = emptyList()
}
if (results.wikipedia != null) {
articleResults.value = results.wikipedia!!.applyRanking(query)
} else {
articleResults.value = emptyList()
}
if (results.websites != null) {
websiteResults.value = results.websites!!.applyRanking(query)
} else {
websiteResults.value = emptyList()
}
calculatorResults.value = results.calculators ?: emptyList()
unitConverterResults.value = results.unitConverters ?: emptyList()
contactResults.mergeWith(
results.contacts?.filterNot { hiddenKeys.contains(it.key) }
?.applyRanking(query)
)
calendarResults.mergeWith(
results.calendars?.filterNot { hiddenKeys.contains(it.key) }
?.applyRanking(query)
)
locationResults.mergeWith(
results.locations?.filterNot { hiddenKeys.contains(it.key) }
?.let { locations ->
devicePoseProvider.lastLocation?.let {
locations.asSequence()
.sortedWith { a, b ->
a.distanceTo(it).compareTo(b.distanceTo(it))
}
.distinctBy { it.key }
.toList()
} ?: locations.applyRanking(query)
}
)
articleResults.mergeWith(
results.wikipedia?.applyRanking(query)
)
websiteResults.mergeWith(
results.websites?.applyRanking(query)
)
calculatorResults.mergeWith(results.calculators)
unitConverterResults.mergeWith(results.unitConverters)
if (results.searchActions != null) {
searchActionResults.value = results.searchActions!!
searchActionResults.mergeWith(results.searchActions!!)
}
if (launchOnEnter.value) {
bestMatch.value = when {
appResults.value.isNotEmpty() -> appResults.value.first()
appShortcutResults.value.isNotEmpty() -> appShortcutResults.value.first()
calendarResults.value.isNotEmpty() -> calendarResults.value.first()
locationResults.value.isNotEmpty() -> locationResults.value.first()
contactResults.value.isNotEmpty() -> contactResults.value.first()
articleResults.value.isNotEmpty() -> articleResults.value.first()
websiteResults.value.isNotEmpty() -> websiteResults.value.first()
fileResults.value.isNotEmpty() -> fileResults.value.first()
searchActionResults.value.isNotEmpty() -> searchActionResults.value.first()
appResults.isNotEmpty() -> appResults.first()
appShortcutResults.isNotEmpty() -> appShortcutResults.first()
calendarResults.isNotEmpty() -> calendarResults.first()
locationResults.isNotEmpty() -> locationResults.first()
contactResults.isNotEmpty() -> contactResults.first()
articleResults.isNotEmpty() -> articleResults.first()
websiteResults.isNotEmpty() -> websiteResults.first()
fileResults.isNotEmpty() -> fileResults.first()
searchActionResults.isNotEmpty() -> searchActionResults.first()
else -> null
}
} else {
@ -494,6 +428,23 @@ class SearchVM : ViewModel(), KoinComponent {
}
return sorted.distinctBy { it.key }.toList()
}
private fun <T> SnapshotStateList<T>.mergeWith(newItems: List<T>?) {
val items = newItems ?: emptyList()
val diff = toSet() subtract items.toSet()
removeAll(diff)
for ((i, item) in items.withIndex()) {
if (i < size)
set(i, item)
else
add(item)
}
}
private suspend fun <T : SavableSearchable> SnapshotStateList<T>.mergeWith(
newItems: List<T>?,
hiddenKeys: List<String>,
query: String
) = this.mergeWith((newItems ?: emptyList()).filterNot { hiddenKeys.contains(it.key) }.applyRanking(query))
}

View File

@ -68,7 +68,7 @@ fun LauncherSearchBar(
val searchVM: SearchVM = viewModel()
val hiddenItemsButtonEnabled by searchVM.hiddenResultsButton.collectAsState(false)
val hiddenItems by searchVM.hiddenResults
val hiddenItems = searchVM.hiddenResults
val sheetManager = LocalBottomSheetManager.current

View File

@ -1,7 +1,5 @@
package de.mm20.launcher2.search
import kotlinx.coroutines.Deferred
interface Searchable {
val score: ResultScore
get() = ResultScore.Unspecified

View File

@ -1,6 +1,5 @@
package de.mm20.launcher2.search
import kotlinx.collections.immutable.ImmutableList
import kotlinx.coroutines.flow.Flow
interface SearchableRepository<T : Searchable> {

View File

@ -30,6 +30,7 @@ interface SearchService {
fun search(
query: String,
filters: SearchFilters,
initialResults: SearchResults? = null,
): Flow<SearchResults>
fun getAllApps(): Flow<AllAppsResults>
@ -54,9 +55,10 @@ internal class SearchServiceImpl(
override fun search(
query: String,
filters: SearchFilters,
initialResults: SearchResults?,
): Flow<SearchResults> = flow {
supervisorScope {
val results = MutableStateFlow(SearchResults())
val results = MutableStateFlow(initialResults ?: SearchResults())
val customAttrResults = customAttributesRepository.search(query)
.map { items ->