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.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,
)

View file

@ -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,

View file

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

View file

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

View file

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

View file

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