Skip to content

Instantly share code, notes, and snippets.

@inidamleader
Last active September 10, 2026 18:19
Show Gist options
  • Select an option

  • Save inidamleader/7bcc273afe6b885738556d190582a815 to your computer and use it in GitHub Desktop.

Select an option

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.util.ui.picker
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.snapping.rememberSnapFlingBehavior
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
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.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.material3.ripple
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.SideEffect
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.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.runtime.structuralEqualityPolicy
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalLocale
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.semantics.onClick
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.semantics.stateDescription
import androidx.compose.ui.text.TextRange
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.input.TextFieldValue
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 androidx.compose.ui.unit.isSpecified
import androidx.compose.ui.unit.sp
import androidx.compose.ui.util.fastCoerceIn
import com.inidamleader.util.BuildConfig
import com.inidamleader.util.format
import com.inidamleader.util.geometry.toDp
import com.inidamleader.util.global.fullWidthModifier
import com.inidamleader.util.global.integerOnlyRegex
import com.inidamleader.util.minusDays
import com.inidamleader.util.now
import com.inidamleader.util.performance.BackHandler
import com.inidamleader.util.performance.ComposeScope
import com.inidamleader.util.ui.fadingEdge
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.map
import java.time.format.FormatStyle
/**
* A controlled selector-wheel picker for choosing an item from [getValues].
*
* The caller owns the value exposed by [getValue]. User-driven wheel changes and valid text edits
* are reported through [onValueChange]; external changes are reflected by scrolling the wheel
* without sending the same value back to the caller. Providers are read only by the picker areas
* that need them, keeping caller observation scopes small.
*
* @param getValue Provides the currently selected value. It must be present in [getValues].
* @param getValues Provides the non-empty list of selectable values.
* @param onValueChange Called when a user selects a different value.
* @param modifier Modifier for customizing the appearance of the picker.
* @param wrapSelectorWheel Whether the wheel wraps around at the beginning and end of [values].
* @param format Formats an item for display and accessibility state descriptions.
* @param parse Parses text entered while editing. A parsed item must also be in [values].
* @param getIsError Whether the inline-edit field is in an error state.
* @param onIsErrorChange Called when inline-edit validation changes while editing.
* @param beyondViewportPageCount Number of rows shown above and below the selected row.
* @param textStyle Text style for wheel rows and inline editing.
* @param verticalPadding Vertical padding within each row.
* @param keyboardType Keyboard type used for inline editing.
* @param enableEditing Whether tapping the selected row opens inline editing. Requires [parse].
* @param selectionContainerColor Background color behind the selected row.
* @param selectedTextColor Text color for the selected row.
* @param unselectedTextColor Text color for unselected rows.
*
* @throws IllegalArgumentException if the list from [getValues] is empty, the value from
* [getValue] is absent from it, or inline editing is enabled without a parser.
*/
@Composable
fun <E> ListPicker(
getValue: () -> E,
getValues: () -> List<E>,
onValueChange: (E) -> Unit,
getIsError: () -> Boolean,
onIsErrorChange: (Boolean) -> Unit,
modifier: Modifier = Modifier,
wrapSelectorWheel: Boolean = false,
format: E.() -> String = { toString() },
parse: (String.() -> E?)? = null,
enableEditing: Boolean = parse != null,
beyondViewportPageCount: Int = 1,
textStyle: TextStyle = LocalTextStyle.current,
verticalPadding: Dp = 16.dp,
keyboardType: KeyboardType = KeyboardType.Text,
selectionContainerColor: Color = MaterialTheme.colorScheme.secondaryContainer,
selectedTextColor: Color = MaterialTheme.colorScheme.onSecondaryContainer,
unselectedTextColor: Color = MaterialTheme.colorScheme.onSurfaceVariant,
) {
if (BuildConfig.DEBUG) {
require(!enableEditing || parse != null) { "ListPicker requires parse when enableEditing is true." }
LaunchedEffect(Unit) {
snapshotFlow { getValue() to getValues() }.collectLatest { (value, values) ->
require(values.isNotEmpty()) { "ListPicker values must not be empty." }
require(value in values) { "ListPicker value must be present in values." }
}
}
}
val wheelConfiguration by remember(key1 = beyondViewportPageCount, key2 = wrapSelectorWheel) {
derivedStateOf(policy = structuralEqualityPolicy()) {
WheelConfiguration(
listSize = getValues().size,
beyondViewportPageCount = beyondViewportPageCount,
wrapSelectorWheel = wrapSelectorWheel,
)
}
}
val lazyListState = rememberLazyListState(initialFirstVisibleItemIndex = remember { wheelConfiguration.scrollIndexFor(getValues().indexOf(getValue())) })
val resolvedTextStyle = LocalTextStyle.current.merge(textStyle)
val rowContentHeight = when {
resolvedTextStyle.lineHeight.isSpecified -> resolvedTextStyle.lineHeight
resolvedTextStyle.fontSize.isSpecified -> resolvedTextStyle.fontSize
else -> 20.sp
}.toDp()
val itemHeight = (rowContentHeight + verticalPadding * 2f).coerceAtLeast(56.dp)
val pickerHeight = itemHeight * wheelConfiguration.visibleItemsCount
val selectionShape = MaterialTheme.shapes.small
var editing by rememberSaveable { mutableStateOf(false) }
var hasEditingFocus by remember { mutableStateOf(false) }
var editValue by rememberSaveable(stateSaver = TextFieldValue.Saver) { mutableStateOf(TextFieldValue()) }
val keyboardController = LocalSoftwareKeyboardController.current
val dismissEditing: () -> Unit = {
editing = false
hasEditingFocus = false
onIsErrorChange(false)
keyboardController?.hide()
}
LaunchedEffect(key1 = Unit) {
snapshotFlow { lazyListState.firstVisibleItemIndex to lazyListState.isScrollInProgress }.filter { !it.second }.map { it.first }.collectLatest { firstVisibleItemIndex ->
getValues().let { onValueChange(it[firstVisibleItemIndex % it.size]) }
}
}
LaunchedEffect(key1 = Unit) {
snapshotFlow { getValue() to getValues() }.collectLatest { (value, values) ->
val valueIndex = values.indexOf(value)
val currentIndex = lazyListState.firstVisibleItemIndex % values.size
if (valueIndex != currentIndex) lazyListState.scrollToItem(index = wheelConfiguration.scrollIndexFor(valueIndex))
}
}
LaunchedEffect(key1 = Unit) {
snapshotFlow { editing }.filter { it }.collectLatest {
val initialText = getValue().format()
editValue = TextFieldValue(
text = initialText,
selection = TextRange(start = 0, end = initialText.length),
)
onIsErrorChange(false)
}
}
BackHandler(getIsEnabled = { editing }) {
dismissEditing()
}
Box(
modifier = modifier
.height(pickerHeight)
.semantics {
stateDescription = getValue().format()
if (enableEditing && !editing) {
onClick(label = "Edit selected value") {
editing = true
true
}
}
},
contentAlignment = Alignment.Center,
) {
Surface(
color = selectionContainerColor,
shape = selectionShape,
modifier = fullWidthModifier.height(itemHeight),
) {}
ComposeScope {
LazyColumn(
state = lazyListState,
flingBehavior = rememberSnapFlingBehavior(lazyListState = lazyListState),
horizontalAlignment = Alignment.CenterHorizontally,
userScrollEnabled = !editing,
modifier = fullWidthModifier
.height(pickerHeight)
.fadingEdge(
brush = remember {
Brush.verticalGradient(
0f to Color.Transparent,
0.5f to Color.Black,
1f to Color.Transparent,
)
},
),
) {
items(
count = wheelConfiguration.itemCount,
key = { it },
) { index ->
val getHasValue = { wrapSelectorWheel or (index in wheelConfiguration.listStartIndex..<wheelConfiguration.listEndIndex) }
val item by remember(key1 = index) {
derivedStateOf(policy = structuralEqualityPolicy()) {
if (getHasValue()) {
val values = getValues()
values[(index - wheelConfiguration.listStartIndex) % values.size]
} else null
}
}
val isSelected by remember(key1 = index) {
derivedStateOf(policy = structuralEqualityPolicy()) {
getHasValue() && index == lazyListState.firstVisibleItemIndex + wheelConfiguration.outOfBoundsPageCount
}
}
ComposeScope {
Text(
text = item?.format().orEmpty(),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
textAlign = TextAlign.Center,
style = resolvedTextStyle,
color = if (isSelected) selectedTextColor else unselectedTextColor.copy(alpha = 0.62f),
modifier = kotlin.run {
val rowModifier = fullWidthModifier
.height(itemHeight)
.padding(vertical = verticalPadding)
if (isSelected && enableEditing) {
rowModifier.clickable(
onClick = { editing = true },
indication = ripple(bounded = false),
interactionSource = remember { MutableInteractionSource() },
)
} else rowModifier
},
)
}
}
}
AnimatedVisibility(
visible = editing,
enter = fadeIn(),
exit = fadeOut(),
modifier = Modifier.align(Alignment.Center),
) {
val focusRequester = remember { FocusRequester() }
SideEffect(key1 = Unit) {
focusRequester.requestFocus()
}
ComposeScope {
TextField(
modifier = fullWidthModifier
.height(itemHeight)
.clip(selectionShape)
.focusRequester(focusRequester)
.onFocusChanged { focusState ->
when {
focusState.isFocused -> hasEditingFocus = true
hasEditingFocus -> dismissEditing()
}
},
value = editValue,
onValueChange = { enteredValue ->
editValue = enteredValue
val parsedValue = parse?.invoke(enteredValue.text)
if (parsedValue != null && parsedValue in getValues()) {
onIsErrorChange(false)
if (parsedValue != getValue()) onValueChange(parsedValue)
} else onIsErrorChange(true)
},
textStyle = resolvedTextStyle.copy(textAlign = TextAlign.Center),
keyboardOptions = KeyboardOptions.Default.copy(
keyboardType = keyboardType,
imeAction = ImeAction.Done,
),
keyboardActions = KeyboardActions(onDone = { dismissEditing() }),
singleLine = true,
isError = getIsError(),
colors = TextFieldDefaults.colors(
focusedContainerColor = selectionContainerColor,
unfocusedContainerColor = selectionContainerColor,
errorContainerColor = selectionContainerColor,
focusedIndicatorColor = Color.Transparent,
unfocusedIndicatorColor = Color.Transparent,
errorIndicatorColor = Color.Transparent,
focusedTextColor = selectedTextColor,
unfocusedTextColor = selectedTextColor,
errorTextColor = MaterialTheme.colorScheme.error,
),
)
}
}
}
}
}
private data class WheelConfiguration(
val listSize: Int,
val outOfBoundsPageCount: Int,
val iteration: Int,
) {
constructor(
listSize: Int,
beyondViewportPageCount: Int,
wrapSelectorWheel: Boolean,
) : this(
listSize = listSize,
outOfBoundsPageCount = beyondViewportPageCount.fastCoerceIn(0, listSize / 2),
iteration = if (wrapSelectorWheel) (Int.MAX_VALUE - 2 * beyondViewportPageCount.fastCoerceIn(0, listSize / 2)) / listSize else 1,
)
val visibleItemsCount: Int = 1 + outOfBoundsPageCount * 2
val listStartIndex: Int = outOfBoundsPageCount
val listEndIndex: Int = listStartIndex + iteration * listSize
val itemCount: Int = listEndIndex + outOfBoundsPageCount
fun scrollIndexFor(valueIndex: Int): Int = valueIndex + listSize * (iteration / 2)
}
@Preview(widthDp = 300)
@Composable
private fun Preview1() {
MaterialTheme {
Surface(color = MaterialTheme.colorScheme.primary) {
var value by remember { mutableStateOf(now().date) }
val list = remember {
buildList {
repeat(10) {
add(now().date.minusDays((it - 5).toLong()))
}
}
}
val locale = LocalLocale.current.platformLocale
ListPicker(
getValue = { value },
getValues = { list },
onValueChange = { value = it },
getIsError = { false },
onIsErrorChange = {},
wrapSelectorWheel = true,
format = { format(formatStyle = FormatStyle.MEDIUM, locale = locale) },
textStyle = MaterialTheme.typography.labelLarge,
verticalPadding = 8.dp,
keyboardType = KeyboardType.Number,
)
}
}
}
@Preview(widthDp = 100)
@Composable
private fun Preview2() {
MaterialTheme {
Surface(color = MaterialTheme.colorScheme.tertiary) {
var value by remember { mutableStateOf("5") }
val values = remember { (1..10).map { it.toString() } }
ListPicker(
getValue = { value },
getValues = { values },
onValueChange = { value = it },
getIsError = { false },
onIsErrorChange = {},
beyondViewportPageCount = 2,
textStyle = MaterialTheme.typography.labelLarge,
verticalPadding = 8.dp,
keyboardType = KeyboardType.Number,
)
}
}
}
@Preview
@Composable
private fun Preview3() {
MaterialTheme {
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 = fullWidthModifier.padding(8.dp),
style = MaterialTheme.typography.bodyMedium,
)
}
Spacer(modifier = Modifier.height(16.dp))
Surface {
ListPicker(
getValue = { value },
getValues = { list },
onValueChange = { value = it },
getIsError = { true },
onIsErrorChange = {},
parse = {
try {
takeIf { it.matches(integerOnlyRegex) }?.toInt()
} catch (_: NumberFormatException) {
null
}
},
beyondViewportPageCount = 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())
@Stable
fun Modifier.fadingEdge(brush: Brush, getIsEnabled: () -> Boolean = { true }): Modifier = graphicsLayer(compositingStrategy = CompositingStrategy.Offscreen).drawWithContent {
drawContent()
if (getIsEnabled()) drawRect(brush = brush, blendMode = BlendMode.DstIn)
}
@inidamleader

inidamleader commented Jan 14, 2024 •

Copy link
Copy Markdown
Author

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

@iadtgar

iadtgar commented Jun 4, 2024 •

Copy link
Copy Markdown

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

@inidamleader

Copy link
Copy Markdown
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

iadtgar commented Jun 4, 2024

Copy link
Copy Markdown

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

inidamleader commented Jun 5, 2024 •

Copy link
Copy Markdown
Author

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

inidamleader commented Jun 6, 2024 •

Copy link
Copy Markdown
Author

@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

iadtgar commented Jun 6, 2024

Copy link
Copy Markdown

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
Copy Markdown
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
Copy Markdown
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

@oochanov

Copy link
Copy Markdown

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

Try to use the default font. I use Inter font in the project and had the same issue. I tried to remove the line with textStyle and it worked

@nasserkhosravi

Copy link
Copy Markdown

What does ComposeScope and InvokedEffect refer to?

@inidamleader

inidamleader commented Sep 3, 2024 •

Copy link
Copy Markdown
Author

@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
Copy Markdown

@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
Copy Markdown
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

inidamleader commented Oct 13, 2024 •

Copy link
Copy Markdown
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.

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

@ddekanski

Copy link
Copy Markdown

@inidamleader a great piece of work, thank you! Didn't you think about releasing it as a library? Maintenance and issue tracking would be easier, plus more people could get involved.

@mainrs

mainrs commented Mar 20, 2025 •

Copy link
Copy Markdown

Hey! I have issues with updating the ListPicker state on data loads. This is my UI component and the associated state.

@Composable
private fun WeightPicker(
    modifier: Modifier = Modifier,
    state: WeightPickerState
) {
    Log.d("WP", "recompose: ${state.kilos}.${state.grams} kg")

    Row(
        modifier = modifier,
        horizontalArrangement = Arrangement.Center,
        verticalAlignment = Alignment.CenterVertically,
    ) {
        Spacer(modifier = Modifier.weight(1f))
        ListPicker(
            modifier = Modifier.weight(1f),
            initialValue = state.kilos,
            values = (1..300).toList(),
            onValueChange = { state.kilos = it },
        )
        Text(text = ".")
        ListPicker(
            modifier = Modifier.weight(1f),
            initialValue = state.grams,
            values = (0..9).toList(),
            onValueChange = { state.grams = it },
        )
        Spacer(modifier = Modifier.weight(1f))
    }
}

@Stable
private class WeightPickerState(
    defaultKilos: Int = 75,
    defaultGrams: Int = 0,
) {
    var kilos by mutableIntStateOf(defaultKilos)

    var grams by mutableIntStateOf(defaultGrams)

    val concatenatedDouble: Double
        get() {
            return kilos + (grams / 10.0)
        }

    fun updateWeight(weight: Double) {
        Log.i("WEIGHT", "new weight: $weight")

        val newKilos = weight.toInt()
        val newGrams = ((weight - weight.toInt()) * 10).toInt()

        if (kilos != newKilos) {
            Log.i("WEIGHT", "updated kilos: $kilos -> $newKilos")
            kilos = newKilos
        }
        if (grams != newGrams) {
            Log.i("WEIGHT", "updated grams: $grams -> $newGrams")
            grams = newGrams
        }
    }
}

@Composable
private fun rememberWeightPickerState(initial: Double): WeightPickerState {
    val state by remember { mutableStateOf(WeightPickerState()) }

    LaunchedEffect(initial) {
        Log.d("WeightPicker", "Updating weight picker with: $initial")
        state.updateWeight(initial)
    }

    return state
}

// Usage.
val latestWeight by viewModel.latestWeight.collectAsState(initial = 75.0)
val weightPickerState = rememberWeightPickerState(initial = latestWeight)

WeightPicker(
                    modifier = Modifier.fillMaxWidth(),
                    state = weightPickerState,
)

I see log output for the initial value of 75.0 and the latest weight fetched with 161.0. But the UI never updates. It looks like ListPicker does not update on initialValue changes to me. Is that the case?

recompose: 75.0 kg
...
recompose: 161.0 kg

I think the recomposition chain works, just that the ListPicker doesn't update its state.

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