From bfe493e5c9a0ae10f265a4f7bcf839663828802a Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Sat, 8 Aug 2026 15:52:45 +0200 Subject: [PATCH] Fade the search bar into selection mode (#80) The bar cut straight from the search field to the contextual bar. Both now live in one top app bar whose container colour eases and whose title, navigation and action slots fade through, so entering and leaving selection mode is a transition rather than a swap. --- .../calendula/ui/search/SearchScreen.kt | 202 ++++++++++++------ 1 file changed, 131 insertions(+), 71 deletions(-) diff --git a/app/src/main/java/de/jeanlucmakiola/calendula/ui/search/SearchScreen.kt b/app/src/main/java/de/jeanlucmakiola/calendula/ui/search/SearchScreen.kt index 3fe3012..574d9db 100644 --- a/app/src/main/java/de/jeanlucmakiola/calendula/ui/search/SearchScreen.kt +++ b/app/src/main/java/de/jeanlucmakiola/calendula/ui/search/SearchScreen.kt @@ -5,12 +5,15 @@ import android.content.pm.PackageManager import androidx.activity.compose.BackHandler import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.animateColorAsState import androidx.compose.foundation.background import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth @@ -31,6 +34,7 @@ import androidx.compose.material.icons.filled.SelectAll import androidx.compose.material3.AlertDialog import androidx.compose.material3.Checkbox import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme @@ -43,6 +47,7 @@ import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue @@ -67,6 +72,7 @@ import de.jeanlucmakiola.calendula.R import de.jeanlucmakiola.calendula.domain.EventInstance import de.jeanlucmakiola.calendula.domain.RecurringWriteScope import de.jeanlucmakiola.floret.identity.animateItemMotion +import de.jeanlucmakiola.floret.identity.fadeThrough import de.jeanlucmakiola.floret.identity.predictiveBack import de.jeanlucmakiola.floret.components.GroupedRow import de.jeanlucmakiola.floret.components.InlineTextField @@ -160,51 +166,18 @@ fun SearchScreen( modifier = modifier.predictiveBack(onBack = onBack, enabled = !inSelection), containerColor = MaterialTheme.colorScheme.surface, topBar = { - if (inSelection) { - SelectionTopBar( - count = selection.size, - onClose = viewModel::clearSelection, - onSelectAll = viewModel::selectAll, - onDelete = { requireWrite { showDeleteDialog = true } }, - ) - } else { - TopAppBar( - title = { - InlineTextField( - value = query, - onValueChange = viewModel::setQuery, - placeholder = stringResource(R.string.search_hint), - capitalization = KeyboardCapitalization.None, - imeAction = ImeAction.Search, - onImeAction = { keyboard?.hide() }, - modifier = Modifier - .fillMaxWidth() - .focusRequester(focusRequester), - ) - }, - navigationIcon = { - IconButton(onClick = onBack) { - Icon( - imageVector = Icons.AutoMirrored.Filled.ArrowBack, - contentDescription = stringResource(R.string.search_back), - ) - } - }, - actions = { - if (query.isNotEmpty()) { - IconButton(onClick = { viewModel.setQuery("") }) { - Icon( - imageVector = Icons.Default.Close, - contentDescription = stringResource(R.string.search_clear), - ) - } - } - }, - colors = TopAppBarDefaults.topAppBarColors( - containerColor = MaterialTheme.colorScheme.surface, - ), - ) - } + SearchTopBar( + inSelection = inSelection, + query = query, + selectedCount = selection.size, + focusRequester = focusRequester, + onQueryChange = viewModel::setQuery, + onImeAction = { keyboard?.hide() }, + onBack = onBack, + onCloseSelection = viewModel::clearSelection, + onSelectAll = viewModel::selectAll, + onDelete = { requireWrite { showDeleteDialog = true } }, + ) }, ) { padding -> // imePadding shrinks the content by the keyboard, so the centered @@ -284,43 +257,130 @@ fun SearchScreen( } } -/** Contextual bar replacing the search field while results are picked. */ -@OptIn(ExperimentalMaterial3Api::class) +/** + * The search field and the contextual selection bar as one bar: entering or + * leaving selection mode keeps the bar itself in place, eases its container + * colour and fades its slots through, rather than cutting between two bars. + */ +@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class) @Composable -private fun SelectionTopBar( - count: Int, - onClose: () -> Unit, +private fun SearchTopBar( + inSelection: Boolean, + query: String, + selectedCount: Int, + focusRequester: FocusRequester, + onQueryChange: (String) -> Unit, + onImeAction: () -> Unit, + onBack: () -> Unit, + onCloseSelection: () -> Unit, onSelectAll: () -> Unit, onDelete: () -> Unit, ) { + val container by animateColorAsState( + targetValue = if (inSelection) { + MaterialTheme.colorScheme.surfaceContainerHigh + } else { + MaterialTheme.colorScheme.surface + }, + animationSpec = MaterialTheme.motionScheme.fastEffectsSpec(), + label = "search-top-bar-color", + ) + // Held past the selection emptying so the count has something to draw while + // it fades out — by then the set is already at zero. + var lastCount by remember { mutableIntStateOf(0) } + if (selectedCount > 0) lastCount = selectedCount + + // Read here so the non-composable transitionSpec lambdas can capture it. + val slotSwap = fadeThrough() + TopAppBar( - title = { Text(pluralStringResource(R.plurals.search_selected_count, count, count)) }, + title = { + AnimatedContent( + targetState = inSelection, + transitionSpec = { slotSwap }, + label = "search-top-bar-title", + ) { selecting -> + if (selecting) { + Text( + pluralStringResource( + R.plurals.search_selected_count, + lastCount, + lastCount, + ), + ) + } else { + InlineTextField( + value = query, + onValueChange = onQueryChange, + placeholder = stringResource(R.string.search_hint), + capitalization = KeyboardCapitalization.None, + imeAction = ImeAction.Search, + onImeAction = onImeAction, + modifier = Modifier + .fillMaxWidth() + .focusRequester(focusRequester), + ) + } + } + }, navigationIcon = { - IconButton(onClick = onClose) { - Icon( - imageVector = Icons.Default.Close, - contentDescription = stringResource(R.string.search_selection_close), - ) + AnimatedContent( + targetState = inSelection, + transitionSpec = { slotSwap }, + label = "search-top-bar-navigation", + ) { selecting -> + if (selecting) { + IconButton(onClick = onCloseSelection) { + Icon( + imageVector = Icons.Default.Close, + contentDescription = stringResource(R.string.search_selection_close), + ) + } + } else { + IconButton(onClick = onBack) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = stringResource(R.string.search_back), + ) + } + } } }, actions = { - IconButton(onClick = onSelectAll) { - Icon( - imageVector = Icons.Default.SelectAll, - contentDescription = stringResource(R.string.search_select_all), - ) - } - IconButton(onClick = onDelete) { - Icon( - imageVector = Icons.Default.Delete, - contentDescription = stringResource(R.string.search_delete_selected), - tint = MaterialTheme.colorScheme.error, - ) + AnimatedContent( + targetState = inSelection, + transitionSpec = { slotSwap }, + label = "search-top-bar-actions", + ) { selecting -> + Row { + if (selecting) { + IconButton(onClick = onSelectAll) { + Icon( + imageVector = Icons.Default.SelectAll, + contentDescription = stringResource(R.string.search_select_all), + ) + } + IconButton(onClick = onDelete) { + Icon( + imageVector = Icons.Default.Delete, + contentDescription = stringResource( + R.string.search_delete_selected, + ), + tint = MaterialTheme.colorScheme.error, + ) + } + } else if (query.isNotEmpty()) { + IconButton(onClick = { onQueryChange("") }) { + Icon( + imageVector = Icons.Default.Close, + contentDescription = stringResource(R.string.search_clear), + ) + } + } + } } }, - colors = TopAppBarDefaults.topAppBarColors( - containerColor = MaterialTheme.colorScheme.surfaceContainerHigh, - ), + colors = TopAppBarDefaults.topAppBarColors(containerColor = container), ) }