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.
This commit is contained in:
2026-08-08 15:52:45 +02:00
parent 7ab8fcb5f5
commit bfe493e5c9

View File

@@ -5,12 +5,15 @@ import android.content.pm.PackageManager
import androidx.activity.compose.BackHandler import androidx.activity.compose.BackHandler
import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts 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.background
import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth 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.AlertDialog
import androidx.compose.material3.Checkbox import androidx.compose.material3.Checkbox
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
@@ -43,6 +47,7 @@ import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue 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.EventInstance
import de.jeanlucmakiola.calendula.domain.RecurringWriteScope import de.jeanlucmakiola.calendula.domain.RecurringWriteScope
import de.jeanlucmakiola.floret.identity.animateItemMotion import de.jeanlucmakiola.floret.identity.animateItemMotion
import de.jeanlucmakiola.floret.identity.fadeThrough
import de.jeanlucmakiola.floret.identity.predictiveBack import de.jeanlucmakiola.floret.identity.predictiveBack
import de.jeanlucmakiola.floret.components.GroupedRow import de.jeanlucmakiola.floret.components.GroupedRow
import de.jeanlucmakiola.floret.components.InlineTextField import de.jeanlucmakiola.floret.components.InlineTextField
@@ -160,51 +166,18 @@ fun SearchScreen(
modifier = modifier.predictiveBack(onBack = onBack, enabled = !inSelection), modifier = modifier.predictiveBack(onBack = onBack, enabled = !inSelection),
containerColor = MaterialTheme.colorScheme.surface, containerColor = MaterialTheme.colorScheme.surface,
topBar = { topBar = {
if (inSelection) { SearchTopBar(
SelectionTopBar( inSelection = inSelection,
count = selection.size, query = query,
onClose = viewModel::clearSelection, selectedCount = selection.size,
onSelectAll = viewModel::selectAll, focusRequester = focusRequester,
onDelete = { requireWrite { showDeleteDialog = true } }, onQueryChange = viewModel::setQuery,
) onImeAction = { keyboard?.hide() },
} else { onBack = onBack,
TopAppBar( onCloseSelection = viewModel::clearSelection,
title = { onSelectAll = viewModel::selectAll,
InlineTextField( onDelete = { requireWrite { showDeleteDialog = true } },
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,
),
)
}
}, },
) { padding -> ) { padding ->
// imePadding shrinks the content by the keyboard, so the centered // 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 @Composable
private fun SelectionTopBar( private fun SearchTopBar(
count: Int, inSelection: Boolean,
onClose: () -> Unit, query: String,
selectedCount: Int,
focusRequester: FocusRequester,
onQueryChange: (String) -> Unit,
onImeAction: () -> Unit,
onBack: () -> Unit,
onCloseSelection: () -> Unit,
onSelectAll: () -> Unit, onSelectAll: () -> Unit,
onDelete: () -> 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( 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 = { navigationIcon = {
IconButton(onClick = onClose) { AnimatedContent(
Icon( targetState = inSelection,
imageVector = Icons.Default.Close, transitionSpec = { slotSwap },
contentDescription = stringResource(R.string.search_selection_close), 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 = { actions = {
IconButton(onClick = onSelectAll) { AnimatedContent(
Icon( targetState = inSelection,
imageVector = Icons.Default.SelectAll, transitionSpec = { slotSwap },
contentDescription = stringResource(R.string.search_select_all), label = "search-top-bar-actions",
) ) { selecting ->
} Row {
IconButton(onClick = onDelete) { if (selecting) {
Icon( IconButton(onClick = onSelectAll) {
imageVector = Icons.Default.Delete, Icon(
contentDescription = stringResource(R.string.search_delete_selected), imageVector = Icons.Default.SelectAll,
tint = MaterialTheme.colorScheme.error, 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( colors = TopAppBarDefaults.topAppBarColors(containerColor = container),
containerColor = MaterialTheme.colorScheme.surfaceContainerHigh,
),
) )
} }