From 82738a8f03f94b1aaac906057d1891e44d9462b2 Mon Sep 17 00:00:00 2001 From: Thomas Andres Gomez Date: Mon, 31 Jul 2023 10:21:40 +0200 Subject: [PATCH] Small UX fix. Trim on search. keyboard action done focus. maxline on search. collapsing header on search. remove keyboard hide on search scroll. --- .../rplexicon/ui/composable/AsyncImage.kt | 57 ++++++++ .../ui/composable/CollapsingHeader.kt | 92 +++++++++++++ .../ui/composable/ScrollingKeyboardHanlder.kt | 18 --- .../rplexicon/ui/composable/form/TextField.kt | 15 ++ .../screens/detail/CharacterDetailScreen.kt | 53 ++++--- .../rplexicon/ui/screens/search/SearchItem.kt | 7 +- .../ui/screens/search/SearchScreen.kt | 130 +++++++++++------- .../ui/screens/search/SearchViewModel.kt | 10 +- .../rplexicon/utilitary/GlideHelp.kt | 34 +++-- 9 files changed, 303 insertions(+), 113 deletions(-) create mode 100644 app/src/main/java/com/pixelized/rplexicon/ui/composable/AsyncImage.kt create mode 100644 app/src/main/java/com/pixelized/rplexicon/ui/composable/CollapsingHeader.kt delete mode 100644 app/src/main/java/com/pixelized/rplexicon/ui/composable/ScrollingKeyboardHanlder.kt diff --git a/app/src/main/java/com/pixelized/rplexicon/ui/composable/AsyncImage.kt b/app/src/main/java/com/pixelized/rplexicon/ui/composable/AsyncImage.kt new file mode 100644 index 0000000..68d934d --- /dev/null +++ b/app/src/main/java/com/pixelized/rplexicon/ui/composable/AsyncImage.kt @@ -0,0 +1,57 @@ +package com.pixelized.rplexicon.ui.composable + +import androidx.annotation.DrawableRes +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Stable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.graphics.painter.Painter +import com.bumptech.glide.request.RequestListener +import com.pixelized.rplexicon.utilitary.rememberLoadingTransition +import com.skydoves.landscapist.ImageOptions +import com.skydoves.landscapist.InternalLandscapistApi +import com.skydoves.landscapist.components.ImageComponent +import com.skydoves.landscapist.components.rememberImageComponent +import com.skydoves.landscapist.glide.GlideImage +import com.skydoves.landscapist.glide.GlideImageState +import com.skydoves.landscapist.glide.GlideRequestType + +@Composable +fun AsyncImage( + imageModel: () -> Any?, + modifier: Modifier = Modifier, + glideRequestType: GlideRequestType = GlideRequestType.DRAWABLE, + requestListener: (() -> RequestListener)? = null, + component: ImageComponent = rememberImageComponent {}, + imageOptions: ImageOptions = ImageOptions(), + onImageStateChanged: (GlideImageState) -> Unit = {}, + @DrawableRes previewPlaceholder: Int = 0, + loading: @Composable (BoxScope.(imageState: GlideImageState.Loading) -> Unit)? = null, + success: @Composable (BoxScope.(imageState: GlideImageState.Success, painter: Painter) -> Unit)? = null, + failure: @Composable (BoxScope.(imageState: GlideImageState.Failure) -> Unit)? = null, +) { + val transition = rememberLoadingTransition(imageModel) + + GlideImage( + imageModel = imageModel, + modifier = modifier.alpha(alpha = transition.alpha), + glideRequestType = glideRequestType, + requestListener = requestListener, + component = component, + imageOptions = imageOptions, + onImageStateChanged = { + when (it) { + is GlideImageState.Success -> transition.target = 1f + is GlideImageState.Failure -> transition.target = 1f + is GlideImageState.Loading -> transition.target = 0f + is GlideImageState.None -> transition.target = 0f + } + onImageStateChanged(it) + }, + previewPlaceholder = previewPlaceholder, + loading = loading, + success = success, + failure = failure, + ) +} \ No newline at end of file diff --git a/app/src/main/java/com/pixelized/rplexicon/ui/composable/CollapsingHeader.kt b/app/src/main/java/com/pixelized/rplexicon/ui/composable/CollapsingHeader.kt new file mode 100644 index 0000000..15a548e --- /dev/null +++ b/app/src/main/java/com/pixelized/rplexicon/ui/composable/CollapsingHeader.kt @@ -0,0 +1,92 @@ +package com.pixelized.rplexicon.ui.composable + +import androidx.compose.animation.core.animateIntAsState +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.input.nestedscroll.NestedScrollConnection +import androidx.compose.ui.input.nestedscroll.NestedScrollSource +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.layout.Layout +import androidx.compose.ui.layout.Measurable +import androidx.compose.ui.layout.layoutId +import androidx.compose.ui.unit.Velocity + +private val List.grid: Measurable get() = first { it.layoutId == GRID_ID } +private val List.header: Measurable get() = first { it.layoutId == HEADER_ID } + +private const val GRID_ID = "GRID_ID" +private const val HEADER_ID = "HEADER_ID" + +@Composable +fun CollapsingHeader( + modifier: Modifier = Modifier, + header: @Composable () -> Unit, + content: @Composable () -> Unit +) { + val headerHeight = rememberSaveable { mutableStateOf(0) } + val headerScroll = rememberSaveable { mutableStateOf(0) } + + val animatedHeaderScroll = animateIntAsState( + targetValue = headerScroll.value, + label = "HeaderSnapAnimation", + ) + + val nestedScrollConnection = remember { + object : NestedScrollConnection { + override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset { + val scroll = (headerScroll.value + available.y.toInt()).coerceIn( + minimumValue = -headerHeight.value, + maximumValue = 0, + ) + return if (headerScroll.value != scroll) { + headerScroll.value = scroll + available + } else { + Offset.Zero + } + } + + override suspend fun onPostFling(consumed: Velocity, available: Velocity): Velocity { + headerScroll.value = if (headerScroll.value < -headerHeight.value / 2) { + -headerHeight.value + } else { + 0 + } + return super.onPostFling(consumed, available) + } + } + } + + Layout( + modifier = modifier.nestedScroll(nestedScrollConnection), + content = { + Box( + modifier = Modifier.layoutId(HEADER_ID), + content = { header() }, + ) + Box( + modifier = Modifier.layoutId(GRID_ID), + content = { content() }, + ) + }, + measurePolicy = { measurables, constraints -> + val headerPlaceable = measurables.header.measure(constraints) + val gridPlaceable = measurables.grid.measure(constraints) + + if (headerHeight.value == 0) { + headerHeight.value = headerPlaceable.height + } + + layout(constraints.maxWidth, constraints.maxHeight) { + headerPlaceable.place(0, animatedHeaderScroll.value) + gridPlaceable.place(0, headerPlaceable.height + animatedHeaderScroll.value) + } + } + ) +} \ No newline at end of file diff --git a/app/src/main/java/com/pixelized/rplexicon/ui/composable/ScrollingKeyboardHanlder.kt b/app/src/main/java/com/pixelized/rplexicon/ui/composable/ScrollingKeyboardHanlder.kt deleted file mode 100644 index 2b0c612..0000000 --- a/app/src/main/java/com/pixelized/rplexicon/ui/composable/ScrollingKeyboardHanlder.kt +++ /dev/null @@ -1,18 +0,0 @@ -package com.pixelized.rplexicon.ui.composable - -import androidx.compose.foundation.lazy.LazyListState -import androidx.compose.runtime.Composable -import androidx.compose.ui.ExperimentalComposeUiApi -import androidx.compose.ui.platform.LocalSoftwareKeyboardController - -@OptIn(ExperimentalComposeUiApi::class) -@Composable -fun ScrollingKeyboardHandler( - lazyListState: LazyListState -) { - val keyboard = LocalSoftwareKeyboardController.current - - if (lazyListState.isScrollInProgress) { - keyboard?.hide() - } -} \ No newline at end of file diff --git a/app/src/main/java/com/pixelized/rplexicon/ui/composable/form/TextField.kt b/app/src/main/java/com/pixelized/rplexicon/ui/composable/form/TextField.kt index 7cce867..1f37477 100644 --- a/app/src/main/java/com/pixelized/rplexicon/ui/composable/form/TextField.kt +++ b/app/src/main/java/com/pixelized/rplexicon/ui/composable/form/TextField.kt @@ -9,6 +9,8 @@ import androidx.compose.animation.fadeOut import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme @@ -22,8 +24,11 @@ import androidx.compose.runtime.State import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameterProvider @@ -52,6 +57,8 @@ fun TextField( modifier: Modifier = Modifier, field: TextFieldUio, ) { + val focus = LocalFocusManager.current + OutlinedTextField( modifier = modifier, shape = MaterialTheme.lexicon.shapes.textField, @@ -77,6 +84,14 @@ fun TextField( } } }, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Text, + imeAction = ImeAction.Done, + ), + keyboardActions = KeyboardActions( + onDone = { focus.clearFocus(true) }, + ), + maxLines = 1, value = field.value.value, onValueChange = field.onValueChange, ) diff --git a/app/src/main/java/com/pixelized/rplexicon/ui/screens/detail/CharacterDetailScreen.kt b/app/src/main/java/com/pixelized/rplexicon/ui/screens/detail/CharacterDetailScreen.kt index 40db692..ce7cebd 100644 --- a/app/src/main/java/com/pixelized/rplexicon/ui/screens/detail/CharacterDetailScreen.kt +++ b/app/src/main/java/com/pixelized/rplexicon/ui/screens/detail/CharacterDetailScreen.kt @@ -38,7 +38,6 @@ import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.composed -import androidx.compose.ui.draw.alpha import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color @@ -48,6 +47,7 @@ import androidx.compose.ui.graphics.Shadow import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalView import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.AnnotatedString @@ -59,16 +59,14 @@ import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import com.pixelized.rplexicon.R import com.pixelized.rplexicon.model.Lexicon +import com.pixelized.rplexicon.ui.composable.AsyncImage import com.pixelized.rplexicon.ui.navigation.LocalScreenNavHost import com.pixelized.rplexicon.ui.theme.LexiconTheme import com.pixelized.rplexicon.utilitary.composable.stringResource import com.pixelized.rplexicon.utilitary.extentions.annotatedSpan import com.pixelized.rplexicon.utilitary.extentions.annotatedString import com.pixelized.rplexicon.utilitary.extentions.highlightRegex -import com.pixelized.rplexicon.utilitary.rememberLoadingTransition import com.skydoves.landscapist.ImageOptions -import com.skydoves.landscapist.glide.GlideImage -import com.skydoves.landscapist.glide.GlideImageState @Stable data class CharacterDetailUio( @@ -200,16 +198,8 @@ private fun CharacterDetailScreenContent( .aspectRatio(ratio = 1f) .scrollOffset(scrollState = state) { -it / 2 }, ) { - val transition = rememberLoadingTransition { uri } - GlideImage( - modifier = Modifier - .matchParentSize() - .alpha(alpha = transition.alpha), - onImageStateChanged = { - if (it is GlideImageState.Success) { - transition.target = 1f - } - }, + AsyncImage( + modifier = Modifier.matchParentSize(), imageOptions = ImageOptions( alignment = Alignment.TopCenter, contentScale = ContentScale.Crop, @@ -299,8 +289,7 @@ private fun CharacterDetailScreenContent( ) } if (annotatedItem.portrait.isNotEmpty()) { - val configuration = LocalConfiguration.current - val maxSize = remember { (configuration.screenWidthDp.dp - 16.dp * 2) } + val maxSize = rememberPortraitWidth() Text( modifier = Modifier.padding(start = 16.dp, top = 24.dp, end = 16.dp), style = typography.titleMedium, @@ -308,19 +297,16 @@ private fun CharacterDetailScreenContent( ) LazyRow( contentPadding = PaddingValues(horizontal = 16.dp), - horizontalArrangement = Arrangement.spacedBy(8.dp) + horizontalArrangement = Arrangement.spacedBy(8.dp), ) { items(items = annotatedItem.portrait) { - val transition = rememberLoadingTransition { it } - GlideImage( - modifier = Modifier - .sizeIn(maxWidth = maxSize, maxHeight = maxSize) - .alpha(alpha = transition.alpha), - onImageStateChanged = { - if (it is GlideImageState.Success) { - transition.target = 1f - } - }, + AsyncImage( + modifier = Modifier.sizeIn( + minWidth = maxSize / 2, + maxWidth = maxSize, + minHeight = maxSize, + maxHeight = maxSize, + ), imageOptions = ImageOptions( contentScale = ContentScale.FillHeight ), @@ -348,6 +334,19 @@ private fun rememberBackgroundGradient(): Brush { } } +@Composable +private fun rememberPortraitWidth(): Dp { + val configuration = LocalConfiguration.current + val view = LocalView.current + return remember(configuration, view) { + if (view.isInEditMode) { + 300.dp + } else { + (configuration.screenWidthDp.dp - 16.dp * 2) + } + } +} + @Stable private fun Modifier.scrollOffset( scrollState: ScrollState, diff --git a/app/src/main/java/com/pixelized/rplexicon/ui/screens/search/SearchItem.kt b/app/src/main/java/com/pixelized/rplexicon/ui/screens/search/SearchItem.kt index 46e560c..66eaf43 100644 --- a/app/src/main/java/com/pixelized/rplexicon/ui/screens/search/SearchItem.kt +++ b/app/src/main/java/com/pixelized/rplexicon/ui/screens/search/SearchItem.kt @@ -99,12 +99,13 @@ class AnnotatedSearchItemUio( private fun SearchItemUio.annotate(): AnnotatedSearchItemUio { val colorScheme = MaterialTheme.colorScheme val highlight = remember { SpanStyle(color = colorScheme.primary) } - val highlightRegex = remember(search) { search.highlightRegex } - val finderRegex = remember(search) { search.finderRegex } + val trimmedSearch = remember(search) { search.trim() } + val highlightRegex = remember(search) { trimmedSearch.highlightRegex } + val finderRegex = remember(search) { trimmedSearch.finderRegex } val gender = stringResource(id = gender, short = true) val race = stringResource(id = race) - return remember(search, race, highlightRace, gender, highlightGender) { + return remember(trimmedSearch, race, highlightRace, gender, highlightGender) { AnnotatedSearchItemUio( id = id, name = AnnotatedString( diff --git a/app/src/main/java/com/pixelized/rplexicon/ui/screens/search/SearchScreen.kt b/app/src/main/java/com/pixelized/rplexicon/ui/screens/search/SearchScreen.kt index 39d4057..54ed822 100644 --- a/app/src/main/java/com/pixelized/rplexicon/ui/screens/search/SearchScreen.kt +++ b/app/src/main/java/com/pixelized/rplexicon/ui/screens/search/SearchScreen.kt @@ -41,7 +41,7 @@ import androidx.hilt.navigation.compose.hiltViewModel import com.pixelized.rplexicon.NO_WINDOW_INSETS import com.pixelized.rplexicon.R import com.pixelized.rplexicon.model.Lexicon -import com.pixelized.rplexicon.ui.composable.ScrollingKeyboardHandler +import com.pixelized.rplexicon.ui.composable.CollapsingHeader import com.pixelized.rplexicon.ui.composable.form.DropDownField import com.pixelized.rplexicon.ui.composable.form.DropDownFieldUio import com.pixelized.rplexicon.ui.composable.form.TextField @@ -88,9 +88,6 @@ fun SearchScreen( screen.popBackStack() } ) - ScrollingKeyboardHandler( - lazyListState = lazyState, - ) } } @@ -125,50 +122,64 @@ private fun SearchScreenContent( ) }, ) { paddingValues -> - LazyColumn( - modifier = Modifier - .fillMaxSize() - .padding(paddingValues = paddingValues), - state = lazyColumnState, - contentPadding = PaddingValues(vertical = 8.dp), - ) { - item { - Column( + CollapsingHeader( + modifier = Modifier.padding(paddingValues = paddingValues), + header = { + SearchBox( modifier = Modifier.padding(horizontal = 16.dp), - verticalArrangement = Arrangement.spacedBy(2.dp), + form = form, + ) + }, + content = { + LazyColumn( + modifier = Modifier.fillMaxSize(), + state = lazyColumnState, + contentPadding = PaddingValues(vertical = 8.dp), ) { - TextField( - modifier = Modifier.fillMaxWidth(), - field = form.search, - ) - Row( - horizontalArrangement = Arrangement.spacedBy(8.dp), + items( + items = items.value, + key = { it.id }, + contentType = { "Search" }, ) { - DropDownField( - modifier = Modifier.weight(1f), - field = form.gender, - ) - DropDownField( - modifier = Modifier.weight(1f), - field = form.race, + SearchItem( + modifier = Modifier + .clickable { onItem(it) } + .heightIn(min = MaterialTheme.lexicon.dimens.item), + item = it, ) } - Divider(modifier = Modifier.padding(top = 16.dp, bottom = 8.dp)) } - } - items( - items = items.value, - key = { it.id }, - contentType = { "Search" }, - ) { - SearchItem( - modifier = Modifier - .clickable { onItem(it) } - .heightIn(min = MaterialTheme.lexicon.dimens.item), - item = it, - ) - } + }, + ) + } +} + +@Composable +private fun SearchBox( + modifier: Modifier = Modifier, + form: SearchFormUio, +) { + Column( + modifier = modifier, + verticalArrangement = Arrangement.spacedBy(2.dp), + ) { + TextField( + modifier = Modifier.fillMaxWidth(), + field = form.search, + ) + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + DropDownField( + modifier = Modifier.weight(1f), + field = form.gender, + ) + DropDownField( + modifier = Modifier.weight(1f), + field = form.race, + ) } + Divider(modifier = Modifier.padding(top = 16.dp, bottom = 8.dp)) } } @@ -196,18 +207,41 @@ private fun SearchScreenContentPreview() { items = remember { mutableStateOf( listOf( - SearchItemUio( + SearchItemUio.preview( id = 0, name = "Brulkhai", diminutive = "Bru", gender = Lexicon.Gender.FEMALE, race = Lexicon.Race.HALF_ORC, - description = null, - history = null, - search = "", - highlightGender = false, - highlightRace = false, - ) + ), + SearchItemUio.preview( + id = 0, + name = "Léandre", + diminutive = null, + gender = Lexicon.Gender.MALE, + race = Lexicon.Race.HUMAN, + ), + SearchItemUio.preview( + id = 0, + name = "Nélia", + diminutive = null, + gender = Lexicon.Gender.FEMALE, + race = Lexicon.Race.ELF, + ), + SearchItemUio.preview( + id = 0, + name = "Tigrane", + diminutive = null, + gender = Lexicon.Gender.MALE, + race = Lexicon.Race.TIEFLING, + ), + SearchItemUio.preview( + id = 0, + name = "Unathana", + diminutive = "Una", + gender = Lexicon.Gender.FEMALE, + race = Lexicon.Race.HALF_ELF, + ), ) ) }, diff --git a/app/src/main/java/com/pixelized/rplexicon/ui/screens/search/SearchViewModel.kt b/app/src/main/java/com/pixelized/rplexicon/ui/screens/search/SearchViewModel.kt index d341e90..248847f 100644 --- a/app/src/main/java/com/pixelized/rplexicon/ui/screens/search/SearchViewModel.kt +++ b/app/src/main/java/com/pixelized/rplexicon/ui/screens/search/SearchViewModel.kt @@ -52,11 +52,11 @@ class SearchViewModel @Inject constructor( data.filter { item -> val gender = _gender.value?.let { it == item.gender } val race = _race.value?.let { it == item.race } - val search = _search.value.takeIf { it.isNotEmpty() }?.let { - val name = item.name.contains(_search.value, true) - val diminutive = item.diminutive?.contains(_search.value, true) == true - val description = item.description?.contains(_search.value, true) == true - val history = item.history?.contains(_search.value, true) == true + val search = _search.value.takeIf { it.isNotEmpty() }?.trim()?.let { search -> + val name = item.name.contains(search, true) + val diminutive = item.diminutive?.contains(search, true) == true + val description = item.description?.contains(search, true) == true + val history = item.history?.contains(search, true) == true name || diminutive || description || history } (gender == null || gender) && (race == null || race) && (search == null || search) diff --git a/app/src/main/java/com/pixelized/rplexicon/utilitary/GlideHelp.kt b/app/src/main/java/com/pixelized/rplexicon/utilitary/GlideHelp.kt index 2ae29e1..8d8e759 100644 --- a/app/src/main/java/com/pixelized/rplexicon/utilitary/GlideHelp.kt +++ b/app/src/main/java/com/pixelized/rplexicon/utilitary/GlideHelp.kt @@ -23,18 +23,28 @@ class GlideLoadingTransition( @Composable fun rememberLoadingTransition(model: () -> Any?): GlideLoadingTransition { val isInEditMode = LocalView.current.isInEditMode - val key = model() - val target = remember(key) { - mutableStateOf(if (isInEditMode) 1f else 0f) - } - val alpha = animateFloatAsState( - targetValue = target.value, - label = "RememberLoadingTransition" - ) - return remember(key) { - GlideLoadingTransition( - target = target, - alpha = alpha, + + return if (isInEditMode) { + remember { + GlideLoadingTransition( + target = mutableStateOf(1f), + alpha = mutableStateOf(1f), + ) + } + } else { + val key = model() + val target = remember(key) { + mutableStateOf(0f) + } + val alpha = animateFloatAsState( + targetValue = target.value, + label = "LoadingAlphaTransition" ) + remember(key) { + GlideLoadingTransition( + target = target, + alpha = alpha, + ) + } } } \ No newline at end of file