Skip to content

Instantly share code, notes, and snippets.

@inidamleader
Last active June 8, 2024 07:52
Show Gist options
  • Save inidamleader/7bcc273afe6b885738556d190582a815 to your computer and use it in GitHub Desktop.
Save inidamleader/7bcc273afe6b885738556d190582a815 to your computer and use it in GitHub Desktop.
Generic List picker (Number picker, date or any other type) composable function to select an item from a list by scrolling through the list with: possibility of editing and wrapSelectorWheel parameter
package com.inidamleader.ovtracker.util.compose
import androidx.compose.animation.AnimatedContent
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.snapping.rememberSnapFlingBehavior
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
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.lazy.LazyColumn
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextField
import androidx.compose.material3.TextFieldDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import com.inidamleader.ovtracker.layer.ui.theme.OvTrackerTheme
import com.inidamleader.ovtracker.util.compose.geometry.toDp
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
import java.time.LocalDate
import java.time.format.DateTimeFormatter
import java.time.format.FormatStyle
import java.util.Locale
/**
* A composable function that allows users to select an item from a list using a scrollable list with a text field for editing.
*
* @param initialValue The initial value to be selected in the list.
* @param values The list of items.
* @param modifier Modifier for customizing the appearance of the `ListPicker`.
* @param wrapSelectorWheel Boolean flag indicating whether the list should wrap around like a selector wheel.
* @param format A lambda function that formats an item into a string for display.
* @param onValueChange A callback function that is invoked when the selected item changes.
* @param onIsErrorChange A callback function that is invoked when the isError changes.
* @param parse A lambda function that parses a string into an item.
* @param enableEdition Boolean flag indicating whether the user can edit the selected item using a text field.
* @param outOfBoundsPageCount The number of pages to display on either side of the selected item.
* @param textStyle The text style for the displayed items.
* @param verticalPadding The vertical padding between items.
* @param dividerColor The color of the horizontal dividers.
* @param dividerThickness The thickness of the horizontal dividers.
*
* @author Reda El Madini - For support, contact gladiatorkilo@gmail.com
*/
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun <E> ListPicker(
initialValue: E,
values: List<E>,
modifier: Modifier = Modifier,
wrapSelectorWheel: Boolean = false,
format: E.() -> String = { toString() },
onValueChange: (E) -> Unit,
onIsErrorChange: (Boolean) -> Unit,
parse: (String.() -> E?)? = null,
enableEdition: Boolean = parse != null,
outOfBoundsPageCount: Int = 1,
textStyle: TextStyle = LocalTextStyle.current,
verticalPadding: Dp = 16.dp,
dividerColor: Color = MaterialTheme.colorScheme.outline,
dividerThickness: Dp = 1.dp,
keyboardType: KeyboardType = KeyboardType.Text,
) {
val listSize = values.size
val coercedOutOfBoundsPageCount = outOfBoundsPageCount.coerceIn(0..listSize / 2)
val visibleItemsCount = 1 + coercedOutOfBoundsPageCount * 2
val iteration =
if (wrapSelectorWheel)
remember(key1 = coercedOutOfBoundsPageCount, key2 = listSize) {
(Int.MAX_VALUE - 2 * coercedOutOfBoundsPageCount) / listSize
}
else 1
val intervals =
remember(key1 = coercedOutOfBoundsPageCount, key2 = iteration, key3 = listSize) {
listOf(
0,
coercedOutOfBoundsPageCount,
coercedOutOfBoundsPageCount + iteration * listSize,
coercedOutOfBoundsPageCount + iteration * listSize + coercedOutOfBoundsPageCount,
)
}
val scrollOfItemIndex = { it: Int ->
it + (listSize * (iteration / 2))
}
val scrollOfItem = { item: E ->
values.indexOf(item)
.takeIf { it != -1 }
?.let { index -> scrollOfItemIndex(index) }
}
val lazyListState = rememberLazyListState(
initialFirstVisibleItemIndex = remember(
key1 = initialValue,
key2 = listSize,
key3 = iteration,
) {
scrollOfItem(initialValue) ?: 0
},
)
LaunchedEffect(key1 = values) {
snapshotFlow { lazyListState.firstVisibleItemIndex }.collectLatest {
onValueChange(values[it % listSize])
}
}
Box(
modifier = modifier,
contentAlignment = Alignment.Center,
) {
val itemHeight = textStyle.lineHeight.toDp + verticalPadding * 2
var edit by rememberSaveable { mutableStateOf(false) }
ComposeScope {
AnimatedContent(
targetState = edit,
label = "AnimatedContent",
) { showTextField ->
if (showTextField) {
var isError by rememberSaveable { mutableStateOf(false) }
val initialSelectedItem = remember {
values[lazyListState.firstVisibleItemIndex % listSize]
}
var value by rememberSaveable {
mutableStateOf(initialSelectedItem.format())
}
val focusRequester = remember { FocusRequester() }
LaunchedEffect(key1 = Unit) {
focusRequester.requestFocus()
}
val coroutineScope = rememberCoroutineScope()
ComposeScope {
TextField(
modifier = Modifier
.fillMaxWidth()
.focusRequester(focusRequester),
value = value,
onValueChange = { string ->
value = string
parse?.invoke(string).let { item ->
isError =
if (item != null)
if (values.contains(item)) false
else true // item not found
else true // string cannot be parsed
if (isError) onValueChange(initialSelectedItem)
else onValueChange(item ?: initialSelectedItem)
onIsErrorChange(isError)
}
},
textStyle = textStyle.copy(textAlign = TextAlign.Center),
keyboardOptions = KeyboardOptions.Default.copy(
keyboardType = keyboardType,
imeAction = if (!isError) ImeAction.Done else ImeAction.Default,
),
keyboardActions = KeyboardActions(
onDone = {
if (!isError) {
parse?.invoke(value)?.let { item ->
scrollOfItem(item)?.let { scroll ->
coroutineScope.launch {
lazyListState.scrollToItem(scroll)
}
}
}
edit = false
}
}
),
isError = isError,
colors = TextFieldDefaults.colors().copy(
focusedContainerColor = Color.Transparent,
unfocusedContainerColor = Color.Transparent,
errorContainerColor = Color.Transparent,
focusedIndicatorColor = Color.Transparent,
unfocusedIndicatorColor = Color.Transparent,
errorIndicatorColor = Color.Transparent,
errorTextColor = MaterialTheme.colorScheme.error,
),
)
}
} else {
LazyColumn(
state = lazyListState,
flingBehavior = rememberSnapFlingBehavior(lazyListState = lazyListState),
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier
.fillMaxWidth()
.height(itemHeight * visibleItemsCount)
.fadingEdge(
brush = remember {
Brush.verticalGradient(
0F to Color.Transparent,
0.5F to Color.Black,
1F to Color.Transparent
)
},
),
) {
items(
count = intervals.last(),
key = { it },
) { index ->
val enabled by remember(index, enableEdition) {
derivedStateOf {
enableEdition && !edit && (index == lazyListState.firstVisibleItemIndex + coercedOutOfBoundsPageCount)
}
}
val textModifier = Modifier.padding(vertical = verticalPadding)
when (index) {
in intervals[0]..<intervals[1] -> Text(
text = if (wrapSelectorWheel) values[(index - coercedOutOfBoundsPageCount + listSize) % listSize].format() else "",
maxLines = 1,
overflow = TextOverflow.Ellipsis,
style = textStyle,
modifier = textModifier,
)
in intervals[1]..<intervals[2] -> {
Text(
text = values[(index - coercedOutOfBoundsPageCount) % listSize].format(),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
style = textStyle,
modifier = textModifier.then(
Modifier.clickable(
onClick = { edit = true },
enabled = enabled,
)
),
)
}
in intervals[2]..<intervals[3] -> Text(
text = if (wrapSelectorWheel) values[(index - coercedOutOfBoundsPageCount) % listSize].format() else "",
maxLines = 1,
overflow = TextOverflow.Ellipsis,
style = textStyle,
modifier = textModifier,
)
}
}
}
HorizontalDivider(
modifier = Modifier.offset(y = itemHeight * coercedOutOfBoundsPageCount - dividerThickness / 2),
thickness = dividerThickness,
color = dividerColor,
)
HorizontalDivider(
modifier = Modifier.offset(y = itemHeight * (coercedOutOfBoundsPageCount + 1) - dividerThickness / 2),
thickness = dividerThickness,
color = dividerColor,
)
}
}
}
}
}
@Preview(widthDp = 300)
@Composable
fun PreviewListPicker1() {
OvTrackerTheme {
Surface(color = MaterialTheme.colorScheme.primary) {
var value by remember { mutableStateOf(LocalDate.now()) }
val list = remember {
buildList {
repeat(10) {
add(LocalDate.now().minusDays((it - 5).toLong()))
}
}
}
ListPicker(
initialValue = value,
values = list,
wrapSelectorWheel = true,
format = {
format(
DateTimeFormatter
.ofLocalizedDate(FormatStyle.MEDIUM)
.withLocale(Locale.getDefault()),
)
},
onValueChange = { value = it },
onIsErrorChange = {},
textStyle = MaterialTheme.typography.labelLarge,
verticalPadding = 8.dp,
keyboardType = KeyboardType.Number,
)
}
}
}
@Preview(widthDp = 100)
@Composable
fun PreviewListPicker2() {
OvTrackerTheme {
Surface(color = MaterialTheme.colorScheme.tertiary) {
var value by remember { mutableStateOf("5") }
val list = remember { (1..10).map { it.toString() } }
ListPicker(
initialValue = value,
values = list,
modifier = Modifier,
onValueChange = { value = it },
onIsErrorChange = {},
outOfBoundsPageCount = 2,
textStyle = MaterialTheme.typography.labelLarge,
verticalPadding = 8.dp,
keyboardType = KeyboardType.Number,
)
}
}
}
@Preview
@Composable
fun PreviewListPicker3() {
OvTrackerTheme {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
var value by remember { mutableIntStateOf(5) }
val list = remember { (1..10).map { it } }
Surface(color = MaterialTheme.colorScheme.primary) {
Text(
text = "Selected value: $value",
textAlign = TextAlign.Center,
modifier = Modifier
.fillMaxWidth()
.padding(8.dp),
style = MaterialTheme.typography.bodyMedium,
)
}
Spacer(modifier = Modifier.height(16.dp))
Surface {
ListPicker(
initialValue = value,
values = list,
format = { this.toString() },
onValueChange = { value = it },
onIsErrorChange = {},
parse = {
takeIf {
// check if each input string contains only integers
it.matches(Regex("^\\d+\$"))
}?.toInt()
},
outOfBoundsPageCount = 2,
textStyle = MaterialTheme.typography.labelLarge,
verticalPadding = 8.dp,
keyboardType = KeyboardType.Number,
)
}
}
}
}
@Composable
fun ComposeScope(content: @Composable () -> Unit) {
content()
}
val TextUnit.toDp: Dp @composable get() = LocalDensity.current.toDp(this)
fun Density.toDp(sp: TextUnit): Dp = sp.toDp()
@Stable
fun Modifier.fadingEdge(brush: Brush) = this
.graphicsLayer(compositingStrategy = CompositingStrategy.Offscreen)
.drawWithContent {
drawContent()
drawRect(brush = brush, blendMode = BlendMode.DstIn)
}
@inidamleader
Copy link
Author

inidamleader commented Jan 14, 2024

Screenshot from 2024-01-14 12-27-27

@iadtgar
Copy link

iadtgar commented Jun 4, 2024

Thanks for this. Can you also post the code for your util.compose.geometry.toDp.Thanks

@inidamleader
Copy link
Author

Thanks for this. Can you also post the code for your util.compose.geometry.toDp.Thanks

val Dp.toPx: Float @composable get() = LocalDensity.current.toPx(this)

If you like don’t forget to give a star

@iadtgar
Copy link

iadtgar commented Jun 4, 2024

Thanks for this. Can you also post the code for your util.compose.geometry.toDp.Thanks

val Dp.toPx: Float @composable get() = LocalDensity.current.toPx(this)

If you like don’t forget to give a star

Thanks for the reply, but you shared Dp.toPx (which seems to be incorrect since there isn't a toPx function that accepts a Dp) while your code looks to use TextUnit.toDp?

@inidamleader
Copy link
Author

inidamleader commented Jun 5, 2024

Oh sorry, here is the correct code:

val TextUnit.toDp: Dp @composable get() = LocalDensity.current.toDp(this)
fun Density.toDp(sp: TextUnit): Dp = sp.toDp()

This extension property converts a TextUnit to Dp within a Composable function using the current LocalDensity.

@inidamleader
Copy link
Author

inidamleader commented Jun 6, 2024

@iadtgar Sorry, I accidentally deleted your previous post. Here it is:

Thanks, The previews fail with java.lang.IllegalStateException: Only Sp can convert to Px I assume because it is TextUnitType.Unspecified while the extension functions assume TextUnitType.Sp. If I set the textStyle on the ListPicker with a value from MaterialTheme.typography it seems to work since lineHeight is actually set. I'm just wondering how you got this to work using TextStyle(fontSize = 32.sp)

Thank you for reporting this, I’ve made the appropriate change to the code

@iadtgar
Copy link

iadtgar commented Jun 6, 2024

Thank you, the last thing that I noticed - If you use some values from MaterialTheme.typography such as MaterialTheme.typography.bodyLarge the initial state of the list isn't showing the selected item in the center. Once you scroll the list it seems to correct itself
image

@inidamleader
Copy link
Author

Thank you, the last thing that I noticed - If you use some values from MaterialTheme.typography such as MaterialTheme.typography.bodyLarge the initial state of the list isn't showing the selected item in the center. Once you scroll the list it seems to correct itself image

Please give me also the code to be able to reproduce this issue

@inidamleader
Copy link
Author

I've tried using MaterialTheme.typography.bodyLarge, and it worked for me without any issues. I'm not sure why it's not working for you.
Screenshot from 2024-06-08 08-48-22

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment