Skip to content

Instantly share code, notes, and snippets.

@inidamleader
Last active October 13, 2024 10:07
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.
ListPicker: Generic compose picker (Number picker, date or any other type) 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.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 [email protected]
*/
@Composable
fun <E> ListPicker(
initialValue: E,
values: List<E>,
onValueChange: (E) -> Unit,
modifier: Modifier = Modifier,
wrapSelectorWheel: Boolean = false,
format: E.() -> String = { toString() },
parse: (String.() -> E?)? = null,
onIsErrorChange: (Boolean) -> Unit = {},
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,
) {
LogComposition() // 0 recompositions
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,
) {
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() }
InvokedEffect(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 {
val itemHeight = textStyle.lineHeight.toDp() + verticalPadding * 2F
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(key1 = index, key2 = enableEdition) {
derivedStateOf {
enableEdition && (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,
onValueChange = { value = it },
wrapSelectorWheel = true,
format = {
format(
DateTimeFormatter
.ofLocalizedDate(FormatStyle.MEDIUM)
.withLocale(Locale.getDefault()),
)
},
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,
onValueChange = { value = it },
modifier = Modifier,
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,
onValueChange = { value = it },
format = { this.toString() },
parse = {
try {
takeIf {
// check if each input string contains only integers
it.matches(Regex("^\\d+\$"))
}?.toInt()
} catch (e: NumberFormatException) {
null
}
},
onIsErrorChange = {},
outOfBoundsPageCount = 2,
textStyle = MaterialTheme.typography.labelLarge,
verticalPadding = 8.dp,
keyboardType = KeyboardType.Number,
)
}
}
}
}
package com.inidamleader.ovtracker.util.compose
import androidx.compose.runtime.Composable
@Composable
fun ComposeScope(content: @Composable () -> Unit) {
content()
}
package com.inidamleader.ovtracker.util.compose.geometry
import androidx.compose.runtime.Composable
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.geometry.isSpecified
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.DpOffset
import androidx.compose.ui.unit.DpSize
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.TextUnit
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.isSpecified
import kotlin.math.roundToInt
// Those functions are designed to be used in lambdas
// DP
fun Density.dpToSp(dp: Dp) = if (dp.isSpecified) dp.toSp() else TextUnit.Unspecified
fun Density.dpToFloatPx(dp: Dp) = if (dp.isSpecified) dp.toPx() else Float.NaN
fun Density.dpToIntPx(dp: Dp) = if (dp.isSpecified) dp.toPx().toInt() else 0
fun Density.dpRoundToPx(dp: Dp) = if (dp.isSpecified) dp.roundToPx() else 0
@Composable
fun Dp.toSp() = LocalDensity.current.dpToSp(this)
@Composable
fun Dp.toFloatPx() = LocalDensity.current.dpToFloatPx(this)
@Composable
fun Dp.toIntPx() = LocalDensity.current.dpToIntPx(this)
@Composable
fun Dp.roundToPx() = LocalDensity.current.dpRoundToPx(this)
fun Dp.toRecDpSize() = if (isSpecified) DpSize(this, this) else DpSize.Unspecified
fun Dp.toRecDpOffset() = if (isSpecified) DpOffset(this, this) else DpOffset.Unspecified
// TEXT UNIT
fun Density.spToDp(sp: TextUnit) = if (sp.isSpecified) sp.toDp() else Dp.Unspecified
fun Density.spToFloatPx(sp: TextUnit) = if (sp.isSpecified) sp.toPx() else Float.NaN
fun Density.spToIntPx(sp: TextUnit) = if (sp.isSpecified) sp.toPx().toInt() else 0
fun Density.spRoundToPx(sp: TextUnit) = if (sp.isSpecified) sp.roundToPx() else 0
@Composable
fun TextUnit.toDp() = LocalDensity.current.spToDp(this)
@Composable
fun TextUnit.toFloatPx() = LocalDensity.current.spToFloatPx(this)
@Composable
fun TextUnit.toIntPx() = LocalDensity.current.spToIntPx(this)
@Composable
fun TextUnit.roundToPx() = LocalDensity.current.spRoundToPx(this)
// FLOAT
fun Density.floatPxToDp(px: Float) = if (px.isFinite()) px.toDp() else Dp.Unspecified
fun Density.floatPxToSp(px: Float) = if (px.isFinite()) px.toSp() else TextUnit.Unspecified
@Composable
fun Float.toDp() = LocalDensity.current.floatPxToDp(this)
@Composable
fun Float.toSp() = LocalDensity.current.floatPxToSp(this)
fun Float.toIntPx() = if (isFinite()) toInt() else 0
fun Float.roundToPx() = if (isFinite()) roundToInt() else 0
fun Float.toRecSize() = if (isFinite()) Size(this, this) else Size.Unspecified
fun Float.toRecOffset() = if (isFinite()) Offset(this, this) else Offset.Unspecified
// INT
fun Density.intPxToDp(px: Int) = px.toDp()
fun Density.intPxToSp(px: Int) = px.toSp()
@Composable
fun Int.toDp() = LocalDensity.current.intPxToDp(this)
@Composable
fun Int.toSp() = LocalDensity.current.intPxToSp(this)
fun Int.toFloatPx() = toFloat()
fun Int.toRecIntSize() = IntSize(this, this)
fun Int.toRecIntOffset() = IntOffset(this, this)
// DP SIZE
fun Density.dpSizeToIntSize(dpSize: DpSize) =
if (dpSize.isSpecified) IntSize(dpSize.width.toPx().toInt(), dpSize.height.toPx().toInt())
else IntSize.Zero
fun Density.dpSizeRoundToIntSize(dpSize: DpSize) =
if (dpSize.isSpecified) IntSize(dpSize.width.roundToPx(), dpSize.height.roundToPx())
else IntSize.Zero
fun Density.dpSizeToSize(dpSize: DpSize) =
if (dpSize.isSpecified) Size(dpSize.width.toPx(), dpSize.height.toPx())
else Size.Unspecified
@Composable
fun DpSize.toIntSize() = LocalDensity.current.dpSizeToIntSize(this)
@Composable
fun DpSize.roundToIntSize() = LocalDensity.current.dpSizeRoundToIntSize(this)
@Composable
fun DpSize.toSize() = LocalDensity.current.dpSizeToSize(this)
fun DpSize.isSpaced() = isSpecified && width > 0.dp && height > 0.dp
// SIZE
fun Density.sizeToDpSize(size: Size) =
if (size.isSpecified) DpSize(size.width.toDp(), size.height.toDp())
else DpSize.Unspecified
@Composable
fun Size.toDpSize() =
if (isSpecified) LocalDensity.current.sizeToDpSize(this)
else DpSize.Unspecified
fun Size.toIntSize() =
if (isSpecified) IntSize(width.toInt(), height.toInt())
else IntSize.Zero
fun Size.isSpaced() = isSpecified && width > 0F && height > 0F
// INT SIZE
fun Density.intSizeToDpSize(intSize: IntSize) = DpSize(intSize.width.toDp(), intSize.height.toDp())
@Composable
fun IntSize.toDpSize() = LocalDensity.current.intSizeToDpSize(this)
@Composable
fun IntSize.toSize() = Size(width.toFloat(), height.toFloat())
fun IntSize.isSpaced() = width > 0 && height > 0
// DP OFFSET
fun Density.dpOffsetToIntOffset(dpOffset: DpOffset) =
if (dpOffset.isSpecified) IntOffset(dpOffset.x.toPx().toInt(), dpOffset.y.toPx().toInt())
else IntOffset.Zero
fun Density.dpOffsetRoundToIntOffset(dpOffset: DpOffset) =
if (dpOffset.isSpecified) IntOffset(dpOffset.x.roundToPx(), dpOffset.y.roundToPx())
else IntOffset.Zero
fun Density.dpOffsetToOffset(dpOffset: DpOffset) =
if (dpOffset.isSpecified) Offset(dpOffset.x.toPx(), dpOffset.y.toPx())
else Offset.Unspecified
@Composable
fun DpOffset.toIntOffset() = LocalDensity.current.dpOffsetToIntOffset(this)
@Composable
fun DpOffset.roundToIntOffset() = LocalDensity.current.dpOffsetRoundToIntOffset(this)
@Composable
fun DpOffset.toOffset() = LocalDensity.current.dpOffsetToOffset(this)
// OFFSET
fun Density.offsetToDpOffset(offset: Offset) =
if (offset.isSpecified) DpOffset(offset.x.toDp(), offset.y.toDp())
else DpOffset.Unspecified
@Composable
fun Offset.toDpOffset() = LocalDensity.current.offsetToDpOffset(this)
fun Offset.toIntOffset() =
if (isSpecified) IntOffset(x.toInt(), y.toInt())
else IntOffset.Zero
// INT OFFSET
fun Density.intOffsetToDpOffset(intOffset: IntOffset) = DpOffset(intOffset.x.toDp(), intOffset.y.toDp())
@Composable
fun IntOffset.toDpOffset() = LocalDensity.current.intOffsetToDpOffset(this)
fun IntOffset.toOffset() = Offset(x.toFloat(), y.toFloat())
package com.inidamleader.ovtracker.util.compose
import androidx.compose.runtime.Composable
import androidx.compose.runtime.RememberObserver
import androidx.compose.runtime.remember
@Composable
fun InvokedEffect(
key1: Any?,
effect: () -> Unit,
) {
remember(key1 = key1) { InvokedEffectImpl(effect) }
}
@Composable
fun InvokedEffect(
key1: Any?,
key2: Any?,
effect: () -> Unit,
) {
remember(key1 = key1, key2 = key2) { InvokedEffectImpl(effect) }
}
@Composable
fun InvokedEffect(
key1: Any?,
key2: Any?,
key3: Any?,
effect: () -> Unit,
) {
remember(key1 = key1, key2 = key2, key3 = key3) { InvokedEffectImpl(effect) }
}
@Composable
fun InvokedEffect(
vararg keys: Any?,
effect: () -> Unit,
) {
remember(*keys) { InvokedEffectImpl(effect) }
}
internal class InvokedEffectImpl(
private val effect: () -> Unit
) : RememberObserver {
override fun onRemembered() {
effect()
}
override fun onForgotten() {
}
override fun onAbandoned() {
}
}
@Stable
fun Modifier.fadingEdge(brush: Brush) = this
.graphicsLayer(compositingStrategy = CompositingStrategy.Offscreen)
.drawWithContent {
drawContent()
drawRect(brush = brush, blendMode = BlendMode.DstIn)
}
@nasserkhosravi
Copy link

What does ComposeScope and InvokedEffect refer to?

@inidamleader
Copy link
Author

inidamleader commented Sep 3, 2024

@nasserkhosravi You can replace InvokedEffect with LaunchedEffect and remove the ComposeScope function. I initially used them for performance optimization:
1- ComposeScope was used to minimize the recomposition scope size.
2- InvokedEffect is more lightweight, as LaunchedEffect creates a coroutine internally, which can introduce some overhead and delay when executing potentially suspending functions.

I'll be updating the code to include their implementation.

@Zeng1998
Copy link

@inidamleader Thank you very much for sharing the code. I would like to know if it is possible to achieve an infinite scrolling, such as ... 0 1 2 ... 23 0 1 2 ...

@Zeng1998
Copy link

LaunchedEffect(key1 = values) {
        lazyListState.scrollToItem(scrollOfItem(initialValue) ?: 0)   // +
        snapshotFlow { lazyListState.firstVisibleItemIndex }.collectLatest {
            onValueChange(values[it % listSize])
        }
    }

Also, I'd like to know if it's right to add one line of code here? In my codes, without this, after I dynamically change the parameters values ​​and initialValue, the initialValue doesn't work because the scroll state of lazyListState has not changed.

@inidamleader
Copy link
Author

@Zeng1998 Thank you for your question! Yes, you can achieve infinite scrolling by setting the wrapSelectorWheel parameter to true. This will allow the items to scroll continuously, wrapping around once they reach the end.

Regarding your second point, you're correct—the initialValue might not update properly because the scroll state of lazyListState doesn’t reset automatically. You can modify the code based on your needs, and yes, adding the line to reset the scroll state could be the right approach, though I don’t fully recall the exact implementation at the moment. I’ll review and fix this today.

Feel free to reach out if you need any further clarification!

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