-
-
Save inidamleader/7bcc273afe6b885738556d190582a815 to your computer and use it in GitHub Desktop.
| 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) | |
| } |
Thanks for this. Can you also post the code for your util.compose.geometry.toDp.Thanks
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 this. Can you also post the code for your
util.compose.geometry.toDp.Thanksval 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?
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.
@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
Thank you, the last thing that I noticed - If you use some values from
MaterialTheme.typographysuch asMaterialTheme.typography.bodyLargethe initial state of the list isn't showing the selected item in the center. Once you scroll the list it seems to correct itself
Please give me also the code to be able to reproduce this issue
Thank you, the last thing that I noticed - If you use some values from
MaterialTheme.typographysuch asMaterialTheme.typography.bodyLargethe initial state of the list isn't showing the selected item in the center. Once you scroll the list it seems to correct itself
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
What does ComposeScope and InvokedEffect refer to?
@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.
@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 ...
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.
@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!
@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.
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.




Uh oh!
There was an error while loading. Please reload this page.