diff --git a/plugins/plugin-konfeature/src/main/kotlin/com/redmadrobot/debug/plugin/konfeature/ui/AnimatedFilterItem.kt b/plugins/plugin-konfeature/src/main/kotlin/com/redmadrobot/debug/plugin/konfeature/ui/AnimatedFilterItem.kt new file mode 100644 index 00000000..38711261 --- /dev/null +++ b/plugins/plugin-konfeature/src/main/kotlin/com/redmadrobot/debug/plugin/konfeature/ui/AnimatedFilterItem.kt @@ -0,0 +1,32 @@ +package com.redmadrobot.debug.plugin.konfeature.ui + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.tween +import androidx.compose.animation.expandVertically +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkVertically +import androidx.compose.foundation.lazy.LazyItemScope +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier + +private const val ENTER_DURATION_MILLIS = 220 +private const val EXIT_DURATION_MILLIS = 180 + +@Composable +internal fun LazyItemScope.AnimatedFilterItem( + visible: Boolean, + modifier: Modifier = Modifier, + content: @Composable () -> Unit, +) { + AnimatedVisibility( + visible = visible, + enter = fadeIn(animationSpec = tween(durationMillis = ENTER_DURATION_MILLIS)) + + expandVertically(animationSpec = tween(durationMillis = ENTER_DURATION_MILLIS)), + exit = fadeOut(animationSpec = tween(durationMillis = EXIT_DURATION_MILLIS)) + + shrinkVertically(animationSpec = tween(durationMillis = EXIT_DURATION_MILLIS)), + modifier = modifier.animateItem(), + ) { + content() + } +} diff --git a/plugins/plugin-konfeature/src/main/kotlin/com/redmadrobot/debug/plugin/konfeature/ui/KonfeatureScreen.kt b/plugins/plugin-konfeature/src/main/kotlin/com/redmadrobot/debug/plugin/konfeature/ui/KonfeatureScreen.kt index e52c9810..9fe84776 100644 --- a/plugins/plugin-konfeature/src/main/kotlin/com/redmadrobot/debug/plugin/konfeature/ui/KonfeatureScreen.kt +++ b/plugins/plugin-konfeature/src/main/kotlin/com/redmadrobot/debug/plugin/konfeature/ui/KonfeatureScreen.kt @@ -127,30 +127,29 @@ private fun LazyListScope.konfeatureItems( ) { items( items = state.filteredItems, - key = { item -> - when (item) { - is KonfeatureItem.Config -> "config_${item.name}" - is KonfeatureItem.Value -> "value_${item.key}" - } - }, + key = { item -> item.itemKey }, ) { item -> + val isMatchingFilter = item.itemKey in state.matchingKeys + when (item) { is KonfeatureItem.Config -> { val isCollapsed = !state.isSearchActive && item.name in state.collapsedConfigs val overrideCount = state.values.count { value -> value.configName == item.name && value.isDebugSource } - ConfigGroupHeader( - name = item.description.takeIf { it.isNotEmpty() } ?: item.name, - overrideCount = overrideCount, - isCollapsed = isCollapsed, - onClick = { onHeaderClick(item.name) }, - ) + AnimatedFilterItem(visible = isMatchingFilter) { + ConfigGroupHeader( + name = item.description.takeIf { it.isNotEmpty() } ?: item.name, + overrideCount = overrideCount, + isCollapsed = isCollapsed, + onClick = { onHeaderClick(item.name) }, + ) + } } is KonfeatureItem.Value -> { - val isVisible = state.isSearchActive || item.configName !in state.collapsedConfigs - if (isVisible) { + val isVisible = isMatchingFilter && (state.isSearchActive || item.configName !in state.collapsedConfigs) + AnimatedFilterItem(visible = isVisible) { ConfigValueItem( item = item, onEditClick = onEditClick, diff --git a/plugins/plugin-konfeature/src/main/kotlin/com/redmadrobot/debug/plugin/konfeature/ui/KonfeatureViewModel.kt b/plugins/plugin-konfeature/src/main/kotlin/com/redmadrobot/debug/plugin/konfeature/ui/KonfeatureViewModel.kt index ec720919..530489d9 100644 --- a/plugins/plugin-konfeature/src/main/kotlin/com/redmadrobot/debug/plugin/konfeature/ui/KonfeatureViewModel.kt +++ b/plugins/plugin-konfeature/src/main/kotlin/com/redmadrobot/debug/plugin/konfeature/ui/KonfeatureViewModel.kt @@ -101,7 +101,7 @@ internal class KonfeatureViewModel( .debounce(timeoutMillis = SEARCH_QUERY_DELAY_MILLIS) .onEach { query -> _state.update { state -> - state.copy(filteredItems = filterItems(state.configs, state.values, query)) + state.copy(matchingKeys = computeMatchingKeys(state.values, query)) } } .launchIn(viewModelScope) @@ -110,10 +110,32 @@ internal class KonfeatureViewModel( private suspend fun updateItems() { val (configs, values) = withContext(Dispatchers.IO) { getItems(konfeature) } val searchQuery = _searchQueryFlow.value - val filteredItems = filterItems(configs, values, searchQuery) + val items = buildItems(configs, values) + val matchingKeys = computeMatchingKeys(values, searchQuery) _state.update { state -> - state.copy(configs = configs, values = values, filteredItems = filteredItems) + state.copy( + configs = configs, + values = values, + filteredItems = items, + matchingKeys = matchingKeys, + ) + } + } + + private fun buildItems( + configs: Map, + values: List, + ): List { + return buildList { + var previousValue: KonfeatureItem.Value? = null + for (value in values) { + if (previousValue?.configName != value.configName) { + configs[value.configName]?.let { config -> add(config) } + } + add(value) + previousValue = value + } } } @@ -181,25 +203,26 @@ internal class KonfeatureViewModel( } } - private suspend fun filterItems( - configs: Map, + private suspend fun computeMatchingKeys( values: List, - query: String - ): List { + query: String, + ): Set { return withContext(Dispatchers.Default) { - buildList { - var previousValue: KonfeatureItem.Value? = null - - for (value in values) { - if (value.key.contains(query, ignoreCase = true)) { - if (previousValue?.configName != value.configName) { - configs[value.configName]?.let { config -> add(config) } - } - add(value) - previousValue = value - } - } + if (query.isBlank()) { + values.toMatchingKeys() + } else { + val matchingValues = values.filter { it.key.contains(query, ignoreCase = true) } + matchingValues.toMatchingKeys() } } } + + private fun List.toMatchingKeys(): Set { + return this.flatMapTo(destination = mutableSetOf()) { value -> + listOf( + "${KonfeatureItem.ITEM_KEY_PREFIX_CONFIG}${value.configName}", + "${KonfeatureItem.ITEM_KEY_PREFIX_VALUE}${value.key}" + ) + } + } } diff --git a/plugins/plugin-konfeature/src/main/kotlin/com/redmadrobot/debug/plugin/konfeature/ui/data/KonfeatureItem.kt b/plugins/plugin-konfeature/src/main/kotlin/com/redmadrobot/debug/plugin/konfeature/ui/data/KonfeatureItem.kt index 2c03898d..f79289b0 100644 --- a/plugins/plugin-konfeature/src/main/kotlin/com/redmadrobot/debug/plugin/konfeature/ui/data/KonfeatureItem.kt +++ b/plugins/plugin-konfeature/src/main/kotlin/com/redmadrobot/debug/plugin/konfeature/ui/data/KonfeatureItem.kt @@ -3,10 +3,20 @@ package com.redmadrobot.debug.plugin.konfeature.ui.data import androidx.compose.ui.graphics.Color internal sealed interface KonfeatureItem { + val itemKey: String + + companion object { + const val ITEM_KEY_PREFIX_CONFIG = "config_" + const val ITEM_KEY_PREFIX_VALUE = "value_" + } + data class Config( val name: String, val description: String, - ) : KonfeatureItem + ) : KonfeatureItem { + override val itemKey: String + get() = "$ITEM_KEY_PREFIX_CONFIG$name" + } data class Value( val key: String, @@ -17,6 +27,9 @@ internal sealed interface KonfeatureItem { val sourceColor: Color, val isDebugSource: Boolean ) : KonfeatureItem { + override val itemKey: String + get() = "$ITEM_KEY_PREFIX_VALUE$key" + val editAvailable: Boolean get() = when (value) { is Boolean, diff --git a/plugins/plugin-konfeature/src/main/kotlin/com/redmadrobot/debug/plugin/konfeature/ui/data/KonfeatureViewState.kt b/plugins/plugin-konfeature/src/main/kotlin/com/redmadrobot/debug/plugin/konfeature/ui/data/KonfeatureViewState.kt index 3bdbadeb..024edcf3 100644 --- a/plugins/plugin-konfeature/src/main/kotlin/com/redmadrobot/debug/plugin/konfeature/ui/data/KonfeatureViewState.kt +++ b/plugins/plugin-konfeature/src/main/kotlin/com/redmadrobot/debug/plugin/konfeature/ui/data/KonfeatureViewState.kt @@ -6,10 +6,16 @@ internal data class KonfeatureViewState( val configs: Map = emptyMap(), val values: List = emptyList(), val filteredItems: List = emptyList(), + val matchingKeys: Set = emptySet(), val editDialogState: EditDialogState? = null ) { val isSearchActive: Boolean get() = searchQuery.isNotBlank() - val shouldShowEmptySearchItemsHint - get() = isSearchActive && filteredItems.none { it is KonfeatureItem.Value } + val shouldShowEmptySearchItemsHint: Boolean + get() { + val isNotMatchingKeys = matchingKeys.none { key -> + key.startsWith(prefix = KonfeatureItem.ITEM_KEY_PREFIX_VALUE) + } + return isSearchActive && isNotMatchingKeys + } }