Android Jetpack Compose NumberPicker Widget Equivalent

13.9k views Asked by At

What is the recommended solution for creating a NumberPicker Widget in Jetpack Compose? Similar to the image below. I am able to create an NumberPicker using an AndroidView within my composable but the view does not seem to allow flings or snap to position. Btw the UI below shows three NumberPickers placed in a row. It is not supposed to represent a DatePicker

enter image description here

5

There are 5 answers

2
nglauber On BEST ANSWER

By coincidence I've implemented a screen like that last week. I can't share the whole code here, but basically what I did was:

  1. Create a layout with a DatePicker (res/layout/date_picker.xml).
<DatePicker xmlns:android="http://schemas.android.com/apk/res/android"
    android:id="@+id/datePicker"
    android:theme="@style/DatePickerStyle"
    android:layout_width="wrap_content"
    android:layout_height="wrap_content"
    android:calendarViewShown="false"
    android:datePickerMode="spinner" />
  1. Then, use it in your composable function.
@Composable
fun DatePicker(
    onDateSelected: (Date) -> Unit
) {
    AndroidView(
        modifier = Modifier.fillMaxWidth(),
        factory = { context ->
            val view = LayoutInflater.from(context).inflate(R.layout.date_picker, null)
            val datePicker = view.findViewById<DatePicker>(R.id.datePicker)
            val calendar = Calendar.getInstance() // show today by default
            datePicker.init(
                calendar.get(Calendar.YEAR),
                calendar.get(Calendar.MONTH),
                calendar.get(Calendar.DAY_OF_MONTH)
            ) { _, year, monthOfYear, dayOfMonth ->
                val date = Calendar.getInstance().apply {
                    set(year, monthOfYear, dayOfMonth)
                }.time
                onSelectedDateUpdate(date)
            }
            datePicker
        }
    )
}
  1. Finally, use it in a ModalBottomSheetLayout

Editing my answer... Using a NumberPicker working as well...

AndroidView(
    modifier = Modifier.fillMaxWidth(),
    factory = { context ->
        NumberPicker(context).apply {
            setOnValueChangedListener { numberPicker, i, i2 ->  }
            minValue = 0
            maxValue = 50
        }
    }
)

Here is the result.

enter image description here

0
Rafiul On

I know maybe you are not looking for something like this. But since there is no such widget in compose yet and compose is all about making your way easier to build your own component. So Apart from android.widget NumberPicker, you can make something like this one. You can change the visualization more like the NumberPicker widget and add your callback and stuff.

Have you checked this one on github? ComposeNumberPicker.Kt

2
SoftwareGuy On

We are using this library in our compose project for number picker widget. https://github.com/ChargeMap/Compose-NumberPicker

5
nhcodes On

I implemented a NumberPicker in Jetpack Compose (without using AndroidView): https://gist.github.com/nhcodes/dc68c65ee586628fda5700911e44543f

Picker.kt

@OptIn(ExperimentalFoundationApi::class)
@Composable
fun Picker(
    items: List<String>,
    state: PickerState = rememberPickerState(),
    modifier: Modifier = Modifier,
    startIndex: Int = 0,
    visibleItemsCount: Int = 3,
    textModifier: Modifier = Modifier,
    textStyle: TextStyle = LocalTextStyle.current,
    dividerColor: Color = LocalContentColor.current,
) {

    val visibleItemsMiddle = visibleItemsCount / 2
    val listScrollCount = Integer.MAX_VALUE
    val listScrollMiddle = listScrollCount / 2
    val listStartIndex = listScrollMiddle - listScrollMiddle % items.size - visibleItemsMiddle + startIndex

    fun getItem(index: Int) = items[index % items.size]

    val listState = rememberLazyListState(initialFirstVisibleItemIndex = listStartIndex)
    val flingBehavior = rememberSnapFlingBehavior(lazyListState = listState)

    val itemHeightPixels = remember { mutableStateOf(0) }
    val itemHeightDp = pixelsToDp(itemHeightPixels.value)

    val fadingEdgeGradient = remember {
        Brush.verticalGradient(
            0f to Color.Transparent,
            0.5f to Color.Black,
            1f to Color.Transparent
        )
    }

    LaunchedEffect(listState) {
        snapshotFlow { listState.firstVisibleItemIndex }
            .map { index -> getItem(index + visibleItemsMiddle) }
            .distinctUntilChanged()
            .collect { item -> state.selectedItem = item }
    }

    Box(modifier = modifier) {

        LazyColumn(
            state = listState,
            flingBehavior = flingBehavior,
            horizontalAlignment = Alignment.CenterHorizontally,
            modifier = Modifier
                .fillMaxWidth()
                .height(itemHeightDp * visibleItemsCount)
                .fadingEdge(fadingEdgeGradient)
        ) {
            items(listScrollCount) { index ->
                Text(
                    text = getItem(index),
                    maxLines = 1,
                    overflow = TextOverflow.Ellipsis,
                    style = textStyle,
                    modifier = Modifier
                        .onSizeChanged { size -> itemHeightPixels.value = size.height }
                        .then(textModifier)
                )
            }
        }

        Divider(
            color = dividerColor,
            modifier = Modifier.offset(y = itemHeightDp * visibleItemsMiddle)
        )

        Divider(
            color = dividerColor,
            modifier = Modifier.offset(y = itemHeightDp * (visibleItemsMiddle + 1))
        )

    }

}

private fun Modifier.fadingEdge(brush: Brush) = this
    .graphicsLayer(compositingStrategy = CompositingStrategy.Offscreen)
    .drawWithContent {
        drawContent()
        drawRect(brush = brush, blendMode = BlendMode.DstIn)
    }

@Composable
private fun pixelsToDp(pixels: Int) = with(LocalDensity.current) { pixels.toDp() }

PickerState.kt

@Composable
fun rememberPickerState() = remember { PickerState() }

class PickerState {
    var selectedItem by mutableStateOf("")
}

PickerExample.kt

@Composable
fun PickerExample() {
    Surface(modifier = Modifier.fillMaxSize()) {
        Column(
            horizontalAlignment = Alignment.CenterHorizontally,
            verticalArrangement = Arrangement.Center,
            modifier = Modifier.fillMaxSize()
        ) {

            val values = remember { (1..99).map { it.toString() } }
            val valuesPickerState = rememberPickerState()
            val units = remember { listOf("seconds", "minutes", "hours") }
            val unitsPickerState = rememberPickerState()

            Text(text = "Example Picker", modifier = Modifier.padding(top = 16.dp))
            Row(modifier = Modifier.fillMaxWidth()) {
                Picker(
                    state = valuesPickerState,
                    items = values,
                    visibleItemsCount = 3,
                    modifier = Modifier.weight(0.3f),
                    textModifier = Modifier.padding(8.dp),
                    textStyle = TextStyle(fontSize = 32.sp)
                )
                Picker(
                    state = unitsPickerState,
                    items = units,
                    visibleItemsCount = 3,
                    modifier = Modifier.weight(0.7f),
                    textModifier = Modifier.padding(8.dp),
                    textStyle = TextStyle(fontSize = 32.sp)
                )
            }

            Text(
                text = "Interval: ${valuesPickerState.selectedItem} ${unitsPickerState.selectedItem}",
                modifier = Modifier.padding(vertical = 16.dp)
            )

        }
    }
}

Preview:

Preview

3
Inidam Leader On

This is a generic version with wrapSelectorWheel and onValueChange parameters, and possibility of editing, that works for all types of list.

Previews

ListPicker:

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.platform.LocalDensity
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.input.ImeAction
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.sp
import com.inidamleader.ovtracker.layer.ui.theme.OvTrackerTheme
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 list An immutable wrapper containing 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 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]
 */
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun <E> ListPicker(
    initialValue: E,
    list: ImmutableWrapper<List<E>>,
    modifier: Modifier = Modifier,
    wrapSelectorWheel: Boolean = false,
    format: E.() -> String = { toString() },
    onValueChange: (E) -> 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
) {
    val items = list.value
    val listSize = items.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 ->
        items.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 = list) {
        snapshotFlow { lazyListState.firstVisibleItemIndex }.collectLatest {
            onValueChange(items[it % listSize])
        }
    }
    val itemHeight = with(LocalDensity.current) {
        textStyle.lineHeight.toDp()
    } + verticalPadding * 2
    var edit by rememberSaveable { mutableStateOf(false) }
    Box(
        modifier = modifier,
        contentAlignment = Alignment.Center,
    ) {
        AnimatedContent(
            targetState = edit,
            label = "AnimatedContent",
        ) { showTextField ->
            if (showTextField) {
                var isError by rememberSaveable { mutableStateOf(false) }
                val initialSelectedItem = remember {
                    items[lazyListState.firstVisibleItemIndex % listSize]
                }
                var value by rememberSaveable {
                    mutableStateOf(initialSelectedItem.format())
                }
                val focusRequester = remember { FocusRequester() }
                LaunchedEffect(key1 = Unit) {
                    focusRequester.requestFocus()
                }
                val coroutineScope = rememberCoroutineScope()
                TextField(
                    modifier = Modifier
                        .fillMaxWidth()
                        .focusRequester(focusRequester),
                    value = value,
                    onValueChange = { string ->
                        value = string
                        parse?.invoke(string).let { item ->
                            isError =
                                if (item != null)
                                    if (items.contains(item)) false
                                    else true // item not found
                                else true // string not parcelable

                            if (isError) onValueChange(initialSelectedItem)
                            else onValueChange(item ?: initialSelectedItem)
                        }
                    },
                    textStyle = textStyle.copy(textAlign = TextAlign.Center),
                    keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
                    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) items[(index - coercedOutOfBoundsPageCount + listSize) % listSize].format() else "",
                                maxLines = 1,
                                overflow = TextOverflow.Ellipsis,
                                style = textStyle,
                                modifier = textModifier,
                            )

                            in intervals[1]..<intervals[2] -> {
                                Text(
                                    text = items[(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) items[(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,
                list = list.toImmutableWrapper(),
                wrapSelectorWheel = true,
                format = {
                    format(
                        DateTimeFormatter
                            .ofLocalizedDate(FormatStyle.MEDIUM)
                            .withLocale(Locale.getDefault()),
                    )
                },
                onValueChange = { value = it },
                textStyle = TextStyle(fontSize = 32.sp),
                verticalPadding = 8.dp,
            )
        }
    }
}


@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,
                list = list.toImmutableWrapper(),
                modifier = Modifier,
                onValueChange = { value = it },
                outOfBoundsPageCount = 2,
                textStyle = TextStyle(fontSize = 32.sp),
                verticalPadding = 8.dp,
            )
        }
    }
}

@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 = TextStyle(fontSize = 32.sp).copy(
                        textAlign = TextAlign.Center,
                    ),
                )
            }

            Spacer(modifier = Modifier.height(16.dp))

            Surface {
                ListPicker(
                    initialValue = value,
                    list = list.toImmutableWrapper(),
                    format = { this.toString() },
                    onValueChange = { value = it },
                    parse = {
                        takeIf {
                            // check if each input string contains only integers
                            it.matches(Regex("^\\d+\$"))
                        }?.toInt()
                    },
                    outOfBoundsPageCount = 2,
                    textStyle = TextStyle(fontSize = 32.sp),
                    verticalPadding = 8.dp,
                )
            }
        }
    }
}

ImmutableWrapper.kt:

package com.inidamleader.ovtracker.util.compose

import androidx.compose.runtime.Immutable
import kotlin.reflect.KProperty

@Immutable
data class ImmutableWrapper<T>(val value: T)

fun <T> T.toImmutableWrapper() = ImmutableWrapper(this)

operator fun <T> ImmutableWrapper<T>.getValue(thisRef: Any?, property: KProperty<*>) = value

ModifierExt:

@Stable
fun Modifier.fadingEdge(brush: Brush) = this
    .graphicsLayer(compositingStrategy = CompositingStrategy.Offscreen)
    .drawWithContent {
        drawContent()
        drawRect(brush = brush, blendMode = BlendMode.DstIn)
    }