From 7c53768c015892c4e476ca8a08f96690e3120cfc Mon Sep 17 00:00:00 2001 From: Sad Ellie Date: Sat, 14 Oct 2023 20:45:29 +0300 Subject: [PATCH] Update search UX/UI --- .../unitto/core/ui/common/UnittoSearchBar.kt | 217 +++++++++++------- .../feature/converter/LeftSideScreen.kt | 31 +-- .../feature/converter/RightSideScreen.kt | 8 +- .../converter/components/FavoritesButton.kt | 4 +- .../feature/timezone/AddTimeZoneScreen.kt | 13 +- 5 files changed, 150 insertions(+), 123 deletions(-) diff --git a/core/ui/src/main/java/com/sadellie/unitto/core/ui/common/UnittoSearchBar.kt b/core/ui/src/main/java/com/sadellie/unitto/core/ui/common/UnittoSearchBar.kt index c8679b6c..daa82ed2 100644 --- a/core/ui/src/main/java/com/sadellie/unitto/core/ui/common/UnittoSearchBar.kt +++ b/core/ui/src/main/java/com/sadellie/unitto/core/ui/common/UnittoSearchBar.kt @@ -20,44 +20,60 @@ package com.sadellie.unitto.core.ui.common import androidx.activity.compose.BackHandler import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.Crossfade import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.requiredHeight +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.text.BasicTextField import androidx.compose.foundation.text.KeyboardActionScope import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.outlined.ArrowBack import androidx.compose.material.icons.filled.Search import androidx.compose.material.icons.outlined.Clear +import androidx.compose.material.ripple.rememberRipple import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text -import androidx.compose.material3.TopAppBar -import androidx.compose.material3.TopAppBarColors import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.TopAppBarScrollBehavior +import androidx.compose.material3.surfaceColorAtElevation import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.SideEffect import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.clip +import androidx.compose.ui.focus.FocusManager import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.dp import com.sadellie.unitto.core.base.R +import kotlin.math.roundToInt @Composable fun UnittoSearchBar( @@ -65,73 +81,67 @@ fun UnittoSearchBar( query: TextFieldValue, onQueryChange: (TextFieldValue) -> Unit, navigateUp: () -> Unit, - title: String, - searchActions: @Composable (RowScope.() -> Unit) = {}, - noSearchActions: @Composable (RowScope.() -> Unit) = {}, - placeholder: String = stringResource(R.string.search_text_field_placeholder), - scrollBehavior: TopAppBarScrollBehavior? = null, - colors: TopAppBarColors = TopAppBarDefaults.topAppBarColors() + focusManager: FocusManager = LocalFocusManager.current, + onSearch: KeyboardActionScope.() -> Unit = { focusManager.clearFocus() }, + focusRequester: FocusRequester = remember { FocusRequester() }, + trailingIcon: @Composable () -> Unit = { SearchButton { focusManager.clearFocus() } }, + scrollBehavior: TopAppBarScrollBehavior, ) { - var showSearchInput by remember { mutableStateOf(false) } - val focusRequester = remember { FocusRequester() } + val notEmpty = remember(query) { query.text.isNotEmpty() } + fun clear() = onQueryChange(TextFieldValue()) + LaunchedEffect(Unit) { focusRequester.requestFocus() } + LaunchedEffect(scrollBehavior.state.overlappedFraction) { + if (scrollBehavior.state.collapsedFraction > 0.5f) focusManager.clearFocus() + } + BackHandler(notEmpty, ::clear) - LaunchedEffect(showSearchInput) { - if (showSearchInput) focusRequester.requestFocus() else onQueryChange(TextFieldValue()) - } + val heightOffsetLimit = with(LocalDensity.current) { -(UnittoSearchBarTokens.UnittoSearchBarFullHeight).toPx() } + SideEffect { + if (scrollBehavior.state.heightOffsetLimit != heightOffsetLimit) { + scrollBehavior.state.heightOffsetLimit = heightOffsetLimit + } + } + val height = LocalDensity.current.run { + UnittoSearchBarTokens.UnittoSearchBarFullHeight + scrollBehavior.state.heightOffset.toDp() + } - BackHandler(showSearchInput) { showSearchInput = false } + Box( + modifier = modifier + .statusBarsPadding() + .height(height), + contentAlignment = Alignment.Center + ) { + Row( + modifier = Modifier + .offset { IntOffset(x = 0, y = scrollBehavior.state.heightOffset.roundToInt()) } + .padding(horizontal = UnittoSearchBarTokens.UnittoSearchBarPadding) + .requiredHeight(UnittoSearchBarTokens.UnittoSearchBarHeight) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.surfaceColorAtElevation(6.dp)) + .fillMaxWidth() + .padding(horizontal = 16.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(16.dp) + ) { + ProvideColor(MaterialTheme.colorScheme.onSurface) { - Crossfade( - modifier = modifier, - targetState = showSearchInput, - label = "Search input" - ) { showSearch -> - if (showSearch) { - TopAppBar( - title = { - SearchTextField( - modifier = Modifier - .focusRequester(focusRequester) - .fillMaxWidth(), - value = query, - placeholder = placeholder, - onValueChange = onQueryChange, - onSearch = {} - ) - }, - navigationIcon = { - NavigateUpButton { showSearchInput = false } - }, - actions = { - Row(verticalAlignment = Alignment.CenterVertically) { - ClearButton(visible = query.text.isNotEmpty()) { onQueryChange(TextFieldValue()) } - searchActions() - } - }, - scrollBehavior = scrollBehavior, - colors = colors, - ) - } else { - TopAppBar( - title = { - Text( - text = title, - modifier = Modifier.fillMaxWidth(), - style = MaterialTheme.typography.titleLarge - ) - }, - navigationIcon = { - NavigateUpButton { navigateUp() } - }, - actions = { - Row(verticalAlignment = Alignment.CenterVertically) { - SearchButton { showSearchInput = true } - noSearchActions() - } - }, - scrollBehavior = scrollBehavior, - colors = colors, - ) + NavigateButton { if (notEmpty) clear() else navigateUp() } + + SearchTextField( + modifier = Modifier + .focusRequester(focusRequester) + .fillMaxWidth() + .weight(1f), + value = query, + placeholder = stringResource(R.string.search_text_field_placeholder), + onValueChange = onQueryChange, + onSearch = onSearch + ) + + ClearButton(notEmpty, ::clear) + + trailingIcon() + } } } } @@ -149,7 +159,7 @@ private fun SearchTextField( value = value, onValueChange = onValueChange, singleLine = true, - textStyle = MaterialTheme.typography.titleLarge.copy(color = MaterialTheme.colorScheme.onSurface), + textStyle = MaterialTheme.typography.bodyLarge.copy(color = MaterialTheme.colorScheme.onSurface), cursorBrush = SolidColor(MaterialTheme.colorScheme.onSurface), keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search), keyboardActions = KeyboardActions(onSearch = onSearch), @@ -160,7 +170,7 @@ private fun SearchTextField( Text( modifier = Modifier.alpha(0.7f), text = placeholder, - style = MaterialTheme.typography.titleLarge, + style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.onSurface ) } @@ -172,25 +182,37 @@ private fun SearchTextField( private fun SearchButton( onClick: () -> Unit ) { - IconButton(onClick) { + SearchBarIconButton(onClick) { Icon( - Icons.Default.Search, + imageVector = Icons.Default.Search, contentDescription = stringResource(R.string.search_button_description) ) } } +@Composable +private fun NavigateButton( + onClick: () -> Unit +) { + SearchBarIconButton(onClick) { + Icon( + imageVector = Icons.AutoMirrored.Outlined.ArrowBack, + contentDescription = stringResource(R.string.navigate_up_description) + ) + } +} + @Composable private fun ClearButton( visible: Boolean, onClick: () -> Unit ) { - IconButton(onClick = onClick) { - AnimatedVisibility( - visible = visible, - enter = fadeIn(), - exit = fadeOut() - ) { + AnimatedVisibility( + visible = visible, + enter = fadeIn(), + exit = fadeOut() + ) { + SearchBarIconButton(onClick) { Icon( imageVector = Icons.Outlined.Clear, contentDescription = stringResource(R.string.clear_input_description) @@ -199,6 +221,36 @@ private fun ClearButton( } } +@Composable +fun SearchBarIconButton( + onClick: () -> Unit, + content: @Composable () -> Unit +) { + Box( + modifier = Modifier + .size(24.dp) + .clickable( + onClick = onClick, + enabled = true, + role = Role.Button, + interactionSource = remember { MutableInteractionSource() }, + indication = rememberRipple( + bounded = false, + radius = 20.dp + ) + ), + contentAlignment = Alignment.Center + ) { + content() + } +} + +private object UnittoSearchBarTokens { + val UnittoSearchBarHeight = 56.dp + val UnittoSearchBarPadding = 8.dp + val UnittoSearchBarFullHeight = UnittoSearchBarHeight + UnittoSearchBarPadding * 2 +} + @Preview @Composable fun UnittoSearchBarPreview() { @@ -206,7 +258,6 @@ fun UnittoSearchBarPreview() { query = TextFieldValue("test"), onQueryChange = {}, navigateUp = {}, - title = "Title", - placeholder = "placeholder" + scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior() ) } diff --git a/feature/converter/src/main/java/com/sadellie/unitto/feature/converter/LeftSideScreen.kt b/feature/converter/src/main/java/com/sadellie/unitto/feature/converter/LeftSideScreen.kt index f7b0f79c..ec09e4e1 100644 --- a/feature/converter/src/main/java/com/sadellie/unitto/feature/converter/LeftSideScreen.kt +++ b/feature/converter/src/main/java/com/sadellie/unitto/feature/converter/LeftSideScreen.kt @@ -19,9 +19,6 @@ package com.sadellie.unitto.feature.converter import androidx.compose.animation.Crossfade -import androidx.compose.animation.animateColorAsState -import androidx.compose.animation.core.LinearOutSlowInEasing -import androidx.compose.animation.core.tween import androidx.compose.foundation.background import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize @@ -32,19 +29,13 @@ import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.TopAppBarDefaults -import androidx.compose.material3.rememberTopAppBarState -import androidx.compose.material3.surfaceColorAtElevation import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.derivedStateOf -import androidx.compose.runtime.getValue -import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.sadellie.unitto.core.base.R import com.sadellie.unitto.core.ui.common.UnittoEmptyScreen @@ -96,16 +87,7 @@ private fun LeftSideScreen( navigateUp: () -> Unit, navigateToUnitGroups: () -> Unit, ) { - val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState()) - val elevatedColor = MaterialTheme.colorScheme.surfaceColorAtElevation(3.dp) - val needToTint by remember { - derivedStateOf { scrollBehavior.state.overlappedFraction > 0.01f } - } - val chipsBackground = animateColorAsState( - targetValue = if (needToTint) elevatedColor else MaterialTheme.colorScheme.surface, - animationSpec = tween(durationMillis = 500, easing = LinearOutSlowInEasing), - label = "Chips background", - ) + val scrollBehavior = TopAppBarDefaults.enterAlwaysScrollBehavior() val chipsRowLazyListState = rememberLazyListState() @@ -124,19 +106,18 @@ private fun LeftSideScreen( modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection), topBar = { Column( - Modifier.background(chipsBackground.value) + Modifier.background(MaterialTheme.colorScheme.surface) ) { UnittoSearchBar( query = uiState.query, onQueryChange = onQueryChange, navigateUp = navigateUp, - title = stringResource(R.string.converter_left_side_title), - placeholder = stringResource(R.string.converter_search_bar_placeholder), - noSearchActions = { + trailingIcon = { FavoritesButton(uiState.favorites) { toggleFavoritesOnly(!uiState.favorites) } - } + }, + scrollBehavior = scrollBehavior ) if (uiState.verticalList) { @@ -213,7 +194,7 @@ private fun LeftSideScreenPreview() { uiState = LeftSideUIState.Ready( unitFrom = units.values.first().first(), units = units, - query = TextFieldValue(), + query = TextFieldValue("test"), favorites = false, shownUnitGroups = listOf(UnitGroup.LENGTH, UnitGroup.TEMPERATURE, UnitGroup.CURRENCY), unitGroup = units.keys.toList().first(), diff --git a/feature/converter/src/main/java/com/sadellie/unitto/feature/converter/RightSideScreen.kt b/feature/converter/src/main/java/com/sadellie/unitto/feature/converter/RightSideScreen.kt index 842781ab..14f3bc26 100644 --- a/feature/converter/src/main/java/com/sadellie/unitto/feature/converter/RightSideScreen.kt +++ b/feature/converter/src/main/java/com/sadellie/unitto/feature/converter/RightSideScreen.kt @@ -25,7 +25,6 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.material3.Scaffold import androidx.compose.material3.TopAppBarDefaults -import androidx.compose.material3.rememberTopAppBarState import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.input.nestedscroll.nestedScroll @@ -86,7 +85,7 @@ private fun RightSideScreen( navigateUp: () -> Unit, navigateToUnitGroups: () -> Unit, ) { - val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState()) + val scrollBehavior = TopAppBarDefaults.enterAlwaysScrollBehavior() Scaffold( modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection), @@ -95,9 +94,7 @@ private fun RightSideScreen( query = uiState.query, onQueryChange = onQueryChange, navigateUp = navigateUp, - title = stringResource(R.string.converter_right_side_title), - placeholder = stringResource(R.string.converter_search_bar_placeholder), - noSearchActions = { + trailingIcon = { FavoritesButton(uiState.favorites) { toggleFavoritesOnly(!uiState.favorites) } @@ -229,4 +226,3 @@ private fun RightSideScreenPreview() { navigateToUnitGroups = {} ) } - diff --git a/feature/converter/src/main/java/com/sadellie/unitto/feature/converter/components/FavoritesButton.kt b/feature/converter/src/main/java/com/sadellie/unitto/feature/converter/components/FavoritesButton.kt index a1911472..e2a9b3ff 100644 --- a/feature/converter/src/main/java/com/sadellie/unitto/feature/converter/components/FavoritesButton.kt +++ b/feature/converter/src/main/java/com/sadellie/unitto/feature/converter/components/FavoritesButton.kt @@ -27,17 +27,17 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Favorite import androidx.compose.material.icons.filled.FavoriteBorder import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton import androidx.compose.runtime.Composable import androidx.compose.ui.res.stringResource import com.sadellie.unitto.core.base.R +import com.sadellie.unitto.core.ui.common.SearchBarIconButton @Composable internal fun FavoritesButton( state: Boolean, onClick: () -> Unit ) { - IconButton(onClick = onClick) { + SearchBarIconButton(onClick = onClick) { AnimatedContent( targetState = state, transitionSpec = { diff --git a/feature/timezone/src/main/java/com/sadellie/unitto/feature/timezone/AddTimeZoneScreen.kt b/feature/timezone/src/main/java/com/sadellie/unitto/feature/timezone/AddTimeZoneScreen.kt index 5928ffec..fd3f1262 100644 --- a/feature/timezone/src/main/java/com/sadellie/unitto/feature/timezone/AddTimeZoneScreen.kt +++ b/feature/timezone/src/main/java/com/sadellie/unitto/feature/timezone/AddTimeZoneScreen.kt @@ -27,24 +27,23 @@ import android.text.format.DateFormat.is24HourFormat import androidx.annotation.RequiresApi import androidx.compose.animation.Crossfade import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.material3.TopAppBarDefaults -import androidx.compose.material3.rememberTopAppBarState import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.tooling.preview.Preview import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.sadellie.unitto.core.base.R import com.sadellie.unitto.core.ui.LocalLocale import com.sadellie.unitto.core.ui.common.UnittoEmptyScreen import com.sadellie.unitto.core.ui.common.UnittoListItem @@ -84,7 +83,7 @@ fun AddTimeZoneScreen( addToFavorites: (TimeZone) -> Unit, userTime: ZonedDateTime, ) { - val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState()) + val scrollBehavior = TopAppBarDefaults.enterAlwaysScrollBehavior() val locale = LocalLocale.current val is24Hour = is24HourFormat(LocalContext.current) @@ -95,19 +94,19 @@ fun AddTimeZoneScreen( query = uiState.query, onQueryChange = onQueryChange, navigateUp = navigateUp, - title = stringResource(R.string.time_zone_add_title), - scrollBehavior = scrollBehavior + scrollBehavior = scrollBehavior, ) }, ) { paddingValues -> Crossfade( + modifier = Modifier.padding(paddingValues), targetState = uiState.list.isEmpty(), label = "Placeholder" ) { empty -> if (empty) { UnittoEmptyScreen() } else { - LazyColumn(contentPadding = paddingValues) { + LazyColumn(Modifier.fillMaxSize()) { items(uiState.list, { it.timeZone.id }) { UnittoListItem( modifier = Modifier