Small UX fix.

Trim on search.
keyboard action done focus.
maxline on search.
collapsing header on search.
remove keyboard hide on search scroll.
This commit is contained in:
Thomas Andres Gomez 2023-07-31 10:21:40 +02:00
parent 99f7546621
commit 82738a8f03
9 changed files with 303 additions and 113 deletions

View file

@ -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<Any>)? = 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,
)
}

View file

@ -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<Measurable>.grid: Measurable get() = first { it.layoutId == GRID_ID }
private val List<Measurable>.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)
}
}
)
}

View file

@ -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()
}
}

View file

@ -9,6 +9,8 @@ import androidx.compose.animation.fadeOut
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size 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.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
@ -22,8 +24,11 @@ import androidx.compose.runtime.State
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource 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.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.tooling.preview.PreviewParameterProvider import androidx.compose.ui.tooling.preview.PreviewParameterProvider
@ -52,6 +57,8 @@ fun TextField(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
field: TextFieldUio, field: TextFieldUio,
) { ) {
val focus = LocalFocusManager.current
OutlinedTextField( OutlinedTextField(
modifier = modifier, modifier = modifier,
shape = MaterialTheme.lexicon.shapes.textField, 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, value = field.value.value,
onValueChange = field.onValueChange, onValueChange = field.onValueChange,
) )

View file

@ -38,7 +38,6 @@ import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.composed import androidx.compose.ui.composed
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color 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.layout.ContentScale
import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.AnnotatedString
@ -59,16 +59,14 @@ import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import com.pixelized.rplexicon.R import com.pixelized.rplexicon.R
import com.pixelized.rplexicon.model.Lexicon 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.navigation.LocalScreenNavHost
import com.pixelized.rplexicon.ui.theme.LexiconTheme import com.pixelized.rplexicon.ui.theme.LexiconTheme
import com.pixelized.rplexicon.utilitary.composable.stringResource import com.pixelized.rplexicon.utilitary.composable.stringResource
import com.pixelized.rplexicon.utilitary.extentions.annotatedSpan import com.pixelized.rplexicon.utilitary.extentions.annotatedSpan
import com.pixelized.rplexicon.utilitary.extentions.annotatedString import com.pixelized.rplexicon.utilitary.extentions.annotatedString
import com.pixelized.rplexicon.utilitary.extentions.highlightRegex import com.pixelized.rplexicon.utilitary.extentions.highlightRegex
import com.pixelized.rplexicon.utilitary.rememberLoadingTransition
import com.skydoves.landscapist.ImageOptions import com.skydoves.landscapist.ImageOptions
import com.skydoves.landscapist.glide.GlideImage
import com.skydoves.landscapist.glide.GlideImageState
@Stable @Stable
data class CharacterDetailUio( data class CharacterDetailUio(
@ -200,16 +198,8 @@ private fun CharacterDetailScreenContent(
.aspectRatio(ratio = 1f) .aspectRatio(ratio = 1f)
.scrollOffset(scrollState = state) { -it / 2 }, .scrollOffset(scrollState = state) { -it / 2 },
) { ) {
val transition = rememberLoadingTransition { uri } AsyncImage(
GlideImage( modifier = Modifier.matchParentSize(),
modifier = Modifier
.matchParentSize()
.alpha(alpha = transition.alpha),
onImageStateChanged = {
if (it is GlideImageState.Success) {
transition.target = 1f
}
},
imageOptions = ImageOptions( imageOptions = ImageOptions(
alignment = Alignment.TopCenter, alignment = Alignment.TopCenter,
contentScale = ContentScale.Crop, contentScale = ContentScale.Crop,
@ -299,8 +289,7 @@ private fun CharacterDetailScreenContent(
) )
} }
if (annotatedItem.portrait.isNotEmpty()) { if (annotatedItem.portrait.isNotEmpty()) {
val configuration = LocalConfiguration.current val maxSize = rememberPortraitWidth()
val maxSize = remember { (configuration.screenWidthDp.dp - 16.dp * 2) }
Text( Text(
modifier = Modifier.padding(start = 16.dp, top = 24.dp, end = 16.dp), modifier = Modifier.padding(start = 16.dp, top = 24.dp, end = 16.dp),
style = typography.titleMedium, style = typography.titleMedium,
@ -308,19 +297,16 @@ private fun CharacterDetailScreenContent(
) )
LazyRow( LazyRow(
contentPadding = PaddingValues(horizontal = 16.dp), contentPadding = PaddingValues(horizontal = 16.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp) horizontalArrangement = Arrangement.spacedBy(8.dp),
) { ) {
items(items = annotatedItem.portrait) { items(items = annotatedItem.portrait) {
val transition = rememberLoadingTransition { it } AsyncImage(
GlideImage( modifier = Modifier.sizeIn(
modifier = Modifier minWidth = maxSize / 2,
.sizeIn(maxWidth = maxSize, maxHeight = maxSize) maxWidth = maxSize,
.alpha(alpha = transition.alpha), minHeight = maxSize,
onImageStateChanged = { maxHeight = maxSize,
if (it is GlideImageState.Success) { ),
transition.target = 1f
}
},
imageOptions = ImageOptions( imageOptions = ImageOptions(
contentScale = ContentScale.FillHeight 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 @Stable
private fun Modifier.scrollOffset( private fun Modifier.scrollOffset(
scrollState: ScrollState, scrollState: ScrollState,

View file

@ -99,12 +99,13 @@ class AnnotatedSearchItemUio(
private fun SearchItemUio.annotate(): AnnotatedSearchItemUio { private fun SearchItemUio.annotate(): AnnotatedSearchItemUio {
val colorScheme = MaterialTheme.colorScheme val colorScheme = MaterialTheme.colorScheme
val highlight = remember { SpanStyle(color = colorScheme.primary) } val highlight = remember { SpanStyle(color = colorScheme.primary) }
val highlightRegex = remember(search) { search.highlightRegex } val trimmedSearch = remember(search) { search.trim() }
val finderRegex = remember(search) { search.finderRegex } val highlightRegex = remember(search) { trimmedSearch.highlightRegex }
val finderRegex = remember(search) { trimmedSearch.finderRegex }
val gender = stringResource(id = gender, short = true) val gender = stringResource(id = gender, short = true)
val race = stringResource(id = race) val race = stringResource(id = race)
return remember(search, race, highlightRace, gender, highlightGender) { return remember(trimmedSearch, race, highlightRace, gender, highlightGender) {
AnnotatedSearchItemUio( AnnotatedSearchItemUio(
id = id, id = id,
name = AnnotatedString( name = AnnotatedString(

View file

@ -41,7 +41,7 @@ import androidx.hilt.navigation.compose.hiltViewModel
import com.pixelized.rplexicon.NO_WINDOW_INSETS import com.pixelized.rplexicon.NO_WINDOW_INSETS
import com.pixelized.rplexicon.R import com.pixelized.rplexicon.R
import com.pixelized.rplexicon.model.Lexicon 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.DropDownField
import com.pixelized.rplexicon.ui.composable.form.DropDownFieldUio import com.pixelized.rplexicon.ui.composable.form.DropDownFieldUio
import com.pixelized.rplexicon.ui.composable.form.TextField import com.pixelized.rplexicon.ui.composable.form.TextField
@ -88,9 +88,6 @@ fun SearchScreen(
screen.popBackStack() screen.popBackStack()
} }
) )
ScrollingKeyboardHandler(
lazyListState = lazyState,
)
} }
} }
@ -125,16 +122,45 @@ private fun SearchScreenContent(
) )
}, },
) { paddingValues -> ) { paddingValues ->
CollapsingHeader(
modifier = Modifier.padding(paddingValues = paddingValues),
header = {
SearchBox(
modifier = Modifier.padding(horizontal = 16.dp),
form = form,
)
},
content = {
LazyColumn( LazyColumn(
modifier = Modifier modifier = Modifier.fillMaxSize(),
.fillMaxSize()
.padding(paddingValues = paddingValues),
state = lazyColumnState, state = lazyColumnState,
contentPadding = PaddingValues(vertical = 8.dp), contentPadding = PaddingValues(vertical = 8.dp),
) { ) {
item { 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( Column(
modifier = Modifier.padding(horizontal = 16.dp), modifier = modifier,
verticalArrangement = Arrangement.spacedBy(2.dp), verticalArrangement = Arrangement.spacedBy(2.dp),
) { ) {
TextField( TextField(
@ -155,21 +181,6 @@ private fun SearchScreenContent(
} }
Divider(modifier = Modifier.padding(top = 16.dp, bottom = 8.dp)) 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 @Composable
@ -196,18 +207,41 @@ private fun SearchScreenContentPreview() {
items = remember { items = remember {
mutableStateOf( mutableStateOf(
listOf( listOf(
SearchItemUio( SearchItemUio.preview(
id = 0, id = 0,
name = "Brulkhai", name = "Brulkhai",
diminutive = "Bru", diminutive = "Bru",
gender = Lexicon.Gender.FEMALE, gender = Lexicon.Gender.FEMALE,
race = Lexicon.Race.HALF_ORC, race = Lexicon.Race.HALF_ORC,
description = null, ),
history = null, SearchItemUio.preview(
search = "", id = 0,
highlightGender = false, name = "Léandre",
highlightRace = false, 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,
),
) )
) )
}, },

View file

@ -52,11 +52,11 @@ class SearchViewModel @Inject constructor(
data.filter { item -> data.filter { item ->
val gender = _gender.value?.let { it == item.gender } val gender = _gender.value?.let { it == item.gender }
val race = _race.value?.let { it == item.race } val race = _race.value?.let { it == item.race }
val search = _search.value.takeIf { it.isNotEmpty() }?.let { val search = _search.value.takeIf { it.isNotEmpty() }?.trim()?.let { search ->
val name = item.name.contains(_search.value, true) val name = item.name.contains(search, true)
val diminutive = item.diminutive?.contains(_search.value, true) == true val diminutive = item.diminutive?.contains(search, true) == true
val description = item.description?.contains(_search.value, true) == true val description = item.description?.contains(search, true) == true
val history = item.history?.contains(_search.value, true) == true val history = item.history?.contains(search, true) == true
name || diminutive || description || history name || diminutive || description || history
} }
(gender == null || gender) && (race == null || race) && (search == null || search) (gender == null || gender) && (race == null || race) && (search == null || search)

View file

@ -23,18 +23,28 @@ class GlideLoadingTransition(
@Composable @Composable
fun rememberLoadingTransition(model: () -> Any?): GlideLoadingTransition { fun rememberLoadingTransition(model: () -> Any?): GlideLoadingTransition {
val isInEditMode = LocalView.current.isInEditMode val isInEditMode = LocalView.current.isInEditMode
return if (isInEditMode) {
remember {
GlideLoadingTransition(
target = mutableStateOf(1f),
alpha = mutableStateOf(1f),
)
}
} else {
val key = model() val key = model()
val target = remember(key) { val target = remember(key) {
mutableStateOf(if (isInEditMode) 1f else 0f) mutableStateOf(0f)
} }
val alpha = animateFloatAsState( val alpha = animateFloatAsState(
targetValue = target.value, targetValue = target.value,
label = "RememberLoadingTransition" label = "LoadingAlphaTransition"
) )
return remember(key) { remember(key) {
GlideLoadingTransition( GlideLoadingTransition(
target = target, target = target,
alpha = alpha, alpha = alpha,
) )
} }
}
} }